diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml deleted file mode 100644 index ed381453219..00000000000 --- a/.github/workflows/auto-merge.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Auto Merge or Create PR on Push - -on: - workflow_dispatch: - push: - branches: - - "sync/**" - -env: - CURRENT_BRANCH: ${{ github.ref_name }} - SOURCE_BRANCH: ${{ secrets.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce" - TARGET_BRANCH: ${{ secrets.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop - GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows - REVIEWER: ${{ secrets.SYNC_PR_REVIEWER }} - -jobs: - Check_Branch: - runs-on: ubuntu-latest - outputs: - BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }} - steps: - - name: Check if current branch matches the secret - id: check-branch - run: | - if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then - echo "MATCH=true" >> $GITHUB_OUTPUT - else - echo "MATCH=false" >> $GITHUB_OUTPUT - fi - - Auto_Merge: - if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }} - needs: [Check_Branch] - runs-on: ubuntu-latest - permissions: - pull-requests: write - contents: write - steps: - - name: Checkout code - uses: actions/checkout@v4.1.1 - with: - fetch-depth: 0 # Fetch all history for all branches and tags - - - name: Setup Git - run: | - git config user.name "GitHub Actions" - git config user.email "actions@github.com" - - - name: Setup GH CLI and Git Config - run: | - type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) - curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg - sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null - sudo apt update - sudo apt install gh -y - - - name: Check for merge conflicts - id: conflicts - run: | - git fetch origin $TARGET_BRANCH - git checkout $TARGET_BRANCH - # Attempt to merge the main branch into the current branch - if $(git merge --no-commit --no-ff $SOURCE_BRANCH); then - echo "No merge conflicts detected." - echo "HAS_CONFLICTS=false" >> $GITHUB_ENV - else - echo "Merge conflicts detected." - echo "HAS_CONFLICTS=true" >> $GITHUB_ENV - git merge --abort - fi - - - name: Merge Change to Target Branch - if: env.HAS_CONFLICTS == 'false' - run: | - git commit -m "Merge branch '$SOURCE_BRANCH' into $TARGET_BRANCH" - git push origin $TARGET_BRANCH - - - name: Create PR to Target Branch - if: env.HAS_CONFLICTS == 'true' - run: | - # Replace 'username' with the actual GitHub username of the reviewer. - PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "" --reviewer $REVIEWER) - echo "Pull Request created: $PR_URL" diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index 8644f04f02e..a46fd74d22c 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/create-sync-pr.yml @@ -1,28 +1,53 @@ -name: Create Sync Action +name: Create PR on Sync on: workflow_dispatch: push: branches: - - preview + - "sync/**" env: - SOURCE_BRANCH_NAME: ${{ github.ref_name }} + CURRENT_BRANCH: ${{ github.ref_name }} + SOURCE_BRANCH: ${{ vars.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce" + TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop + GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows + REVIEWER: ${{ vars.SYNC_PR_REVIEWER }} + ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }} + ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }} jobs: - sync_changes: + Check_Branch: + runs-on: ubuntu-latest + outputs: + BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }} + steps: + - name: Check if current branch matches the secret + id: check-branch + run: | + if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then + echo "MATCH=true" >> $GITHUB_OUTPUT + else + echo "MATCH=false" >> $GITHUB_OUTPUT + fi + Auto_Merge: + if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }} + needs: [Check_Branch] runs-on: ubuntu-latest permissions: pull-requests: write - contents: read + contents: write steps: - - name: Checkout Code + - name: Checkout code uses: actions/checkout@v4.1.1 with: - persist-credentials: false - fetch-depth: 0 + fetch-depth: 0 # Fetch all history for all branches and tags + + - name: Setup Git + run: | + git config user.name "$ACCOUNT_USER_NAME" + git config user.email "$ACCOUNT_USER_EMAIL" - - name: Setup GH CLI + - name: Setup GH CLI and Git Config run: | type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg @@ -31,25 +56,14 @@ jobs: sudo apt update sudo apt install gh -y - - name: Push Changes to Target Repo A - env: - GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} + - name: Create PR to Target Branch run: | - TARGET_REPO="${{ secrets.TARGET_REPO_A }}" - TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}" - SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" - - git checkout $SOURCE_BRANCH - git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git" - git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH - - - name: Push Changes to Target Repo B - env: - GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} - run: | - TARGET_REPO="${{ secrets.TARGET_REPO_B }}" - TARGET_BRANCH="${{ secrets.TARGET_REPO_B_BRANCH_NAME }}" - SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" - - git remote add target-origin-b "https://$GH_TOKEN@github.com/$TARGET_REPO.git" - git push target-origin-b $SOURCE_BRANCH:$TARGET_BRANCH + # get all pull requests and check if there is already a PR + PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $SOURCE_BRANCH --state open --json number | jq '.[] | .number') + if [ -n "$PR_EXISTS" ]; then + echo "Pull Request already exists: $PR_EXISTS" + else + echo "Creating new pull request" + PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "") + echo "Pull Request created: $PR_URL" + fi diff --git a/.github/workflows/repo-sync.yml b/.github/workflows/repo-sync.yml new file mode 100644 index 00000000000..9ac4771ef6e --- /dev/null +++ b/.github/workflows/repo-sync.yml @@ -0,0 +1,44 @@ +name: Sync Repositories + +on: + workflow_dispatch: + push: + branches: + - preview + +env: + SOURCE_BRANCH_NAME: ${{ github.ref_name }} + +jobs: + sync_changes: + runs-on: ubuntu-20.04 + permissions: + pull-requests: write + contents: read + steps: + - name: Checkout Code + uses: actions/checkout@v4.1.1 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Setup GH CLI + run: | + type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null + sudo apt update + sudo apt install gh -y + + - name: Push Changes to Target Repo + env: + GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} + run: | + TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}" + TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}" + SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" + + git checkout $SOURCE_BRANCH + git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git" + git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH diff --git a/apiserver/package.json b/apiserver/package.json index 2840f6befc0..d357d5cb4de 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.18.0" + "version": "0.19.0" } diff --git a/apiserver/plane/api/urls/issue.py b/apiserver/plane/api/urls/issue.py index 070ea8bd9c3..5ce9db85c70 100644 --- a/apiserver/plane/api/urls/issue.py +++ b/apiserver/plane/api/urls/issue.py @@ -6,9 +6,15 @@ IssueLinkAPIEndpoint, IssueCommentAPIEndpoint, IssueActivityAPIEndpoint, + WorkspaceIssueAPIEndpoint, ) urlpatterns = [ + path( + "workspaces//issues/-/", + WorkspaceIssueAPIEndpoint.as_view(), + name="issue-by-identifier", + ), path( "workspaces//projects//issues/", IssueAPIEndpoint.as_view(), diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 574ec69b6a9..d59b40fc590 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -3,6 +3,7 @@ from .state import StateAPIEndpoint from .issue import ( + WorkspaceIssueAPIEndpoint, IssueAPIEndpoint, LabelAPIEndpoint, IssueLinkAPIEndpoint, diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 53998c49fcd..8d72ac5db72 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -51,6 +51,65 @@ from .base import BaseAPIView, WebhookMixin + +class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView): + """ + This viewset provides `retrieveByIssueId` on workspace level + + """ + + model = Issue + webhook_event = "issue" + permission_classes = [ + ProjectEntityPermission + ] + serializer_class = IssueSerializer + + + @property + def project__identifier(self): + return self.kwargs.get("project__identifier", None) + + def get_queryset(self): + return ( + Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project__identifier=self.kwargs.get("project__identifier")) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(self.kwargs.get("order_by", "-created_at")) + ).distinct() + + def get(self, request, slug, project__identifier=None, issue__identifier=None): + if issue__identifier and project__identifier: + issue = Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ).get(workspace__slug=slug, project__identifier=project__identifier, sequence_id=issue__identifier) + return Response( + IssueSerializer( + issue, + fields=self.fields, + expand=self.expand, + ).data, + status=status.HTTP_200_OK, + ) + class IssueAPIEndpoint(WebhookMixin, BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, @@ -282,7 +341,7 @@ def patch(self, request, slug, project_id, pk=None): ) if serializer.is_valid(): if ( - str(request.data.get("external_id")) + request.data.get("external_id") and (issue.external_id != str(request.data.get("external_id"))) and Issue.objects.filter( project_id=project_id, diff --git a/apiserver/plane/app/permissions/project.py b/apiserver/plane/app/permissions/project.py index 2ba2a1b64ad..25e5aaeb057 100644 --- a/apiserver/plane/app/permissions/project.py +++ b/apiserver/plane/app/permissions/project.py @@ -79,6 +79,16 @@ def has_permission(self, request, view): if request.user.is_anonymous: return False + # Handle requests based on project__identifier + if hasattr(view, "project__identifier") and view.project__identifier: + if request.method in SAFE_METHODS: + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project__identifier=view.project__identifier, + is_active=True, + ).exists() + ## Safe Methods -> Handle the filtering logic in queryset if request.method in SAFE_METHODS: return ProjectMember.objects.filter( diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index dfdd265cd92..687747242a4 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -210,6 +210,7 @@ class Meta: "backlog_issues", "created_at", "updated_at", + "archived_at", ] read_only_fields = fields diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 2cdab312cec..3d7603e240f 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -38,7 +38,7 @@ WorkSpaceAvailabilityCheckEndpoint, UserWorkspaceDashboardEndpoint, WorkspaceThemeViewSet, - ExportWorkspaceUserActivityEndpoint + ExportWorkspaceUserActivityEndpoint, ) from .workspace.member import ( @@ -91,12 +91,14 @@ CycleDateCheckEndpoint, CycleFavoriteViewSet, TransferCycleIssueEndpoint, - CycleArchiveUnarchiveEndpoint, CycleUserPropertiesEndpoint, ) from .cycle.issue import ( CycleIssueViewSet, ) +from .cycle.archive import ( + CycleArchiveUnarchiveEndpoint, +) from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet from .issue.base import ( @@ -170,7 +172,6 @@ ModuleViewSet, ModuleLinkViewSet, ModuleFavoriteViewSet, - ModuleArchiveUnarchiveEndpoint, ModuleUserPropertiesEndpoint, ) @@ -178,6 +179,10 @@ ModuleIssueViewSet, ) +from .module.archive import ( + ModuleArchiveUnarchiveEndpoint, +) + from .api import ApiTokenEndpoint diff --git a/apiserver/plane/app/views/cycle/archive.py b/apiserver/plane/app/views/cycle/archive.py new file mode 100644 index 00000000000..e6d82795a98 --- /dev/null +++ b/apiserver/plane/app/views/cycle/archive.py @@ -0,0 +1,409 @@ +# Django imports +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import ( + Case, + CharField, + Count, + Exists, + F, + Func, + OuterRef, + Prefetch, + Q, + UUIDField, + Value, + When, +) +from django.db.models.functions import Coalesce +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import ( + Cycle, + CycleFavorite, + Issue, + Label, + User, +) +from plane.utils.analytics_plot import burndown_plot + +# Module imports +from .. import BaseAPIView + + +class CycleArchiveUnarchiveEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def get_queryset(self): + favorite_subquery = CycleFavorite.objects.filter( + user=self.request.user, + cycle_id=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + return ( + Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(archived_at__isnull=False) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("project", "workspace", "owned_by") + .prefetch_related( + Prefetch( + "issue_cycle__issue__assignees", + queryset=User.objects.only( + "avatar", "first_name", "id" + ).distinct(), + ) + ) + .prefetch_related( + Prefetch( + "issue_cycle__issue__labels", + queryset=Label.objects.only( + "name", "color", "id" + ).distinct(), + ) + ) + .annotate(is_favorite=Exists(favorite_subquery)) + .annotate( + total_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + status=Case( + When( + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), + then=Value("CURRENT"), + ), + When( + start_date__gt=timezone.now(), then=Value("UPCOMING") + ), + When(end_date__lt=timezone.now(), then=Value("COMPLETED")), + When( + Q(start_date__isnull=True) & Q(end_date__isnull=True), + then=Value("DRAFT"), + ), + default=Value("DRAFT"), + output_field=CharField(), + ) + ) + .annotate( + assignee_ids=Coalesce( + ArrayAgg( + "issue_cycle__issue__assignees__id", + distinct=True, + filter=~Q( + issue_cycle__issue__assignees__id__isnull=True + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + .order_by("-is_favorite", "name") + .distinct() + ) + + def get(self, request, slug, project_id, pk=None): + if pk is None: + queryset = ( + self.get_queryset() + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "total_issues", + "is_favorite", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + "archived_at", + ) + ).order_by("-is_favorite", "-created_at") + return Response(queryset, status=status.HTTP_200_OK) + else: + queryset = ( + self.get_queryset() + .filter(archived_at__isnull=False) + .filter(pk=pk) + ) + data = ( + self.get_queryset() + .filter(pk=pk) + .annotate( + sub_issues=Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id"), + parent__isnull=False, + issue_cycle__cycle_id=pk, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + "sub_issues", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) + .first() + ) + queryset = queryset.first() + + if data is None: + return Response( + {"error": "Cycle does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Assignee Distribution + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .annotate(display_name=F("assignees__display_name")) + .values( + "first_name", + "last_name", + "assignee_id", + "avatar", + "display_name", + ) + .annotate( + total_issues=Count( + "id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + # Label Distribution + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + data["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + if queryset.start_date and queryset.end_date: + data["distribution"]["completion_chart"] = burndown_plot( + queryset=queryset, + slug=slug, + project_id=project_id, + cycle_id=pk, + ) + + return Response( + data, + status=status.HTTP_200_OK, + ) + + def post(self, request, slug, project_id, cycle_id): + cycle = Cycle.objects.get( + pk=cycle_id, project_id=project_id, workspace__slug=slug + ) + + if cycle.end_date >= timezone.now().date(): + return Response( + {"error": "Only completed cycles can be archived"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + cycle.archived_at = timezone.now() + cycle.save() + return Response( + {"archived_at": str(cycle.archived_at)}, + status=status.HTTP_200_OK, + ) + + def delete(self, request, slug, project_id, cycle_id): + cycle = Cycle.objects.get( + pk=cycle_id, project_id=project_id, workspace__slug=slug + ) + cycle.archived_at = None + cycle.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 58dd9891f4f..dd9826c56b0 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -1,10 +1,9 @@ # Python imports import json +# Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField - -# Django imports from django.db.models import ( Case, CharField, @@ -25,7 +24,6 @@ # Third party imports from rest_framework import status from rest_framework.response import Response - from plane.app.permissions import ( ProjectEntityPermission, ProjectLitePermission, @@ -686,380 +684,6 @@ def destroy(self, request, slug, project_id, pk): return Response(status=status.HTTP_204_NO_CONTENT) -class CycleArchiveUnarchiveEndpoint(BaseAPIView): - - permission_classes = [ - ProjectEntityPermission, - ] - - def get_queryset(self): - favorite_subquery = CycleFavorite.objects.filter( - user=self.request.user, - cycle_id=OuterRef("pk"), - project_id=self.kwargs.get("project_id"), - workspace__slug=self.kwargs.get("slug"), - ) - return ( - Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(archived_at__isnull=False) - .filter( - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - ) - .filter(project__archived_at__isnull=True) - .select_related("project", "workspace", "owned_by") - .prefetch_related( - Prefetch( - "issue_cycle__issue__assignees", - queryset=User.objects.only( - "avatar", "first_name", "id" - ).distinct(), - ) - ) - .prefetch_related( - Prefetch( - "issue_cycle__issue__labels", - queryset=Label.objects.only( - "name", "color", "id" - ).distinct(), - ) - ) - .annotate(is_favorite=Exists(favorite_subquery)) - .annotate( - total_issues=Count( - "issue_cycle__issue__id", - distinct=True, - filter=Q( - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - completed_issues=Count( - "issue_cycle__issue__id", - distinct=True, - filter=Q( - issue_cycle__issue__state__group="completed", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - cancelled_issues=Count( - "issue_cycle__issue__id", - distinct=True, - filter=Q( - issue_cycle__issue__state__group="cancelled", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - started_issues=Count( - "issue_cycle__issue__id", - distinct=True, - filter=Q( - issue_cycle__issue__state__group="started", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - unstarted_issues=Count( - "issue_cycle__issue__id", - distinct=True, - filter=Q( - issue_cycle__issue__state__group="unstarted", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - backlog_issues=Count( - "issue_cycle__issue__id", - distinct=True, - filter=Q( - issue_cycle__issue__state__group="backlog", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - status=Case( - When( - Q(start_date__lte=timezone.now()) - & Q(end_date__gte=timezone.now()), - then=Value("CURRENT"), - ), - When( - start_date__gt=timezone.now(), then=Value("UPCOMING") - ), - When(end_date__lt=timezone.now(), then=Value("COMPLETED")), - When( - Q(start_date__isnull=True) & Q(end_date__isnull=True), - then=Value("DRAFT"), - ), - default=Value("DRAFT"), - output_field=CharField(), - ) - ) - .annotate( - assignee_ids=Coalesce( - ArrayAgg( - "issue_cycle__issue__assignees__id", - distinct=True, - filter=~Q( - issue_cycle__issue__assignees__id__isnull=True - ), - ), - Value([], output_field=ArrayField(UUIDField())), - ) - ) - .order_by("-is_favorite", "name") - .distinct() - ) - - def get(self, request, slug, project_id, pk=None): - if pk is None: - queryset = ( - self.get_queryset() - .annotate( - total_issues=Count( - "issue_cycle", - filter=Q( - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .values( - # necessary fields - "id", - "workspace_id", - "project_id", - # model fields - "name", - "description", - "start_date", - "end_date", - "owned_by_id", - "view_props", - "sort_order", - "external_source", - "external_id", - "progress_snapshot", - # meta fields - "total_issues", - "is_favorite", - "cancelled_issues", - "completed_issues", - "started_issues", - "unstarted_issues", - "backlog_issues", - "assignee_ids", - "status", - "archived_at", - ) - ).order_by("-is_favorite", "-created_at") - return Response(queryset, status=status.HTTP_200_OK) - else: - queryset = ( - self.get_queryset() - .filter(archived_at__isnull=False) - .filter(pk=pk) - ) - data = ( - self.get_queryset() - .filter(pk=pk) - .annotate( - sub_issues=Issue.issue_objects.filter( - project_id=self.kwargs.get("project_id"), - parent__isnull=False, - issue_cycle__cycle_id=pk, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .values( - # necessary fields - "id", - "workspace_id", - "project_id", - # model fields - "name", - "description", - "start_date", - "end_date", - "owned_by_id", - "view_props", - "sort_order", - "external_source", - "external_id", - "progress_snapshot", - "sub_issues", - # meta fields - "is_favorite", - "total_issues", - "cancelled_issues", - "completed_issues", - "started_issues", - "unstarted_issues", - "backlog_issues", - "assignee_ids", - "status", - ) - .first() - ) - queryset = queryset.first() - - if data is None: - return Response( - {"error": "Cycle does not exist"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Assignee Distribution - assignee_distribution = ( - Issue.objects.filter( - issue_cycle__cycle_id=pk, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(first_name=F("assignees__first_name")) - .annotate(last_name=F("assignees__last_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .annotate(display_name=F("assignees__display_name")) - .values( - "first_name", - "last_name", - "assignee_id", - "avatar", - "display_name", - ) - .annotate( - total_issues=Count( - "id", - filter=Q(archived_at__isnull=True, is_draft=False), - ), - ) - .annotate( - completed_issues=Count( - "id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("first_name", "last_name") - ) - - # Label Distribution - label_distribution = ( - Issue.objects.filter( - issue_cycle__cycle_id=pk, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(label_name=F("labels__name")) - .annotate(color=F("labels__color")) - .annotate(label_id=F("labels__id")) - .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "id", - filter=Q(archived_at__isnull=True, is_draft=False), - ), - ) - .annotate( - completed_issues=Count( - "id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - - data["distribution"] = { - "assignees": assignee_distribution, - "labels": label_distribution, - "completion_chart": {}, - } - - if queryset.start_date and queryset.end_date: - data["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset, - slug=slug, - project_id=project_id, - cycle_id=pk, - ) - - return Response( - data, - status=status.HTTP_200_OK, - ) - - def post(self, request, slug, project_id, cycle_id): - cycle = Cycle.objects.get( - pk=cycle_id, project_id=project_id, workspace__slug=slug - ) - - if cycle.end_date >= timezone.now().date(): - return Response( - {"error": "Only completed cycles can be archived"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - cycle.archived_at = timezone.now() - cycle.save() - return Response( - {"archived_at": str(cycle.archived_at)}, - status=status.HTTP_200_OK, - ) - - def delete(self, request, slug, project_id, cycle_id): - cycle = Cycle.objects.get( - pk=cycle_id, project_id=project_id, workspace__slug=slug - ) - cycle.archived_at = None - cycle.save() - return Response(status=status.HTTP_204_NO_CONTENT) - - class CycleDateCheckEndpoint(BaseAPIView): permission_classes = [ ProjectEntityPermission, diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index 2a5505dd055..9a029eb2570 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -38,7 +38,7 @@ ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters - +from plane.utils.user_timezone_converter import user_timezone_converter class CycleIssueViewSet(WebhookMixin, BaseViewSet): serializer_class = CycleIssueSerializer @@ -191,6 +191,11 @@ def list(self, request, slug, project_id, cycle_id): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) + return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id, cycle_id): diff --git a/apiserver/plane/app/views/dashboard/base.py b/apiserver/plane/app/views/dashboard/base.py index 33b3cf9d5b9..9558348d905 100644 --- a/apiserver/plane/app/views/dashboard/base.py +++ b/apiserver/plane/app/views/dashboard/base.py @@ -571,14 +571,16 @@ def dashboard_recent_collaborators(self, request, slug): return self.paginate( request=request, queryset=project_members_with_activities, - controller=self.get_results_controller, + controller=lambda qs: self.get_results_controller(qs, slug), ) class DashboardEndpoint(BaseAPIView): - def get_results_controller(self, project_members_with_activities): + def get_results_controller(self, project_members_with_activities, slug): user_active_issue_counts = ( - User.objects.filter(id__in=project_members_with_activities) + User.objects.filter( + id__in=project_members_with_activities, + ) .annotate( active_issue_count=Count( Case( @@ -587,10 +589,13 @@ def get_results_controller(self, project_members_with_activities): "unstarted", "started", ], - then=1, + issue_assignee__issue__workspace__slug=slug, + issue_assignee__issue__project__project_projectmember__is_active=True, + then=F("issue_assignee__issue__id"), ), output_field=IntegerField(), - ) + ), + distinct=True, ) ) .values("active_issue_count", user_id=F("id")) diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index d9274ae4fa3..af019a7ec62 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -47,7 +47,7 @@ ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters - +from plane.utils.user_timezone_converter import user_timezone_converter class IssueArchiveViewSet(BaseViewSet): permission_classes = [ @@ -239,6 +239,11 @@ def list(self, request, slug, project_id): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issue_queryset, datetime_fields, request.user.user_timezone + ) + return Response(issues, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, pk=None): diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index 23df585402f..7a0e5d9b1eb 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -50,6 +50,7 @@ Project, ) from plane.utils.issue_filters import issue_filters +from plane.utils.user_timezone_converter import user_timezone_converter # Module imports from .. import BaseAPIView, BaseViewSet, WebhookMixin @@ -241,6 +242,10 @@ def get(self, request, slug, project_id): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) return Response(issues, status=status.HTTP_200_OK) @@ -440,6 +445,10 @@ def list(self, request, slug, project_id): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issue_queryset, datetime_fields, request.user.user_timezone + ) return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): @@ -503,6 +512,10 @@ def create(self, request, slug, project_id): ) .first() ) + datetime_fields = ["created_at", "updated_at"] + issue = user_timezone_converter( + issue, datetime_fields, request.user.user_timezone + ) return Response(issue, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py index 62a0aa25cc5..fe75c61f1f0 100644 --- a/apiserver/plane/app/views/issue/draft.py +++ b/apiserver/plane/app/views/issue/draft.py @@ -1,6 +1,7 @@ # Python imports import json +# Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.core.serializers.json import DjangoJSONEncoder @@ -19,14 +20,12 @@ When, ) from django.db.models.functions import Coalesce - -# Django imports from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from rest_framework import status # Third Party imports +from rest_framework import status from rest_framework.response import Response from plane.app.permissions import ProjectEntityPermission @@ -46,6 +45,7 @@ Project, ) from plane.utils.issue_filters import issue_filters +from plane.utils.user_timezone_converter import user_timezone_converter # Module imports from .. import BaseViewSet @@ -230,6 +230,10 @@ def list(self, request, slug, project_id): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issue_queryset, datetime_fields, request.user.user_timezone + ) return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index da479e0e99e..2ee4574eb6d 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -31,6 +31,7 @@ IssueAttachment, ) from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.user_timezone_converter import user_timezone_converter from collections import defaultdict @@ -132,6 +133,10 @@ def get(self, request, slug, project_id, issue_id): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + sub_issues = user_timezone_converter( + sub_issues, datetime_fields, request.user.user_timezone + ) return Response( { "sub_issues": sub_issues, diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py new file mode 100644 index 00000000000..8a5345ff4c4 --- /dev/null +++ b/apiserver/plane/app/views/module/archive.py @@ -0,0 +1,362 @@ +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import ( + Count, + Exists, + F, + Func, + IntegerField, + OuterRef, + Prefetch, + Q, + Subquery, + UUIDField, + Value, +) +from django.db.models.functions import Coalesce +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from plane.app.permissions import ( + ProjectEntityPermission, +) +from plane.app.serializers import ( + ModuleDetailSerializer, +) +from plane.db.models import ( + Issue, + Module, + ModuleFavorite, + ModuleLink, +) +from plane.utils.analytics_plot import burndown_plot +from plane.utils.user_timezone_converter import user_timezone_converter + + +# Module imports +from .. import BaseAPIView + + +class ModuleArchiveUnarchiveEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def get_queryset(self): + favorite_subquery = ModuleFavorite.objects.filter( + user=self.request.user, + module_id=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + cancelled_issues = ( + Issue.issue_objects.filter( + state__group="cancelled", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + completed_issues = ( + Issue.issue_objects.filter( + state__group="completed", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + started_issues = ( + Issue.issue_objects.filter( + state__group="started", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + unstarted_issues = ( + Issue.issue_objects.filter( + state__group="unstarted", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + backlog_issues = ( + Issue.issue_objects.filter( + state__group="backlog", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + total_issues = ( + Issue.issue_objects.filter( + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + return ( + Module.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(archived_at__isnull=False) + .annotate(is_favorite=Exists(favorite_subquery)) + .select_related("workspace", "project", "lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related( + "module", "created_by" + ), + ) + ) + .annotate( + completed_issues=Coalesce( + Subquery(completed_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + cancelled_issues=Coalesce( + Subquery(cancelled_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + started_issues=Coalesce( + Subquery(started_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + unstarted_issues=Coalesce( + Subquery(unstarted_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + backlog_issues=Coalesce( + Subquery(backlog_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + total_issues=Coalesce( + Subquery(total_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + member_ids=Coalesce( + ArrayAgg( + "members__id", + distinct=True, + filter=~Q(members__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + .order_by("-is_favorite", "-created_at") + ) + + def get(self, request, slug, project_id, pk=None): + if pk is None: + queryset = self.get_queryset() + modules = queryset.values( # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "total_issues", + "is_favorite", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + "archived_at", + ) + datetime_fields = ["created_at", "updated_at"] + modules = user_timezone_converter( + modules, datetime_fields, request.user.user_timezone + ) + return Response(modules, status=status.HTTP_200_OK) + else: + queryset = ( + self.get_queryset() + .filter(pk=pk) + .annotate( + sub_issues=Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id"), + parent__isnull=False, + issue_module__module_id=pk, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + assignee_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(display_name=F("assignees__display_name")) + .annotate(avatar=F("assignees__avatar")) + .values( + "first_name", + "last_name", + "assignee_id", + "avatar", + "display_name", + ) + .annotate( + total_issues=Count( + "id", + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "id", + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ), + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + data = ModuleDetailSerializer(queryset.first()).data + data["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + # Fetch the modules + modules = queryset.first() + if modules and modules.start_date and modules.target_date: + data["distribution"]["completion_chart"] = burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + module_id=pk, + ) + + return Response( + data, + status=status.HTTP_200_OK, + ) + + def post(self, request, slug, project_id, module_id): + module = Module.objects.get( + pk=module_id, project_id=project_id, workspace__slug=slug + ) + if module.status not in ["completed", "cancelled"]: + return Response( + { + "error": "Only completed or cancelled modules can be archived" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + module.archived_at = timezone.now() + module.save() + return Response( + {"archived_at": str(module.archived_at)}, + status=status.HTTP_200_OK, + ) + + def delete(self, request, slug, project_id, module_id): + module = Module.objects.get( + pk=module_id, project_id=project_id, workspace__slug=slug + ) + module.archived_at = None + module.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index aaaf8fb6781..59f26a03646 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -48,6 +48,8 @@ Project, ) from plane.utils.analytics_plot import burndown_plot +from plane.utils.user_timezone_converter import user_timezone_converter + # Module imports from .. import BaseAPIView, BaseViewSet, WebhookMixin @@ -236,6 +238,10 @@ def create(self, request, slug, project_id): "updated_at", ) ).first() + datetime_fields = ["created_at", "updated_at"] + module = user_timezone_converter( + module, datetime_fields, request.user.user_timezone + ) return Response(module, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -277,6 +283,10 @@ def list(self, request, slug, project_id): "created_at", "updated_at", ) + datetime_fields = ["created_at", "updated_at"] + modules = user_timezone_converter( + modules, datetime_fields, request.user.user_timezone + ) return Response(modules, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, pk): @@ -454,6 +464,10 @@ def partial_update(self, request, slug, project_id, pk): "created_at", "updated_at", ).first() + datetime_fields = ["created_at", "updated_at"] + module = user_timezone_converter( + module, datetime_fields, request.user.user_timezone + ) return Response(module, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -515,325 +529,6 @@ def get_queryset(self): ) -class ModuleArchiveUnarchiveEndpoint(BaseAPIView): - - permission_classes = [ - ProjectEntityPermission, - ] - - def get_queryset(self): - favorite_subquery = ModuleFavorite.objects.filter( - user=self.request.user, - module_id=OuterRef("pk"), - project_id=self.kwargs.get("project_id"), - workspace__slug=self.kwargs.get("slug"), - ) - cancelled_issues = ( - Issue.issue_objects.filter( - state__group="cancelled", - issue_module__module_id=OuterRef("pk"), - ) - .values("issue_module__module_id") - .annotate(cnt=Count("pk")) - .values("cnt") - ) - completed_issues = ( - Issue.issue_objects.filter( - state__group="completed", - issue_module__module_id=OuterRef("pk"), - ) - .values("issue_module__module_id") - .annotate(cnt=Count("pk")) - .values("cnt") - ) - started_issues = ( - Issue.issue_objects.filter( - state__group="started", - issue_module__module_id=OuterRef("pk"), - ) - .values("issue_module__module_id") - .annotate(cnt=Count("pk")) - .values("cnt") - ) - unstarted_issues = ( - Issue.issue_objects.filter( - state__group="unstarted", - issue_module__module_id=OuterRef("pk"), - ) - .values("issue_module__module_id") - .annotate(cnt=Count("pk")) - .values("cnt") - ) - backlog_issues = ( - Issue.issue_objects.filter( - state__group="backlog", - issue_module__module_id=OuterRef("pk"), - ) - .values("issue_module__module_id") - .annotate(cnt=Count("pk")) - .values("cnt") - ) - total_issues = ( - Issue.issue_objects.filter( - issue_module__module_id=OuterRef("pk"), - ) - .values("issue_module__module_id") - .annotate(cnt=Count("pk")) - .values("cnt") - ) - return ( - Module.objects.filter(workspace__slug=self.kwargs.get("slug")) - .filter(archived_at__isnull=False) - .annotate(is_favorite=Exists(favorite_subquery)) - .select_related("workspace", "project", "lead") - .prefetch_related("members") - .prefetch_related( - Prefetch( - "link_module", - queryset=ModuleLink.objects.select_related( - "module", "created_by" - ), - ) - ) - .annotate( - completed_issues=Coalesce( - Subquery(completed_issues[:1]), - Value(0, output_field=IntegerField()), - ) - ) - .annotate( - cancelled_issues=Coalesce( - Subquery(cancelled_issues[:1]), - Value(0, output_field=IntegerField()), - ) - ) - .annotate( - started_issues=Coalesce( - Subquery(started_issues[:1]), - Value(0, output_field=IntegerField()), - ) - ) - .annotate( - unstarted_issues=Coalesce( - Subquery(unstarted_issues[:1]), - Value(0, output_field=IntegerField()), - ) - ) - .annotate( - backlog_issues=Coalesce( - Subquery(backlog_issues[:1]), - Value(0, output_field=IntegerField()), - ) - ) - .annotate( - total_issues=Coalesce( - Subquery(total_issues[:1]), - Value(0, output_field=IntegerField()), - ) - ) - .annotate( - member_ids=Coalesce( - ArrayAgg( - "members__id", - distinct=True, - filter=~Q(members__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ) - ) - .order_by("-is_favorite", "-created_at") - ) - - def get(self, request, slug, project_id, pk=None): - if pk is None: - queryset = self.get_queryset() - modules = queryset.values( # Required fields - "id", - "workspace_id", - "project_id", - # Model fields - "name", - "description", - "description_text", - "description_html", - "start_date", - "target_date", - "status", - "lead_id", - "member_ids", - "view_props", - "sort_order", - "external_source", - "external_id", - # computed fields - "total_issues", - "is_favorite", - "cancelled_issues", - "completed_issues", - "started_issues", - "unstarted_issues", - "backlog_issues", - "created_at", - "updated_at", - "archived_at", - ) - return Response(modules, status=status.HTTP_200_OK) - else: - queryset = ( - self.get_queryset() - .filter(pk=pk) - .annotate( - sub_issues=Issue.issue_objects.filter( - project_id=self.kwargs.get("project_id"), - parent__isnull=False, - issue_module__module_id=pk, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ) - assignee_distribution = ( - Issue.objects.filter( - issue_module__module_id=pk, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(first_name=F("assignees__first_name")) - .annotate(last_name=F("assignees__last_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate(display_name=F("assignees__display_name")) - .annotate(avatar=F("assignees__avatar")) - .values( - "first_name", - "last_name", - "assignee_id", - "avatar", - "display_name", - ) - .annotate( - total_issues=Count( - "id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - completed_issues=Count( - "id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("first_name", "last_name") - ) - - label_distribution = ( - Issue.objects.filter( - issue_module__module_id=pk, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(label_name=F("labels__name")) - .annotate(color=F("labels__color")) - .annotate(label_id=F("labels__id")) - .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ), - ) - .annotate( - completed_issues=Count( - "id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - - data = ModuleDetailSerializer(queryset.first()).data - data["distribution"] = { - "assignees": assignee_distribution, - "labels": label_distribution, - "completion_chart": {}, - } - - # Fetch the modules - modules = queryset.first() - if modules and modules.start_date and modules.target_date: - data["distribution"]["completion_chart"] = burndown_plot( - queryset=modules, - slug=slug, - project_id=project_id, - module_id=pk, - ) - - return Response( - data, - status=status.HTTP_200_OK, - ) - - def post(self, request, slug, project_id, module_id): - module = Module.objects.get( - pk=module_id, project_id=project_id, workspace__slug=slug - ) - if module.status not in ["completed", "cancelled"]: - return Response( - { - "error": "Only completed or cancelled modules can be archived" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - module.archived_at = timezone.now() - module.save() - return Response( - {"archived_at": str(module.archived_at)}, - status=status.HTTP_200_OK, - ) - - def delete(self, request, slug, project_id, module_id): - module = Module.objects.get( - pk=module_id, project_id=project_id, workspace__slug=slug - ) - module.archived_at = None - module.save() - return Response(status=status.HTTP_204_NO_CONTENT) - - class ModuleFavoriteViewSet(BaseViewSet): serializer_class = ModuleFavoriteSerializer model = ModuleFavorite diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index d2643334079..e0fcb2d3c95 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -31,7 +31,7 @@ ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters - +from plane.utils.user_timezone_converter import user_timezone_converter class ModuleIssueViewSet(WebhookMixin, BaseViewSet): serializer_class = ModuleIssueSerializer @@ -150,6 +150,11 @@ def list(self, request, slug, project_id, module_id): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) + return Response(issues, status=status.HTTP_200_OK) # create multiple issues inside a module diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 50435e3a868..d8791ae9bf0 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -185,7 +185,6 @@ def retrieve(self, request, slug, pk): .annotate( total_issues=Issue.issue_objects.filter( project_id=self.kwargs.get("pk"), - parent__isnull=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -204,7 +203,6 @@ def retrieve(self, request, slug, pk): archived_issues=Issue.objects.filter( project_id=self.kwargs.get("pk"), archived_at__isnull=False, - parent__isnull=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -224,7 +222,6 @@ def retrieve(self, request, slug, pk): draft_issues=Issue.objects.filter( project_id=self.kwargs.get("pk"), is_draft=True, - parent__isnull=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py index 4d69d1cf27c..487e365cdf9 100644 --- a/apiserver/plane/app/views/user/base.py +++ b/apiserver/plane/app/views/user/base.py @@ -49,7 +49,12 @@ def retrieve_instance_admin(self, request): {"is_instance_admin": is_admin}, status=status.HTTP_200_OK ) - @invalidate_cache(path="/api/users/me/") + @invalidate_cache( + path="/api/users/me/", + ) + @invalidate_cache( + path="/api/users/me/settings/", + ) def partial_update(self, request, *args, **kwargs): return super().partial_update(request, *args, **kwargs) diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index 35772ccf3bb..7736e465c98 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -42,7 +42,7 @@ IssueAttachment, ) from plane.utils.issue_filters import issue_filters - +from plane.utils.user_timezone_converter import user_timezone_converter class GlobalViewViewSet(BaseViewSet): serializer_class = IssueViewSerializer @@ -255,6 +255,10 @@ def list(self, request, slug): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) return Response(issues, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py index 1ff863d1352..24a3d730213 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -151,8 +151,8 @@ def partial_update(self, request, *args, **kwargs): return super().partial_update(request, *args, **kwargs) @invalidate_cache(path="/api/workspaces/", user=False) - @invalidate_cache(path="/api/users/me/workspaces/", multiple=True) - @invalidate_cache(path="/api/users/me/settings/", multiple=True) + @invalidate_cache(path="/api/users/me/workspaces/", multiple=True, user=False) + @invalidate_cache(path="/api/users/me/settings/", multiple=True, user=False) def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) diff --git a/apiserver/plane/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py index e85fa1cefaa..fa2954d6736 100644 --- a/apiserver/plane/app/views/workspace/cycle.py +++ b/apiserver/plane/app/views/workspace/cycle.py @@ -27,7 +27,7 @@ def get(self, request, slug): .select_related("project") .select_related("workspace") .select_related("owned_by") - .filter(archived_at__isnull=False) + .filter(archived_at__isnull=True) .annotate( total_issues=Count( "issue_cycle", diff --git a/apiserver/plane/app/views/workspace/module.py b/apiserver/plane/app/views/workspace/module.py index 085787694f4..7671692ec77 100644 --- a/apiserver/plane/app/views/workspace/module.py +++ b/apiserver/plane/app/views/workspace/module.py @@ -30,7 +30,7 @@ def get(self, request, slug): .select_related("workspace") .select_related("lead") .prefetch_related("members") - .filter(archived_at__isnull=False) + .filter(archived_at__isnull=True) .prefetch_related( Prefetch( "link_module", diff --git a/apiserver/plane/app/views/workspace/state.py b/apiserver/plane/app/views/workspace/state.py index 7e3b158e504..c69b56d4f88 100644 --- a/apiserver/plane/app/views/workspace/state.py +++ b/apiserver/plane/app/views/workspace/state.py @@ -21,6 +21,7 @@ def get(self, request, slug): project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, project__archived_at__isnull=True, + is_triage=False, ) serializer = StateSerializer(states, many=True).data return Response(serializer, status=status.HTTP_200_OK) diff --git a/apiserver/plane/db/management/commands/test_email.py b/apiserver/plane/db/management/commands/test_email.py index 99c5d9684f4..63b602518e5 100644 --- a/apiserver/plane/db/management/commands/test_email.py +++ b/apiserver/plane/db/management/commands/test_email.py @@ -15,7 +15,7 @@ def handle(self, *args, **options): receiver_email = options.get("to_email") if not receiver_email: - raise CommandError("Reciever email is required") + raise CommandError("Receiver email is required") ( EMAIL_HOST, @@ -54,7 +54,7 @@ def handle(self, *args, **options): connection=connection, ) msg.send() - self.stdout.write(self.style.SUCCESS("Email succesfully sent")) + self.stdout.write(self.style.SUCCESS("Email successfully sent")) except Exception as e: self.stdout.write( self.style.ERROR( diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index 32a37879ff1..b5cc8a60d74 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -46,7 +46,7 @@ def handle(self, *args, **options): } instance = Instance.objects.create( - instance_name="Plane Free", + instance_name="Plane Community Edition", instance_id=secrets.token_hex(12), license_key=None, api_key=secrets.token_hex(8), diff --git a/apiserver/plane/utils/user_timezone_converter.py b/apiserver/plane/utils/user_timezone_converter.py new file mode 100644 index 00000000000..579b96c2630 --- /dev/null +++ b/apiserver/plane/utils/user_timezone_converter.py @@ -0,0 +1,25 @@ +import pytz + +def user_timezone_converter(queryset, datetime_fields, user_timezone): + # Create a timezone object for the user's timezone + user_tz = pytz.timezone(user_timezone) + + # Check if queryset is a dictionary (single item) or a list of dictionaries + if isinstance(queryset, dict): + queryset_values = [queryset] + else: + queryset_values = list(queryset.values()) + + # Iterate over the dictionaries in the list + for item in queryset_values: + # Iterate over the datetime fields + for field in datetime_fields: + # Convert the datetime field to the user's timezone + if item[field]: + item[field] = item[field].astimezone(user_tz) + + # If queryset was a single item, return a single item + if isinstance(queryset, dict): + return queryset_values[0] + else: + return queryset_values diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt index a0e9f8a175f..bea44fcfe70 100644 --- a/apiserver/requirements/production.txt +++ b/apiserver/requirements/production.txt @@ -1,3 +1,3 @@ -r base.txt -gunicorn==21.2.0 +gunicorn==22.0.0 diff --git a/apiserver/runtime.txt b/apiserver/runtime.txt index 424240cc059..cd0aac54270 100644 --- a/apiserver/runtime.txt +++ b/apiserver/runtime.txt @@ -1 +1 @@ -python-3.11.8 \ No newline at end of file +python-3.11.9 \ No newline at end of file diff --git a/deploy/coolify/coolify-docker-compose.yml b/deploy/coolify/coolify-docker-compose.yml index 58e00a7a715..8ac5f44f086 100644 --- a/deploy/coolify/coolify-docker-compose.yml +++ b/deploy/coolify/coolify-docker-compose.yml @@ -180,7 +180,7 @@ services: plane-redis: container_name: plane-redis - image: redis:6.2.7-alpine + image: redis:7.2.4-alpine restart: always volumes: - redisdata:/data diff --git a/docker-compose-local.yml b/docker-compose-local.yml index a68a045dd60..d79fa54d325 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -10,7 +10,7 @@ volumes: services: plane-redis: - image: redis:6.2.7-alpine + image: redis:7.2.4-alpine restart: unless-stopped networks: - dev_env diff --git a/docker-compose.yml b/docker-compose.yml index 6efe0e0a1d5..cf5a8421d90 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,7 +90,7 @@ services: plane-redis: container_name: plane-redis - image: redis:6.2.7-alpine + image: redis:7.2.4-alpine restart: always volumes: - redisdata:/data diff --git a/package.json b/package.json index 534bda24f78..39c8bfdc6ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", - "version": "0.18.0", + "version": "0.19.0", "license": "AGPL-3.0", "private": true, "workspaces": [ diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index 760f1d3722d..6f2744dca8c 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-core", - "version": "0.18.0", + "version": "0.19.0", "description": "Core Editor that powers Plane", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/core/src/hooks/use-editor.tsx b/packages/editor/core/src/hooks/use-editor.tsx index 647b79929cb..a90be65f13d 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/core/src/hooks/use-editor.tsx @@ -34,7 +34,7 @@ interface CustomEditorProps { suggestions?: () => Promise; }; handleEditorReady?: (value: boolean) => void; - placeholder?: string | ((isFocused: boolean) => string); + placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; } @@ -142,11 +142,11 @@ export const useEditor = ({ executeMenuItemCommand: (itemName: EditorMenuItemNames) => { const editorItems = getEditorMenuItems(editorRef.current, uploadFile); - const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName); + const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName); const item = getEditorMenuItem(itemName); if (item) { - if (item.name === "image") { + if (item.key === "image") { item.command(savedSelection); } else { item.command(); @@ -158,7 +158,7 @@ export const useEditor = ({ isMenuItemActive: (itemName: EditorMenuItemNames): boolean => { const editorItems = getEditorMenuItems(editorRef.current, uploadFile); - const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName); + const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName); const item = getEditorMenuItem(itemName); return item ? item.isActive() : false; }, diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index f0c6c85e09d..ce2cf3ad6bf 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -4,6 +4,11 @@ import { findTableAncestor } from "src/lib/utils"; import { Selection } from "@tiptap/pm/state"; import { UploadImage } from "src/types/upload-image"; +export const setText = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).clearNodes().run(); + else editor.chain().focus().clearNodes().run(); +}; + export const toggleHeadingOne = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); else editor.chain().focus().toggleHeading({ level: 1 }).run(); @@ -19,6 +24,21 @@ export const toggleHeadingThree = (editor: Editor, range?: Range) => { else editor.chain().focus().toggleHeading({ level: 3 }).run(); }; +export const toggleHeadingFour = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run(); + else editor.chain().focus().toggleHeading({ level: 4 }).run(); +}; + +export const toggleHeadingFive = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run(); + else editor.chain().focus().toggleHeading({ level: 5 }).run(); +}; + +export const toggleHeadingSix = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run(); + else editor.chain().focus().toggleHeading({ level: 6 }).run(); +}; + export const toggleBold = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).toggleBold().run(); else editor.chain().focus().toggleBold().run(); diff --git a/packages/editor/core/src/styles/editor.css b/packages/editor/core/src/styles/editor.css index 5868fce91ca..ae0155eec8b 100644 --- a/packages/editor/core/src/styles/editor.css +++ b/packages/editor/core/src/styles/editor.css @@ -1,3 +1,14 @@ +.ProseMirror { + --font-size-h1: 1.5rem; + --font-size-h2: 1.3125rem; + --font-size-h3: 1.125rem; + --font-size-h4: 0.9375rem; + --font-size-h5: 0.8125rem; + --font-size-h6: 0.75rem; + --font-size-regular: 0.9375rem; + --font-size-list: var(--font-size-regular); +} + .ProseMirror p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; @@ -56,7 +67,7 @@ /* to-do list */ ul[data-type="taskList"] li { - font-size: 1rem; + font-size: var(--font-size-list); line-height: 1.5; } @@ -162,7 +173,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { cursor: text; line-height: 1.2; font-family: inherit; - font-size: 14px; + font-size: var(--font-size-regular); color: inherit; -moz-box-sizing: border-box; box-sizing: border-box; @@ -310,15 +321,15 @@ ul[data-type="taskList"] ul[data-type="taskList"] { .prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 2rem; margin-bottom: 4px; - font-size: 1.875rem; - font-weight: 700; + font-size: var(--font-size-h1); + font-weight: 600; line-height: 1.3; } .prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 1.4rem; margin-bottom: 1px; - font-size: 1.5rem; + font-size: var(--font-size-h2); font-weight: 600; line-height: 1.3; } @@ -326,21 +337,46 @@ ul[data-type="taskList"] ul[data-type="taskList"] { .prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 1rem; margin-bottom: 1px; - font-size: 1.25rem; + font-size: var(--font-size-h3); + font-weight: 600; line-height: 1.3; } +.prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + margin-top: 1rem; + margin-bottom: 1px; + font-size: var(--font-size-h4); + font-weight: 600; + line-height: 1.5; +} + +.prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + margin-top: 1rem; + margin-bottom: 1px; + font-size: var(--font-size-h5); + font-weight: 600; + line-height: 1.5; +} + +.prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + margin-top: 1rem; + margin-bottom: 1px; + font-size: var(--font-size-h6); + font-weight: 600; + line-height: 1.5; +} + .prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 0.25rem; margin-bottom: 1px; - padding: 3px 2px; - font-size: 1rem; + padding: 3px 0; + font-size: var(--font-size-regular); line-height: 1.5; } .prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p, .prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p { - font-size: 1rem; + font-size: var(--font-size-list); line-height: 1.5; } diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index f4dbaee3be1..f6afdfbc169 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -43,7 +43,7 @@ type TArguments = { cancelUploadImage?: () => void; uploadFile: UploadImage; }; - placeholder?: string | ((isFocused: boolean) => string); + placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; }; @@ -147,7 +147,7 @@ export const CoreEditorExtensions = ({ if (placeholder) { if (typeof placeholder === "string") return placeholder; - else return placeholder(editor.isFocused); + else return placeholder(editor.isFocused, editor.getHTML()); } return "Press '/' for commands..."; diff --git a/packages/editor/core/src/ui/menus/menu-items/index.tsx b/packages/editor/core/src/ui/menus/menu-items/index.tsx index 66736e0ea7d..46b1ed92a75 100644 --- a/packages/editor/core/src/ui/menus/menu-items/index.tsx +++ b/packages/editor/core/src/ui/menus/menu-items/index.tsx @@ -13,16 +13,24 @@ import { UnderlineIcon, StrikethroughIcon, CodeIcon, + Heading4, + Heading5, + Heading6, + CaseSensitive, } from "lucide-react"; import { Editor } from "@tiptap/react"; import { insertImageCommand, insertTableCommand, + setText, toggleBlockquote, toggleBold, toggleBulletList, toggleCodeBlock, + toggleHeadingFive, + toggleHeadingFour, toggleHeadingOne, + toggleHeadingSix, toggleHeadingThree, toggleHeadingTwo, toggleItalic, @@ -36,15 +44,26 @@ import { UploadImage } from "src/types/upload-image"; import { Selection } from "@tiptap/pm/state"; export interface EditorMenuItem { + key: string; name: string; isActive: () => boolean; command: () => void; icon: LucideIconType; } +export const TextItem = (editor: Editor) => + ({ + key: "text", + name: "Text", + isActive: () => editor.isActive("paragraph"), + command: () => setText(editor), + icon: CaseSensitive, + }) as const satisfies EditorMenuItem; + export const HeadingOneItem = (editor: Editor) => ({ - name: "H1", + key: "h1", + name: "Heading 1", isActive: () => editor.isActive("heading", { level: 1 }), command: () => toggleHeadingOne(editor), icon: Heading1, @@ -52,7 +71,8 @@ export const HeadingOneItem = (editor: Editor) => export const HeadingTwoItem = (editor: Editor) => ({ - name: "H2", + key: "h2", + name: "Heading 2", isActive: () => editor.isActive("heading", { level: 2 }), command: () => toggleHeadingTwo(editor), icon: Heading2, @@ -60,15 +80,44 @@ export const HeadingTwoItem = (editor: Editor) => export const HeadingThreeItem = (editor: Editor) => ({ - name: "H3", + key: "h3", + name: "Heading 3", isActive: () => editor.isActive("heading", { level: 3 }), command: () => toggleHeadingThree(editor), icon: Heading3, }) as const satisfies EditorMenuItem; +export const HeadingFourItem = (editor: Editor) => + ({ + key: "h4", + name: "Heading 4", + isActive: () => editor.isActive("heading", { level: 4 }), + command: () => toggleHeadingFour(editor), + icon: Heading4, + }) as const satisfies EditorMenuItem; + +export const HeadingFiveItem = (editor: Editor) => + ({ + key: "h5", + name: "Heading 5", + isActive: () => editor.isActive("heading", { level: 5 }), + command: () => toggleHeadingFive(editor), + icon: Heading5, + }) as const satisfies EditorMenuItem; + +export const HeadingSixItem = (editor: Editor) => + ({ + key: "h6", + name: "Heading 6", + isActive: () => editor.isActive("heading", { level: 6 }), + command: () => toggleHeadingSix(editor), + icon: Heading6, + }) as const satisfies EditorMenuItem; + export const BoldItem = (editor: Editor) => ({ - name: "bold", + key: "bold", + name: "Bold", isActive: () => editor?.isActive("bold"), command: () => toggleBold(editor), icon: BoldIcon, @@ -76,7 +125,8 @@ export const BoldItem = (editor: Editor) => export const ItalicItem = (editor: Editor) => ({ - name: "italic", + key: "italic", + name: "Italic", isActive: () => editor?.isActive("italic"), command: () => toggleItalic(editor), icon: ItalicIcon, @@ -84,7 +134,8 @@ export const ItalicItem = (editor: Editor) => export const UnderLineItem = (editor: Editor) => ({ - name: "underline", + key: "underline", + name: "Underline", isActive: () => editor?.isActive("underline"), command: () => toggleUnderline(editor), icon: UnderlineIcon, @@ -92,7 +143,8 @@ export const UnderLineItem = (editor: Editor) => export const StrikeThroughItem = (editor: Editor) => ({ - name: "strike", + key: "strikethrough", + name: "Strikethrough", isActive: () => editor?.isActive("strike"), command: () => toggleStrike(editor), icon: StrikethroughIcon, @@ -100,47 +152,53 @@ export const StrikeThroughItem = (editor: Editor) => export const BulletListItem = (editor: Editor) => ({ - name: "bullet-list", + key: "bulleted-list", + name: "Bulleted list", isActive: () => editor?.isActive("bulletList"), command: () => toggleBulletList(editor), icon: ListIcon, }) as const satisfies EditorMenuItem; -export const TodoListItem = (editor: Editor) => - ({ - name: "To-do List", - isActive: () => editor.isActive("taskItem"), - command: () => toggleTaskList(editor), - icon: CheckSquare, - }) as const satisfies EditorMenuItem; - -export const CodeItem = (editor: Editor) => - ({ - name: "code", - isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"), - command: () => toggleCodeBlock(editor), - icon: CodeIcon, - }) as const satisfies EditorMenuItem; - export const NumberedListItem = (editor: Editor) => ({ - name: "ordered-list", + key: "numbered-list", + name: "Numbered list", isActive: () => editor?.isActive("orderedList"), command: () => toggleOrderedList(editor), icon: ListOrderedIcon, }) as const satisfies EditorMenuItem; +export const TodoListItem = (editor: Editor) => + ({ + key: "to-do-list", + name: "To-do list", + isActive: () => editor.isActive("taskItem"), + command: () => toggleTaskList(editor), + icon: CheckSquare, + }) as const satisfies EditorMenuItem; + export const QuoteItem = (editor: Editor) => ({ - name: "quote", + key: "quote", + name: "Quote", isActive: () => editor?.isActive("blockquote"), command: () => toggleBlockquote(editor), icon: QuoteIcon, }) as const satisfies EditorMenuItem; +export const CodeItem = (editor: Editor) => + ({ + key: "code", + name: "Code", + isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"), + command: () => toggleCodeBlock(editor), + icon: CodeIcon, + }) as const satisfies EditorMenuItem; + export const TableItem = (editor: Editor) => ({ - name: "table", + key: "table", + name: "Table", isActive: () => editor?.isActive("table"), command: () => insertTableCommand(editor), icon: TableIcon, @@ -148,7 +206,8 @@ export const TableItem = (editor: Editor) => export const ImageItem = (editor: Editor, uploadFile: UploadImage) => ({ - name: "image", + key: "image", + name: "Image", isActive: () => editor?.isActive("image"), command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection), icon: ImageIcon, @@ -159,9 +218,13 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag return []; } return [ + TextItem(editor), HeadingOneItem(editor), HeadingTwoItem(editor), HeadingThreeItem(editor), + HeadingFourItem(editor), + HeadingFiveItem(editor), + HeadingSixItem(editor), BoldItem(editor), ItalicItem(editor), UnderLineItem(editor), @@ -177,7 +240,7 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag } export type EditorMenuItemNames = ReturnType extends (infer U)[] - ? U extends { name: infer N } + ? U extends { key: infer N } ? N : never : never; diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index 3cde3482533..c69d0c2c80b 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/document-editor", - "version": "0.18.0", + "version": "0.19.0", "description": "Package that powers Plane's Pages Editor", "main": "./dist/index.mjs", "module": "./dist/index.mjs", diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index 3c36ed11c5b..1f1c5f70675 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -31,7 +31,7 @@ interface IDocumentEditor { suggestions: () => Promise; }; tabIndex?: number; - placeholder?: string | ((isFocused: boolean) => string); + placeholder?: string | ((isFocused: boolean, value: string) => string); } const DocumentEditor = (props: IDocumentEditor) => { diff --git a/packages/editor/document-editor/src/ui/menu/block-menu.tsx b/packages/editor/document-editor/src/ui/menu/block-menu.tsx index 6fc9a87fe96..8a303809c56 100644 --- a/packages/editor/document-editor/src/ui/menu/block-menu.tsx +++ b/packages/editor/document-editor/src/ui/menu/block-menu.tsx @@ -67,11 +67,13 @@ export default function BlockMenu(props: BlockMenuProps) { popup.current?.hide(); }; document.addEventListener("click", handleClickDragHandle); + document.addEventListener("contextmenu", handleClickDragHandle); document.addEventListener("keydown", handleKeyDown); document.addEventListener("scroll", handleScroll, true); // Using capture phase return () => { document.removeEventListener("click", handleClickDragHandle); + document.removeEventListener("contextmenu", handleClickDragHandle); document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("scroll", handleScroll, true); }; diff --git a/packages/editor/extensions/package.json b/packages/editor/extensions/package.json index 7dee7fb1400..dfab06cdea7 100644 --- a/packages/editor/extensions/package.json +++ b/packages/editor/extensions/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-extensions", - "version": "0.18.0", + "version": "0.19.0", "description": "Package that powers Plane's Editor with extensions", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/extensions/src/extensions/drag-drop.tsx b/packages/editor/extensions/src/extensions/drag-drop.tsx index e9ef9c06e67..ab2df31adf1 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -225,6 +225,9 @@ function DragHandle(options: DragHandleOptions) { dragHandleElement.addEventListener("click", (e) => { handleClick(e, view); }); + dragHandleElement.addEventListener("contextmenu", (e) => { + handleClick(e, view); + }); dragHandleElement.addEventListener("drag", (e) => { hideDragHandle(); diff --git a/packages/editor/lite-text-editor/package.json b/packages/editor/lite-text-editor/package.json index e7a500eb30a..d0868e239c1 100644 --- a/packages/editor/lite-text-editor/package.json +++ b/packages/editor/lite-text-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/lite-text-editor", - "version": "0.18.0", + "version": "0.19.0", "description": "Package that powers Plane's Comment Editor", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/lite-text-editor/src/ui/index.tsx b/packages/editor/lite-text-editor/src/ui/index.tsx index 71846eca7d3..6b22809d62a 100644 --- a/packages/editor/lite-text-editor/src/ui/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/index.tsx @@ -32,7 +32,8 @@ export interface ILiteTextEditor { suggestions?: () => Promise; }; tabIndex?: number; - placeholder?: string | ((isFocused: boolean) => string); + placeholder?: string | ((isFocused: boolean, value: string) => string); + id?: string; } const LiteTextEditor = (props: ILiteTextEditor) => { @@ -48,12 +49,14 @@ const LiteTextEditor = (props: ILiteTextEditor) => { tabIndex, mentionHandler, placeholder = "Add comment...", + id = "", } = props; const editor = useEditor({ onChange, initialValue, value, + id, editorClassName, restoreFile: fileHandler.restore, uploadFile: fileHandler.upload, diff --git a/packages/editor/rich-text-editor/package.json b/packages/editor/rich-text-editor/package.json index 0561dfb7a5e..fe1f29e1d53 100644 --- a/packages/editor/rich-text-editor/package.json +++ b/packages/editor/rich-text-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/rich-text-editor", - "version": "0.18.0", + "version": "0.19.0", "description": "Rich Text Editor that powers Plane", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index e82615b9503..0cb32e5434a 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -35,7 +35,7 @@ export type IRichTextEditor = { highlights: () => Promise; suggestions: () => Promise; }; - placeholder?: string | ((isFocused: boolean) => string); + placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; }; diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx index 2dbc86cec68..c11f0593d9e 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx @@ -15,6 +15,7 @@ import { } from "@plane/editor-core"; export interface BubbleMenuItem { + key: string; name: string; isActive: () => boolean; command: () => void; diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx index 1bb8c38bd2b..5c1c8479f1c 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx @@ -8,9 +8,13 @@ import { QuoteItem, CodeItem, TodoListItem, + TextItem, + HeadingFourItem, + HeadingFiveItem, + HeadingSixItem, } from "@plane/editor-core"; import { Editor } from "@tiptap/react"; -import { Check, ChevronDown, TextIcon } from "lucide-react"; +import { Check, ChevronDown } from "lucide-react"; import { Dispatch, FC, SetStateAction } from "react"; import { BubbleMenuItem } from "."; @@ -23,18 +27,16 @@ interface NodeSelectorProps { export const NodeSelector: FC = ({ editor, isOpen, setIsOpen }) => { const items: BubbleMenuItem[] = [ - { - name: "Text", - icon: TextIcon, - command: () => editor.chain().focus().clearNodes().run(), - isActive: () => editor.isActive("paragraph") && !editor.isActive("bulletList") && !editor.isActive("orderedList"), - }, + TextItem(editor), HeadingOneItem(editor), HeadingTwoItem(editor), HeadingThreeItem(editor), - TodoListItem(editor), + HeadingFourItem(editor), + HeadingFiveItem(editor), + HeadingSixItem(editor), BulletListItem(editor), NumberedListItem(editor), + TodoListItem(editor), QuoteItem(editor), CodeItem(editor), ]; @@ -58,7 +60,7 @@ export const NodeSelector: FC = ({ editor, isOpen, setIsOpen {isOpen && ( -
+
{items.map((item) => ( ))}
diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index b45b82e7e9c..4eae6488016 100644 --- a/packages/eslint-config-custom/package.json +++ b/packages/eslint-config-custom/package.json @@ -1,7 +1,7 @@ { "name": "eslint-config-custom", "private": true, - "version": "0.18.0", + "version": "0.19.0", "main": "index.js", "license": "MIT", "devDependencies": {}, diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index afc6db09f49..076a9d35231 100644 --- a/packages/tailwind-config-custom/package.json +++ b/packages/tailwind-config-custom/package.json @@ -1,6 +1,6 @@ { "name": "tailwind-config-custom", - "version": "0.18.0", + "version": "0.19.0", "description": "common tailwind configuration across monorepo", "main": "index.js", "private": true, diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index 0aadcc6d0d5..ef5580bffea 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "tsconfig", - "version": "0.18.0", + "version": "0.19.0", "private": true, "files": [ "base.json", diff --git a/packages/types/package.json b/packages/types/package.json index 48f356f42a3..68c0b86d9fb 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "0.18.0", + "version": "0.19.0", "private": true, "main": "./src/index.d.ts" } diff --git a/packages/types/src/inbox.d.ts b/packages/types/src/inbox.d.ts index 01a1dfce3a4..0f0b93e4fb3 100644 --- a/packages/types/src/inbox.d.ts +++ b/packages/types/src/inbox.d.ts @@ -20,7 +20,7 @@ export type TInboxIssueCurrentTab = EInboxIssueCurrentTab; export type TInboxIssueStatus = EInboxIssueStatus; // filters -export type TInboxIssueFilterMemberKeys = "assignee" | "created_by"; +export type TInboxIssueFilterMemberKeys = "assignees" | "created_by"; export type TInboxIssueFilterDateKeys = "created_at" | "updated_at"; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index a1df4527ea6..4871ddc06e7 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -16,14 +16,9 @@ export type TPage = { project: string | undefined; updated_at: Date | undefined; updated_by: string | undefined; - view_props: TPageViewProps | undefined; workspace: string | undefined; }; -export type TPageViewProps = { - full_width?: boolean; -}; - // page filters export type TPageNavigationTabs = "public" | "private" | "archived"; diff --git a/packages/ui/package.json b/packages/ui/package.json index d627c94ba3a..1ffa822e226 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@plane/ui", "description": "UI components shared across multiple apps internally", "private": true, - "version": "0.18.0", + "version": "0.19.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/packages/ui/src/avatar/avatar-group.tsx b/packages/ui/src/avatar/avatar-group.tsx index 60fdc917dc0..501f694900d 100644 --- a/packages/ui/src/avatar/avatar-group.tsx +++ b/packages/ui/src/avatar/avatar-group.tsx @@ -1,6 +1,8 @@ import React from "react"; // ui import { Tooltip } from "../tooltip"; +// helpers +import { cn } from "../../helpers"; // types import { TAvatarSize, getSizeInfo, isAValidNumber } from "./avatar"; @@ -55,7 +57,7 @@ export const AvatarGroup: React.FC = (props) => { const sizeInfo = getSizeInfo(size); return ( -
+
{avatarsWithUpdatedProps.map((avatar, index) => (
{avatar} @@ -64,9 +66,12 @@ export const AvatarGroup: React.FC = (props) => { {maxAvatarsToRender < totalAvatars && (
= (props) => { return (
= (props) => { tabIndex={-1} > {src ? ( - {name} + {name} ) : (
{ variant?: TBadgeVariant; @@ -31,7 +32,7 @@ const Badge = React.forwardRef((props, ref) => { const buttonIconStyle = getIconStyling(size); return ( - + ); +}; diff --git a/packages/ui/src/dropdowns/context-menu/root.tsx b/packages/ui/src/dropdowns/context-menu/root.tsx new file mode 100644 index 00000000000..47a52b8c212 --- /dev/null +++ b/packages/ui/src/dropdowns/context-menu/root.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useRef, useState } from "react"; +import ReactDOM from "react-dom"; +// components +import { ContextMenuItem } from "./item"; +// helpers +import { cn } from "../../../helpers"; +// hooks +import useOutsideClickDetector from "../../hooks/use-outside-click-detector"; + +export type TContextMenuItem = { + key: string; + title: string; + description?: string; + icon?: React.FC; + action: () => void; + shouldRender?: boolean; + closeOnClick?: boolean; + disabled?: boolean; + className?: string; + iconClassName?: string; +}; + +type ContextMenuProps = { + parentRef: React.RefObject; + items: TContextMenuItem[]; +}; + +const ContextMenuWithoutPortal: React.FC = (props) => { + const { parentRef, items } = props; + // states + const [isOpen, setIsOpen] = useState(false); + const [position, setPosition] = useState({ + x: 0, + y: 0, + }); + const [activeItemIndex, setActiveItemIndex] = useState(0); + // refs + const contextMenuRef = useRef(null); + // derived values + const renderedItems = items.filter((item) => item.shouldRender !== false); + + const handleClose = () => { + setIsOpen(false); + setActiveItemIndex(0); + }; + + // calculate position of context menu + useEffect(() => { + const parentElement = parentRef.current; + const contextMenu = contextMenuRef.current; + if (!parentElement || !contextMenu) return; + + const handleContextMenu = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const contextMenuWidth = contextMenu.clientWidth; + const contextMenuHeight = contextMenu.clientHeight; + + const clickX = e?.pageX || 0; + const clickY = e?.pageY || 0; + + // check if there's enough space at the bottom, otherwise show at the top + let top = clickY; + if (clickY + contextMenuHeight > window.innerHeight) top = clickY - contextMenuHeight; + + // check if there's enough space on the right, otherwise show on the left + let left = clickX; + if (clickX + contextMenuWidth > window.innerWidth) left = clickX - contextMenuWidth; + + setPosition({ x: left, y: top }); + setIsOpen(true); + }; + + const hideContextMenu = (e: KeyboardEvent) => { + if (isOpen && e.key === "Escape") handleClose(); + }; + + parentElement.addEventListener("contextmenu", handleContextMenu); + window.addEventListener("keydown", hideContextMenu); + + return () => { + parentElement.removeEventListener("contextmenu", handleContextMenu); + window.removeEventListener("keydown", hideContextMenu); + }; + }, [contextMenuRef, isOpen, parentRef, setIsOpen, setPosition]); + + // handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveItemIndex((prev) => (prev + 1) % renderedItems.length); + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveItemIndex((prev) => (prev - 1 + renderedItems.length) % renderedItems.length); + } + if (e.key === "Enter") { + e.preventDefault(); + const item = renderedItems[activeItemIndex]; + if (!item.disabled) { + renderedItems[activeItemIndex].action(); + if (item.closeOnClick !== false) handleClose(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [activeItemIndex, isOpen, renderedItems, setIsOpen]); + + // close on clicking outside + useOutsideClickDetector(contextMenuRef, handleClose); + + return ( +
+
+ {renderedItems.map((item, index) => ( + setActiveItemIndex(index)} + handleClose={handleClose} + isActive={index === activeItemIndex} + item={item} + /> + ))} +
+
+ ); +}; + +export const ContextMenu: React.FC = (props) => { + let contextMenu = ; + const portal = document.querySelector("#context-menu-portal"); + if (portal) contextMenu = ReactDOM.createPortal(contextMenu, portal); + return contextMenu; +}; diff --git a/packages/ui/src/dropdowns/index.ts b/packages/ui/src/dropdowns/index.ts index 0ad9cbb227c..d77eac129d3 100644 --- a/packages/ui/src/dropdowns/index.ts +++ b/packages/ui/src/dropdowns/index.ts @@ -1,3 +1,4 @@ +export * from "./context-menu"; export * from "./custom-menu"; export * from "./custom-select"; export * from "./custom-search-select"; diff --git a/packages/ui/src/form-fields/checkbox.tsx b/packages/ui/src/form-fields/checkbox.tsx index 09b90b03bed..887bc60740d 100644 --- a/packages/ui/src/form-fields/checkbox.tsx +++ b/packages/ui/src/form-fields/checkbox.tsx @@ -1,4 +1,6 @@ import * as React from "react"; +// helpers +import { cn } from "../../helpers"; export interface CheckboxProps extends React.InputHTMLAttributes { intermediate?: boolean; @@ -9,32 +11,30 @@ const Checkbox = React.forwardRef((props, ref) const { id, name, checked, intermediate = false, disabled, className = "", ...rest } = props; return ( -
+
= (props) => { onChange={handleInputChange} hasError={hasError} placeholder={placeholder} - className={`border-[0.5px] border-custom-border-200 ${className}`} + className={cn("border-[0.5px] border-custom-border-200", className)} style={style} /> diff --git a/packages/ui/src/form-fields/input.tsx b/packages/ui/src/form-fields/input.tsx index f73467621b6..10f9fd85c19 100644 --- a/packages/ui/src/form-fields/input.tsx +++ b/packages/ui/src/form-fields/input.tsx @@ -19,17 +19,16 @@ const Input = React.forwardRef((props, ref) => { type={type} name={name} className={cn( - `block rounded-md bg-transparent text-sm placeholder-custom-text-400 focus:outline-none ${ - mode === "primary" - ? "rounded-md border-[0.5px] border-custom-border-200" - : mode === "transparent" - ? "rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary" - : mode === "true-transparent" - ? "rounded border-none bg-transparent ring-0" - : "" - } ${hasError ? "border-red-500" : ""} ${hasError && mode === "primary" ? "bg-red-500/20" : ""} ${ - inputSize === "sm" ? "px-3 py-2" : inputSize === "md" ? "p-3" : "" - }`, + "block rounded-md bg-transparent text-sm placeholder-custom-text-400 focus:outline-none", + { + "rounded-md border-[0.5px] border-custom-border-200": mode === "primary", + "rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary": + mode === "transparent", + "rounded border-none bg-transparent ring-0": mode === "true-transparent", + "border-red-500": hasError, + "px-3 py-2": inputSize === "sm", + "p-3": inputSize === "md", + }, className )} {...rest} diff --git a/packages/ui/src/form-fields/textarea.tsx b/packages/ui/src/form-fields/textarea.tsx index de225d68f3f..e6927a9682a 100644 --- a/packages/ui/src/form-fields/textarea.tsx +++ b/packages/ui/src/form-fields/textarea.tsx @@ -11,21 +11,11 @@ export interface TextAreaProps extends React.TextareaHTMLAttributes((props, ref) => { - const { - id, - name, - value = "", - rows = 1, - cols = 1, - mode = "primary", - hasError = false, - className = "", - ...rest - } = props; + const { id, name, value = "", mode = "primary", hasError = false, className = "", ...rest } = props; // refs const textAreaRef = useRef(ref); // auto re-size - useAutoResizeTextArea(textAreaRef); + useAutoResizeTextArea(textAreaRef, value); return (
+
{children}
); diff --git a/packages/ui/src/spinners/circular-spinner.tsx b/packages/ui/src/spinners/circular-spinner.tsx index 70ed925af78..010ff6bfae2 100644 --- a/packages/ui/src/spinners/circular-spinner.tsx +++ b/packages/ui/src/spinners/circular-spinner.tsx @@ -1,4 +1,6 @@ import * as React from "react"; +// helpers +import { cn } from "../../helpers"; export interface ISpinner extends React.SVGAttributes { height?: string; @@ -12,7 +14,7 @@ export const Spinner: React.FC = ({ height = "32px", width = "32px", c aria-hidden="true" height={height} width={width} - className={`animate-spin fill-blue-600 text-custom-text-200 ${className}`} + className={cn("animate-spin fill-blue-600 text-custom-text-200", className)} viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg" diff --git a/packages/ui/src/tooltip/tooltip.tsx b/packages/ui/src/tooltip/tooltip.tsx index ca176918679..30fa5ba5bc2 100644 --- a/packages/ui/src/tooltip/tooltip.tsx +++ b/packages/ui/src/tooltip/tooltip.tsx @@ -1,7 +1,7 @@ import React from "react"; - -// next-themes import { Tooltip2 } from "@blueprintjs/popover2"; +// helpers +import { cn } from "../../helpers"; export type TPosition = | "top" @@ -49,9 +49,13 @@ export const Tooltip: React.FC = ({ hoverCloseDelay={closeDelay} content={
{tooltipHeading &&
{tooltipHeading}
} {tooltipContent} diff --git a/space/components/editor/lite-text-editor.tsx b/space/components/editor/lite-text-editor.tsx index 677a342571e..b911ebecb14 100644 --- a/space/components/editor/lite-text-editor.tsx +++ b/space/components/editor/lite-text-editor.tsx @@ -34,10 +34,9 @@ export const LiteTextEditor = React.forwardRef

" || - isEmptyHtmlString(props.initialValue ?? ""); + (isEmptyHtmlString(props.initialValue ?? "") && !props.initialValue?.includes("mention-component")); return (
diff --git a/space/components/editor/rich-text-read-only-editor.tsx b/space/components/editor/rich-text-read-only-editor.tsx index 562e635814d..56694e91f73 100644 --- a/space/components/editor/rich-text-read-only-editor.tsx +++ b/space/components/editor/rich-text-read-only-editor.tsx @@ -18,7 +18,7 @@ export const RichTextReadOnlyEditor = React.forwardRef ); } diff --git a/space/constants/editor.ts b/space/constants/editor.ts index bdd07f0c529..eb8b9949541 100644 --- a/space/constants/editor.ts +++ b/space/constants/editor.ts @@ -4,6 +4,9 @@ import { Heading1, Heading2, Heading3, + Heading4, + Heading5, + Heading6, Image, Italic, List, @@ -29,14 +32,17 @@ export type ToolbarMenuItem = { }; export const BASIC_MARK_ITEMS: ToolbarMenuItem[] = [ - { key: "H1", name: "Heading 1", icon: Heading1, editors: ["document"] }, - { key: "H2", name: "Heading 2", icon: Heading2, editors: ["document"] }, - { key: "H3", name: "Heading 3", icon: Heading3, editors: ["document"] }, + { key: "h1", name: "Heading 1", icon: Heading1, editors: ["document"] }, + { key: "h2", name: "Heading 2", icon: Heading2, editors: ["document"] }, + { key: "h3", name: "Heading 3", icon: Heading3, editors: ["document"] }, + { key: "h4", name: "Heading 4", icon: Heading4, editors: ["document"] }, + { key: "h5", name: "Heading 5", icon: Heading5, editors: ["document"] }, + { key: "h6", name: "Heading 6", icon: Heading6, editors: ["document"] }, { key: "bold", name: "Bold", icon: Bold, shortcut: ["Cmd", "B"], editors: ["lite", "document"] }, { key: "italic", name: "Italic", icon: Italic, shortcut: ["Cmd", "I"], editors: ["lite", "document"] }, { key: "underline", name: "Underline", icon: Underline, shortcut: ["Cmd", "U"], editors: ["lite", "document"] }, { - key: "strike", + key: "strikethrough", name: "Strikethrough", icon: Strikethrough, shortcut: ["Cmd", "Shift", "S"], @@ -46,21 +52,21 @@ export const BASIC_MARK_ITEMS: ToolbarMenuItem[] = [ export const LIST_ITEMS: ToolbarMenuItem[] = [ { - key: "bullet-list", + key: "bulleted-list", name: "Bulleted list", icon: List, shortcut: ["Cmd", "Shift", "7"], editors: ["lite", "document"], }, { - key: "ordered-list", + key: "numbered-list", name: "Numbered list", icon: ListOrdered, shortcut: ["Cmd", "Shift", "8"], editors: ["lite", "document"], }, { - key: "To-do List", + key: "to-do-list", name: "To-do list", icon: ListTodo, shortcut: ["Cmd", "Shift", "9"], diff --git a/space/package.json b/space/package.json index 93581bad432..9b05e573e90 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.18.0", + "version": "0.19.0", "private": true, "scripts": { "dev": "turbo run develop", diff --git a/space/pages/_document.tsx b/space/pages/_document.tsx index bf83a722c8e..ae44554386b 100644 --- a/space/pages/_document.tsx +++ b/space/pages/_document.tsx @@ -6,6 +6,7 @@ class MyDocument extends Document { +
diff --git a/web/components/api-token/modal/create-token-modal.tsx b/web/components/api-token/modal/create-token-modal.tsx index 32305ba5be0..966da76e82e 100644 --- a/web/components/api-token/modal/create-token-modal.tsx +++ b/web/components/api-token/modal/create-token-modal.tsx @@ -111,7 +111,7 @@ export const CreateApiTokenModal: React.FC = (props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - + {generatedToken ? ( ) : ( diff --git a/web/components/api-token/modal/form.tsx b/web/components/api-token/modal/form.tsx index 93a92c9ef7f..7ba0ace719d 100644 --- a/web/components/api-token/modal/form.tsx +++ b/web/components/api-token/modal/form.tsx @@ -146,7 +146,7 @@ export const CreateApiTokenForm: React.FC = (props) => { onChange={onChange} hasError={Boolean(errors.description)} placeholder="Token description" - className="h-24 w-full text-sm" + className="min-h-24 w-full text-sm" /> )} /> @@ -170,8 +170,8 @@ export const CreateApiTokenForm: React.FC = (props) => { {value === "custom" ? "Custom date" : selectedOption - ? selectedOption.label - : "Set expiration date"} + ? selectedOption.label + : "Set expiration date"}
} value={value} @@ -207,8 +207,8 @@ export const CreateApiTokenForm: React.FC = (props) => { ? `Expires ${renderFormattedDate(customDate)}` : null : watch("expired_at") - ? `Expires ${getExpiryDate(watch("expired_at") ?? "")}` - : null} + ? `Expires ${getExpiryDate(watch("expired_at") ?? "")}` + : null} )}
diff --git a/web/components/api-token/modal/generated-token-details.tsx b/web/components/api-token/modal/generated-token-details.tsx index d2bbfd7a1fd..81e5963a308 100644 --- a/web/components/api-token/modal/generated-token-details.tsx +++ b/web/components/api-token/modal/generated-token-details.tsx @@ -28,8 +28,8 @@ export const GeneratedTokenDetails: React.FC = (props) => { }; return ( -
-
+
+

Key created

Copy and save this secret key in Plane Pages. You can{"'"}t see this key after you hit Close. A CSV file @@ -39,11 +39,11 @@ export const GeneratedTokenDetails: React.FC = (props) => {

diff --git a/web/components/command-palette/command-modal.tsx b/web/components/command-palette/command-modal.tsx index aa36eabed7f..29734efc9d8 100644 --- a/web/components/command-palette/command-modal.tsx +++ b/web/components/command-palette/command-modal.tsx @@ -178,7 +178,9 @@ export const CommandModal: React.FC = observer(() => { return 0; }} onKeyDown={(e) => { - // when search is empty and page is undefined + // when search term is not empty, esc should clear the search term + if (e.key === "Escape" && searchTerm) setSearchTerm(""); + // when user tries to close the modal with esc if (e.key === "Escape" && !page && !searchTerm) closePalette(); diff --git a/web/components/core/activity.tsx b/web/components/core/activity.tsx index 31a796949c2..a70cd072792 100644 --- a/web/components/core/activity.tsx +++ b/web/components/core/activity.tsx @@ -17,6 +17,7 @@ import { SignalMediumIcon, MessageSquareIcon, UsersIcon, + Inbox, } from "lucide-react"; import { IIssueActivity } from "@plane/types"; import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon } from "@plane/ui"; @@ -45,10 +46,10 @@ export const IssueLink = ({ activity }: { activity: IIssueActivity }) => { }`}`} target={activity.issue === null ? "_self" : "_blank"} rel={activity.issue === null ? "" : "noopener noreferrer"} - className="font-medium text-custom-text-100 hover:underline" + className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline" > {`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}{" "} - {activity.issue_detail?.name} + {activity.issue_detail?.name} ) : ( @@ -104,7 +105,7 @@ const EstimatePoint = observer((props: { point: string }) => { const estimateValue = getEstimatePointValue(Number(point), null); return ( - + {areEstimatesEnabledForCurrentProject ? estimateValue : `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`} @@ -112,6 +113,40 @@ const EstimatePoint = observer((props: { point: string }) => { ); }); +const inboxActivityMessage = { + declined: { + showIssue: "declined issue", + noIssue: "declined this issue from inbox.", + }, + snoozed: { + showIssue: "snoozed issue", + noIssue: "snoozed this issue.", + }, + accepted: { + showIssue: "accepted issue", + noIssue: "accepted this issue from inbox.", + }, + markedDuplicate: { + showIssue: "declined issue", + noIssue: "declined this issue from inbox by marking a duplicate issue.", + }, +}; + +const getInboxUserActivityMessage = (activity: IIssueActivity, showIssue: boolean) => { + switch (activity.verb) { + case "-1": + return showIssue ? inboxActivityMessage.declined.showIssue : inboxActivityMessage.declined.noIssue; + case "0": + return showIssue ? inboxActivityMessage.snoozed.showIssue : inboxActivityMessage.snoozed.noIssue; + case "1": + return showIssue ? inboxActivityMessage.accepted.showIssue : inboxActivityMessage.accepted.noIssue; + case "2": + return showIssue ? inboxActivityMessage.markedDuplicate.showIssue : inboxActivityMessage.markedDuplicate.noIssue; + default: + return "updated inbox issue status."; + } +}; + const activityDetails: { [key: string]: { message: (activity: IIssueActivity, showIssue: boolean, workspaceSlug: string) => React.ReactNode; @@ -265,11 +300,13 @@ const activityDetails: { message: (activity, showIssue, workspaceSlug) => { if (activity.old_value === "") return ( - <> + added a new label{" "} - + - {activity.new_value} + + {activity.new_value} + {showIssue && ( @@ -277,15 +314,17 @@ const activityDetails: { to )} - + ); else return ( <> removed the label{" "} - + - {activity.old_value} + + {activity.old_value} + {showIssue && ( @@ -369,29 +408,30 @@ const activityDetails: { return ( <> - added {showIssue ? : "this issue"} to the cycle{" "} + added {showIssue ? : "this issue"}{" "} + to the cycle{" "} - {activity.new_value} + {activity.new_value} ); else if (activity.verb === "updated") return ( <> - set the cycle to + set the cycle to - {activity.new_value} + {activity.new_value} ); @@ -403,9 +443,9 @@ const activityDetails: { href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.old_identifier}`} target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline" + className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline" > - {activity.old_value} + {activity.old_value} ); @@ -422,9 +462,9 @@ const activityDetails: { href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.new_identifier}`} target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline" + className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline" > - {activity.new_value} + {activity.new_value} ); @@ -436,9 +476,9 @@ const activityDetails: { href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.new_identifier}`} target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline" + className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline" > - {activity.new_value} + {activity.new_value} ); @@ -450,9 +490,9 @@ const activityDetails: { href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.old_identifier}`} target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline" + className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline" > - {activity.old_value} + {activity.old_value} ); @@ -462,7 +502,7 @@ const activityDetails: { name: { message: (activity, showIssue) => ( <> - set the name to {activity.new_value} + set the name to {activity.new_value} {showIssue && ( <> {" "} @@ -478,7 +518,8 @@ const activityDetails: { if (!activity.new_value) return ( <> - removed the parent {activity.old_value} + removed the parent{" "} + {activity.old_value} {showIssue && ( <> {" "} @@ -490,7 +531,8 @@ const activityDetails: { else return ( <> - set the parent to {activity.new_value} + set the parent to{" "} + {activity.new_value} {showIssue && ( <> {" "} @@ -525,13 +567,14 @@ const activityDetails: { return ( <> marked that {showIssue ? : "this issue"} relates to{" "} - {activity.new_value}. + {activity.new_value}. ); else return ( <> - removed the relation from {activity.old_value}. + removed the relation from{" "} + {activity.old_value}. ); }, @@ -543,13 +586,14 @@ const activityDetails: { return ( <> marked {showIssue ? : "this issue"} is blocking issue{" "} - {activity.new_value}. + {activity.new_value}. ); else return ( <> - removed the blocking issue {activity.old_value}. + removed the blocking issue{" "} + {activity.old_value}. ); }, @@ -561,14 +605,14 @@ const activityDetails: { return ( <> marked {showIssue ? : "this issue"} is being blocked by{" "} - {activity.new_value}. + {activity.new_value}. ); else return ( <> removed {showIssue ? : "this issue"} being blocked by issue{" "} - {activity.old_value}. + {activity.old_value}. ); }, @@ -580,14 +624,14 @@ const activityDetails: { return ( <> marked {showIssue ? : "this issue"} as duplicate of{" "} - {activity.new_value}. + {activity.new_value}. ); else return ( <> removed {showIssue ? : "this issue"} as a duplicate of{" "} - {activity.old_value}. + {activity.old_value}. ); }, @@ -596,7 +640,7 @@ const activityDetails: { state: { message: (activity, showIssue) => ( <> - set the state to {activity.new_value} + set the state to {activity.new_value} {showIssue && ( <> {" "} @@ -625,7 +669,9 @@ const activityDetails: { return ( <> set the start date to{" "} - {renderFormattedDate(activity.new_value)} + + {renderFormattedDate(activity.new_value)} + {showIssue && ( <> {" "} @@ -655,11 +701,12 @@ const activityDetails: { return ( <> set the due date to{" "} - {renderFormattedDate(activity.new_value)} + + {renderFormattedDate(activity.new_value)} + {showIssue && ( <> - {" "} - for + )} @@ -667,6 +714,20 @@ const activityDetails: { }, icon:
+ } + completed={label.completed_issues} + total={label.total_issues} + /> + ); + } + }) ) : (
diff --git a/web/components/core/sidebar/single-progress-stats.tsx b/web/components/core/sidebar/single-progress-stats.tsx index 4d926285b65..25f976d8413 100644 --- a/web/components/core/sidebar/single-progress-stats.tsx +++ b/web/components/core/sidebar/single-progress-stats.tsx @@ -1,7 +1,5 @@ import React from "react"; -import { CircularProgressIndicator } from "@plane/ui"; - type TSingleProgressStatsProps = { title: any; completed: number; @@ -26,9 +24,6 @@ export const SingleProgressStats: React.FC = ({
{title}
- - - {isNaN(Math.round((completed / total) * 100)) ? "0" : Math.round((completed / total) * 100)}% diff --git a/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx index d928b5fbba5..9b63b0f6f61 100644 --- a/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx +++ b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -20,6 +21,8 @@ type Props = { export const UpcomingCycleListItem: React.FC = observer((props) => { const { cycleId } = props; + // refs + const parentRef = useRef(null); // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -90,6 +93,7 @@ export const UpcomingCycleListItem: React.FC = observer((props) => { return ( @@ -123,6 +127,7 @@ export const UpcomingCycleListItem: React.FC = observer((props) => { {workspaceSlug && projectId && ( { const handleFilters = useCallback( (key: keyof TCycleFilters, value: string | string[]) => { if (!projectId) return; - const newValues = currentProjectArchivedFilters?.[key] ?? []; if (Array.isArray(value)) value.forEach((val) => { if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); }); else { if (currentProjectArchivedFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); diff --git a/web/components/cycles/board/cycles-board-card.tsx b/web/components/cycles/board/cycles-board-card.tsx index 3b596360a7f..ec6d80921cb 100644 --- a/web/components/cycles/board/cycles-board-card.tsx +++ b/web/components/cycles/board/cycles-board-card.tsx @@ -1,4 +1,4 @@ -import { FC, MouseEvent } from "react"; +import { FC, MouseEvent, useRef } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -28,6 +28,8 @@ export interface ICyclesBoardCard { export const CyclesBoardCard: FC = observer((props) => { const { cycleId, workspaceSlug, projectId } = props; + // refs + const parentRef = useRef(null); // router const router = useRouter(); // store @@ -149,8 +151,8 @@ export const CyclesBoardCard: FC = observer((props) => { const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; return ( -
- +
+
@@ -231,23 +233,28 @@ export const CyclesBoardCard: FC = observer((props) => { ) : ( No due date )} -
- {isEditingAllowed && ( - { - if (cycleDetails.is_favorite) handleRemoveFromFavorites(e); - else handleAddToFavorites(e); - }} - selected={!!cycleDetails.is_favorite} - /> - )} - - -
+
+ {isEditingAllowed && ( + { + if (cycleDetails.is_favorite) handleRemoveFromFavorites(e); + else handleAddToFavorites(e); + }} + selected={!!cycleDetails.is_favorite} + /> + )} + + +
); }); diff --git a/web/components/cycles/cycles-view-header.tsx b/web/components/cycles/cycles-view-header.tsx index aad650dd66b..97cf18cf946 100644 --- a/web/components/cycles/cycles-view-header.tsx +++ b/web/components/cycles/cycles-view-header.tsx @@ -47,11 +47,13 @@ export const CyclesViewHeader: React.FC = observer((props) => { const handleFilters = useCallback( (key: keyof TCycleFilters, value: string | string[]) => { + if (!projectId) return; const newValues = currentProjectFilters?.[key] ?? []; if (Array.isArray(value)) value.forEach((val) => { if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); }); else { if (currentProjectFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); @@ -74,7 +76,7 @@ export const CyclesViewHeader: React.FC = observer((props) => { }; return ( -
+
{CYCLE_TABS_LIST.map((tab) => ( = observer((props) => { d.name.toLowerCase().includes(searchQuery.toLowerCase()) ); + const isCustomDateSelected = () => { + const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || []; + return isValidDateSelected.length > 0 ? true : false; + }; + const handleCustomDate = () => { + if (isCustomDateSelected()) { + const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || []; + handleUpdate(updateAppliedFilters); + } else setIsDateFilterModalOpen(true); + }; + return ( <> {isDateFilterModalOpen && ( @@ -53,7 +66,7 @@ export const FilterEndDate: React.FC = observer((props) => { multiple /> ))} - setIsDateFilterModalOpen(true)} title="Custom" multiple /> + ) : (

No matches found

diff --git a/web/components/cycles/dropdowns/filters/start-date.tsx b/web/components/cycles/dropdowns/filters/start-date.tsx index 2b55ada35aa..0b8408c2e71 100644 --- a/web/components/cycles/dropdowns/filters/start-date.tsx +++ b/web/components/cycles/dropdowns/filters/start-date.tsx @@ -6,6 +6,8 @@ import { DateFilterModal } from "@/components/core"; import { FilterHeader, FilterOption } from "@/components/issues"; // constants import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters"; +// helpers +import { isInDateFormat } from "@/helpers/date-time.helper"; type Props = { appliedFilters: string[] | null; @@ -25,6 +27,17 @@ export const FilterStartDate: React.FC = observer((props) => { d.name.toLowerCase().includes(searchQuery.toLowerCase()) ); + const isCustomDateSelected = () => { + const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || []; + return isValidDateSelected.length > 0 ? true : false; + }; + const handleCustomDate = () => { + if (isCustomDateSelected()) { + const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || []; + handleUpdate(updateAppliedFilters); + } else setIsDateFilterModalOpen(true); + }; + return ( <> {isDateFilterModalOpen && ( @@ -53,7 +66,7 @@ export const FilterStartDate: React.FC = observer((props) => { multiple /> ))} - setIsDateFilterModalOpen(true)} title="Custom" multiple /> + ) : (

No matches found

diff --git a/web/components/cycles/form.tsx b/web/components/cycles/form.tsx index f8092f8d066..b8b34fe3b11 100644 --- a/web/components/cycles/form.tsx +++ b/web/components/cycles/form.tsx @@ -77,7 +77,7 @@ export const CycleForm: React.FC = (props) => {
-
+
= (props) => { required: "Name is required", maxLength: { value: 255, - message: "Name should be less than 255 characters", + message: "Title should be less than 255 characters", }, }} render={({ field: { value, onChange } }) => ( @@ -103,6 +103,7 @@ export const CycleForm: React.FC = (props) => { /> )} /> + {errors?.name?.message}
; +}; + +export const CycleListItemAction: FC = observer((props) => { + const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef } = props; + // hooks + const { isMobile } = usePlatformOS(); + // store hooks + const { addCycleToFavorites, removeCycleFromFavorites } = useCycle(); + const { captureEvent } = useEventTracker(); + const { + membership: { currentProjectRole }, + } = useUser(); + const { getUserDetails } = useMember(); + + // derived values + const endDate = getDate(cycleDetails.end_date); + const startDate = getDate(cycleDetails.start_date); + const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + const renderDate = cycleDetails.start_date || cycleDetails.end_date; + const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); + const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; + + // handlers + const handleAddToFavorites = (e: MouseEvent) => { + e.preventDefault(); + if (!workspaceSlug || !projectId) return; + + const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( + () => { + captureEvent(CYCLE_FAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", + }); + } + ); + + setPromiseToast(addToFavoritePromise, { + loading: "Adding cycle to favorites...", + success: { + title: "Success!", + message: () => "Cycle added to favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't add the cycle to favorites. Please try again.", + }, + }); + }; + + const handleRemoveFromFavorites = (e: MouseEvent) => { + e.preventDefault(); + if (!workspaceSlug || !projectId) return; + + const removeFromFavoritePromise = removeCycleFromFavorites( + workspaceSlug?.toString(), + projectId.toString(), + cycleId + ).then(() => { + captureEvent(CYCLE_UNFAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", + }); + }); + + setPromiseToast(removeFromFavoritePromise, { + loading: "Removing cycle from favorites...", + success: { + title: "Success!", + message: () => "Cycle removed from favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't remove the cycle from favorites. Please try again.", + }, + }); + }; + + return ( + <> +
+ {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} +
+ + {currentCycle && ( +
+ {currentCycle.value === "current" + ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` + : `${currentCycle.label}`} +
+ )} + + +
+ {cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? ( + + {cycleDetails.assignee_ids?.map((assignee_id) => { + const member = getUserDetails(assignee_id); + return ; + })} + + ) : ( + + + + )} +
+
+ + {isEditingAllowed && !cycleDetails.archived_at && ( + { + if (cycleDetails.is_favorite) handleRemoveFromFavorites(e); + else handleAddToFavorites(e); + }} + selected={!!cycleDetails.is_favorite} + /> + )} + + + ); +}); diff --git a/web/components/cycles/list/cycles-list-item.tsx b/web/components/cycles/list/cycles-list-item.tsx index d6bc484b154..1f70d79c28c 100644 --- a/web/components/cycles/list/cycles-list-item.tsx +++ b/web/components/cycles/list/cycles-list-item.tsx @@ -1,24 +1,17 @@ -import { FC, MouseEvent } from "react"; +import { FC, MouseEvent, useRef } from "react"; import { observer } from "mobx-react"; -import Link from "next/link"; import { useRouter } from "next/router"; // icons -import { Check, Info, User2 } from "lucide-react"; +import { Check, Info } from "lucide-react"; // types import type { TCycleGroups } from "@plane/types"; // ui -import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui"; +import { CircularProgressIndicator } from "@plane/ui"; // components -import { FavoriteStar } from "@/components/core"; -import { CycleQuickActions } from "@/components/cycles"; -// constants -import { CYCLE_STATUS } from "@/constants/cycle"; -import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; -import { EUserProjectRoles } from "@/constants/project"; -// helpers -import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper"; +import { ListItem } from "@/components/core/list"; +import { CycleListItemAction } from "@/components/cycles/list"; // hooks -import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store"; +import { useCycle } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; type TCyclesListItem = { @@ -29,79 +22,41 @@ type TCyclesListItem = { handleRemoveFromFavorites?: () => void; workspaceSlug: string; projectId: string; - isArchived?: boolean; }; export const CyclesListItem: FC = observer((props) => { - const { cycleId, workspaceSlug, projectId, isArchived } = props; + const { cycleId, workspaceSlug, projectId } = props; + // refs + const parentRef = useRef(null); // router const router = useRouter(); // hooks const { isMobile } = usePlatformOS(); // store hooks - const { captureEvent } = useEventTracker(); - const { - membership: { currentProjectRole }, - } = useUser(); - const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle(); - const { getUserDetails } = useMember(); + const { getCycleById } = useCycle(); - const handleAddToFavorites = (e: MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; + // derived values + const cycleDetails = getCycleById(cycleId); - const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( - () => { - captureEvent(CYCLE_FAVORITED, { - cycle_id: cycleId, - element: "List layout", - state: "SUCCESS", - }); - } - ); + if (!cycleDetails) return null; - setPromiseToast(addToFavoritePromise, { - loading: "Adding cycle to favorites...", - success: { - title: "Success!", - message: () => "Cycle added to favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't add the cycle to favorites. Please try again.", - }, - }); - }; + // computed + // TODO: change this logic once backend fix the response + const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + const isCompleted = cycleStatus === "completed"; - const handleRemoveFromFavorites = (e: MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; + const cycleTotalIssues = + cycleDetails.backlog_issues + + cycleDetails.unstarted_issues + + cycleDetails.started_issues + + cycleDetails.completed_issues + + cycleDetails.cancelled_issues; - const removeFromFavoritePromise = removeCycleFromFavorites( - workspaceSlug?.toString(), - projectId.toString(), - cycleId - ).then(() => { - captureEvent(CYCLE_UNFAVORITED, { - cycle_id: cycleId, - element: "List layout", - state: "SUCCESS", - }); - }); + const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100; - setPromiseToast(removeFromFavoritePromise, { - loading: "Removing cycle from favorites...", - success: { - title: "Success!", - message: () => "Cycle removed from favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't remove the cycle from favorites. Please try again.", - }, - }); - }; + const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); + // handlers const openCycleOverview = (e: MouseEvent) => { const { query } = router; e.preventDefault(); @@ -121,136 +76,47 @@ export const CyclesListItem: FC = observer((props) => { } }; - const cycleDetails = getCycleById(cycleId); - - if (!cycleDetails) return null; - - // computed - // TODO: change this logic once backend fix the response - const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; - const isCompleted = cycleStatus === "completed"; - const endDate = getDate(cycleDetails.end_date); - const startDate = getDate(cycleDetails.start_date); - - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - - const cycleTotalIssues = - cycleDetails.backlog_issues + - cycleDetails.unstarted_issues + - cycleDetails.started_issues + - cycleDetails.completed_issues + - cycleDetails.cancelled_issues; - - const renderDate = cycleDetails.start_date || cycleDetails.end_date; - - // const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); - - const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100; - - const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); - - const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); - - const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; - return ( - <> - { - if (isArchived) { - openCycleOverview(e); - } - }} - > -
-
-
-
- - {isCompleted ? ( - progress === 100 ? ( - - ) : ( - {`!`} - ) - ) : progress === 100 ? ( - - ) : ( - {`${progress}%`} - )} - -
- -
- - - - {cycleDetails.name} - - -
- - -
-
- {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} -
-
-
- {currentCycle && ( -
- {currentCycle.value === "current" - ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` - : `${currentCycle.label}`} -
- )} - -
- -
- {cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? ( - - {cycleDetails.assignee_ids?.map((assignee_id) => { - const member = getUserDetails(assignee_id); - return ; - })} - - ) : ( - - - - )} -
-
- - {isEditingAllowed && !isArchived && ( - { - if (cycleDetails.is_favorite) handleRemoveFromFavorites(e); - else handleAddToFavorites(e); - }} - selected={!!cycleDetails.is_favorite} - /> - )} - -
-
-
- - + { + if (cycleDetails.archived_at) openCycleOverview(e); + }} + prependTitleElement={ + + {isCompleted ? ( + progress === 100 ? ( + + ) : ( + {`!`} + ) + ) : progress === 100 ? ( + + ) : ( + {`${progress}%`} + )} + + } + appendTitleElement={ + + } + actionableItems={ + + } + isMobile={isMobile} + parentRef={parentRef} + /> ); }); diff --git a/web/components/cycles/list/cycles-list-map.tsx b/web/components/cycles/list/cycles-list-map.tsx index 7a99f5ab736..004c66fcac0 100644 --- a/web/components/cycles/list/cycles-list-map.tsx +++ b/web/components/cycles/list/cycles-list-map.tsx @@ -5,22 +5,15 @@ type Props = { cycleIds: string[]; projectId: string; workspaceSlug: string; - isArchived?: boolean; }; export const CyclesListMap: React.FC = (props) => { - const { cycleIds, projectId, workspaceSlug, isArchived } = props; + const { cycleIds, projectId, workspaceSlug } = props; return ( <> {cycleIds.map((cycleId) => ( - + ))} ); diff --git a/web/components/cycles/list/index.ts b/web/components/cycles/list/index.ts index 46a3557d7fe..5eda3286160 100644 --- a/web/components/cycles/list/index.ts +++ b/web/components/cycles/list/index.ts @@ -1,3 +1,4 @@ export * from "./cycles-list-item"; export * from "./cycles-list-map"; export * from "./root"; +export * from "./cycle-list-item-action"; diff --git a/web/components/cycles/list/root.tsx b/web/components/cycles/list/root.tsx index 622ca1ae013..34e34acf071 100644 --- a/web/components/cycles/list/root.tsx +++ b/web/components/cycles/list/root.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite"; import { ChevronRight } from "lucide-react"; import { Disclosure } from "@headlessui/react"; // components +import { ListLayout } from "@/components/core/list"; import { CyclePeekOverview, CyclesListMap } from "@/components/cycles"; // helpers import { cn } from "@/helpers/common.helper"; @@ -21,12 +22,11 @@ export const CyclesList: FC = observer((props) => { return (
-
+ {completedCycleIds.length !== 0 && ( @@ -43,16 +43,11 @@ export const CyclesList: FC = observer((props) => { )} - + )} -
+
diff --git a/web/components/cycles/quick-actions.tsx b/web/components/cycles/quick-actions.tsx index 9ba012cc42c..9c130ef7a70 100644 --- a/web/components/cycles/quick-actions.tsx +++ b/web/components/cycles/quick-actions.tsx @@ -2,27 +2,28 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // icons -import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react"; +import { ArchiveRestoreIcon, ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react"; // ui -import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; // components import { ArchiveCycleModal, CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles"; // constants import { EUserProjectRoles } from "@/constants/project"; // helpers +import { cn } from "@/helpers/common.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks import { useCycle, useEventTracker, useUser } from "@/hooks/store"; type Props = { + parentRef: React.RefObject; cycleId: string; projectId: string; workspaceSlug: string; - isArchived?: boolean; }; export const CycleQuickActions: React.FC = observer((props) => { - const { cycleId, projectId, workspaceSlug, isArchived } = props; + const { parentRef, cycleId, projectId, workspaceSlug } = props; // router const router = useRouter(); // states @@ -37,40 +38,31 @@ export const CycleQuickActions: React.FC = observer((props) => { const { getCycleById, restoreCycle } = useCycle(); // derived values const cycleDetails = getCycleById(cycleId); + const isArchived = !!cycleDetails?.archived_at; const isCompleted = cycleDetails?.status?.toLowerCase() === "completed"; // auth const isEditingAllowed = !!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER; - const handleCopyText = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => { + const cycleLink = `${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`; + const handleCopyText = () => + copyUrlToClipboard(cycleLink).then(() => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Link Copied!", message: "Cycle link copied to clipboard.", }); }); - }; + const handleOpenInNewTab = () => window.open(`/${cycleLink}`, "_blank"); - const handleEditCycle = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleEditCycle = () => { setTrackElement("Cycles page list layout"); setUpdateModal(true); }; - const handleArchiveCycle = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setArchiveCycleModal(true); - }; + const handleArchiveCycle = () => setArchiveCycleModal(true); - const handleRestoreCycle = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleRestoreCycle = async () => await restoreCycle(workspaceSlug, projectId, cycleId) .then(() => { setToast({ @@ -87,15 +79,61 @@ export const CycleQuickActions: React.FC = observer((props) => { message: "Cycle could not be restored. Please try again.", }) ); - }; - const handleDeleteCycle = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleDeleteCycle = () => { setTrackElement("Cycles page list layout"); setDeleteModal(true); }; + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: handleEditCycle, + shouldRender: isEditingAllowed && !isCompleted && !isArchived, + }, + { + key: "open-new-tab", + action: handleOpenInNewTab, + title: "Open in new tab", + icon: ExternalLink, + shouldRender: !isArchived, + }, + { + key: "copy-link", + action: handleCopyText, + title: "Copy link", + icon: LinkIcon, + shouldRender: !isArchived, + }, + { + key: "archive", + action: handleArchiveCycle, + title: "Archive", + description: isCompleted ? undefined : "Only completed cycle can\nbe archived.", + icon: ArchiveIcon, + className: "items-start", + iconClassName: "mt-1", + shouldRender: isEditingAllowed && !isArchived, + disabled: !isCompleted, + }, + { + key: "restore", + action: handleRestoreCycle, + title: "Restore", + icon: ArchiveRestoreIcon, + shouldRender: isEditingAllowed && isArchived, + }, + { + key: "delete", + action: handleDeleteCycle, + title: "Delete", + icon: Trash2, + shouldRender: isEditingAllowed && !isCompleted && !isArchived, + }, + ]; + return ( <> {cycleDetails && ( @@ -123,60 +161,43 @@ export const CycleQuickActions: React.FC = observer((props) => { />
)} - - {!isCompleted && isEditingAllowed && !isArchived && ( - - - - Edit cycle - - - )} - {isEditingAllowed && !isArchived && ( - - {isCompleted ? ( -
- - Archive cycle -
- ) : ( -
- -
-

Archive cycle

-

- Only completed cycle
can be archived. + + + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + disabled={item.disabled} + > + {item.icon && } +

+
{item.title}
+ {item.description && ( +

+ {item.description}

-
+ )}
- )} - - )} - {isEditingAllowed && isArchived && ( - - - - Restore cycle - - - )} - {!isArchived && ( - - - - Copy cycle link - - - )} -
- {!isCompleted && isEditingAllowed && ( - - - - Delete cycle - - - )} + + ); + })} ); diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index 106f3b3e010..261dc72f723 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; @@ -16,7 +16,7 @@ import { } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; // types -import { ICycle } from "@plane/types"; +import { ICycle, IIssueFilterOptions } from "@plane/types"; // ui import { Avatar, ArchiveIcon, CustomMenu, Loader, LayersIcon, TOAST_TYPE, setToast, TextArea } from "@plane/ui"; // components @@ -27,12 +27,13 @@ import { DateRangeDropdown } from "@/components/dropdowns"; // constants import { CYCLE_STATUS } from "@/constants/cycle"; import { CYCLE_UPDATED } from "@/constants/event-tracker"; +import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { EUserWorkspaceRoles } from "@/constants/workspace"; // helpers import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks -import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store"; +import { useEventTracker, useCycle, useUser, useMember, useIssues } from "@/hooks/store"; // services import { CycleService } from "@/services/cycle.service"; @@ -191,25 +192,36 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { } }; - // TODO: refactor this - // const handleFiltersUpdate = useCallback( - // (key: keyof IIssueFilterOptions, value: string | string[]) => { - // if (!workspaceSlug || !projectId) return; - // const newValues = issueFilters?.filters?.[key] ?? []; - - // if (Array.isArray(value)) { - // value.forEach((val) => { - // if (!newValues.includes(val)) newValues.push(val); - // }); - // } else { - // if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - // else newValues.push(value); - // } - - // updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { [key]: newValues }, cycleId); - // }, - // [workspaceSlug, projectId, cycleId, issueFilters, updateFilters] - // ); + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.CYCLE); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + // this validation is majorly for the filter start_date, target_date custom + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.FILTERS, + { [key]: newValues }, + cycleId + ); + }, + [workspaceSlug, projectId, cycleId, issueFilters, updateFilters] + ); const cycleStatus = cycleDetails?.status?.toLocaleLowerCase(); const isCompleted = cycleStatus === "completed"; @@ -251,8 +263,8 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { ? "0 Issue" : `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}` : cycleDetails.total_issues === 0 - ? "0 Issue" - : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + ? "0 Issue" + : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; const daysLeft = findHowManyDaysLeft(cycleDetails.end_date); @@ -404,7 +416,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { to: "End date", }} required={cycleDetails.status !== "draft"} - disabled={isArchived} + disabled={!isEditingAllowed || isArchived} /> )} /> @@ -551,6 +563,9 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }} totalIssues={cycleDetails.progress_snapshot.total_issues} isPeekView={Boolean(peekCycle)} + isCompleted={isCompleted} + filters={issueFilters} + handleFiltersUpdate={handleFiltersUpdate} />
)} @@ -570,6 +585,9 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }} totalIssues={cycleDetails.total_issues} isPeekView={Boolean(peekCycle)} + isCompleted={isCompleted} + filters={issueFilters} + handleFiltersUpdate={handleFiltersUpdate} />
)} diff --git a/web/components/dashboard/home-dashboard-widgets.tsx b/web/components/dashboard/home-dashboard-widgets.tsx index 0b13f8f11c4..010e46624b7 100644 --- a/web/components/dashboard/home-dashboard-widgets.tsx +++ b/web/components/dashboard/home-dashboard-widgets.tsx @@ -42,7 +42,7 @@ export const DashboardWidgets = observer(() => { if (!workspaceSlug || !homeDashboardId) return null; return ( -
+
{Object.entries(WIDGETS_LIST).map(([key, widget]) => { const WidgetComponent = widget.component; // if the widget doesn't exist, return null diff --git a/web/components/dashboard/widgets/recent-activity.tsx b/web/components/dashboard/widgets/recent-activity.tsx index bd69d1f6e4b..9f91aeeeb27 100644 --- a/web/components/dashboard/widgets/recent-activity.tsx +++ b/web/components/dashboard/widgets/recent-activity.tsx @@ -68,8 +68,8 @@ export const RecentActivityWidget: React.FC = observer((props) => {
)}
-
-

+

+

{currentUser?.id === activity.actor_detail.id ? "You" : activity.actor_detail?.display_name}{" "} @@ -81,7 +81,9 @@ export const RecentActivityWidget: React.FC = observer((props) => { )}

-

{calculateTimeAgo(activity.created_at)}

+

+ {calculateTimeAgo(activity.created_at)} +

))} diff --git a/web/components/dashboard/widgets/recent-collaborators/collaborators-list.tsx b/web/components/dashboard/widgets/recent-collaborators/collaborators-list.tsx index 3367f8b8c17..b4a210e0f6c 100644 --- a/web/components/dashboard/widgets/recent-collaborators/collaborators-list.tsx +++ b/web/components/dashboard/widgets/recent-collaborators/collaborators-list.tsx @@ -33,7 +33,7 @@ const CollaboratorListItem: React.FC = observer((prop
= (props) => { = observer((props) => { )} {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( - {selectedProject?.name ?? placeholder} + {selectedProject?.name ?? placeholder} )} {dropdownArrow && (
+
+ +
+ +
+
+
+ + {isAcceptedOrDeclined && ( + +
+ + Copy issue link +
+
+ )} + {isAcceptedOrDeclined && ( + + router.push(`/${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`) + } + > +
+ + Open issue +
+
+ )} + {canMarkAsAccepted && !isAcceptedOrDeclined && ( + setIsSnoozeDateModalOpen(true)}> +
+ + Snooze +
+
+ )} + {canMarkAsDuplicate && !isAcceptedOrDeclined && ( + setSelectDuplicateIssue(true)}> +
+ + Mark as duplicate +
+
+ )} + {canMarkAsAccepted && ( + setAcceptIssueModal(true)}> +
+ + Accept +
+
+ )} + {canMarkAsDeclined && ( + setDeclineIssueModal(true)}> +
+ + Decline +
+
+ )} + {canDelete && !isAcceptedOrDeclined && ( + setDeleteIssueModal(true)}> +
+ + Delete +
+
+ )} +
+
+
+
+ ); +}); diff --git a/web/components/inbox/content/index.ts b/web/components/inbox/content/index.ts index 029365f7a04..b499a3ea11a 100644 --- a/web/components/inbox/content/index.ts +++ b/web/components/inbox/content/index.ts @@ -1,4 +1,5 @@ export * from "./root"; export * from "./inbox-issue-header"; +export * from "./inbox-issue-mobile-header"; export * from "./issue-properties"; export * from "./issue-root"; diff --git a/web/components/inbox/content/issue-properties.tsx b/web/components/inbox/content/issue-properties.tsx index 2adf4bdc7ab..4ec9bb14da1 100644 --- a/web/components/inbox/content/issue-properties.tsx +++ b/web/components/inbox/content/issue-properties.tsx @@ -31,9 +31,10 @@ export const InboxIssueContentProperties: React.FC = observer((props) => const minDate = issue.start_date ? getDate(issue.start_date) : null; minDate?.setDate(minDate.getDate()); if (!issue || !issue?.id) return <>; + return (
-
+
Properties
diff --git a/web/components/inbox/content/issue-root.tsx b/web/components/inbox/content/issue-root.tsx index 545e96677b5..fd07da01aae 100644 --- a/web/components/inbox/content/issue-root.tsx +++ b/web/components/inbox/content/issue-root.tsx @@ -26,13 +26,11 @@ type Props = { isEditable: boolean; isSubmitting: "submitting" | "submitted" | "saved"; setIsSubmitting: Dispatch>; - swrIssueDescription: string | undefined; }; export const InboxIssueMainContent: React.FC = observer((props) => { const router = useRouter(); - const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting, swrIssueDescription } = - props; + const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props; // hooks const { currentUser } = useUser(); const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); @@ -116,7 +114,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { return ( <> -
+
= observer((props) => { issueOperations={issueOperations} disabled={!isEditable} value={issue.name} + containerClassName="-ml-3" /> {loader === "issue-loading" ? ( @@ -137,11 +136,12 @@ export const InboxIssueMainContent: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={issue.project_id} issueId={issue.id} - swrIssueDescription={swrIssueDescription} + swrIssueDescription={issue.description_html ?? "

"} initialValue={issue.description_html ?? "

"} disabled={!isEditable} issueOperations={issueOperations} setIsSubmitting={(value) => setIsSubmitting(value)} + containerClassName="-ml-3 !mb-6 border-none" /> )} @@ -154,12 +154,15 @@ export const InboxIssueMainContent: React.FC = observer((props) => { /> )}
- + +
+ +
= observer((props) => { duplicateIssueDetails={inboxIssue?.duplicate_issue_detail} /> -
+
diff --git a/web/components/inbox/content/root.tsx b/web/components/inbox/content/root.tsx index 9af3da0bc63..7874b74a51d 100644 --- a/web/components/inbox/content/root.tsx +++ b/web/components/inbox/content/root.tsx @@ -9,10 +9,12 @@ type TInboxContentRoot = { workspaceSlug: string; projectId: string; inboxIssueId: string; + isMobileSidebar: boolean; + setIsMobileSidebar: (value: boolean) => void; }; export const InboxContentRoot: FC = observer((props) => { - const { workspaceSlug, projectId, inboxIssueId } = props; + const { workspaceSlug, projectId, inboxIssueId, isMobileSidebar, setIsMobileSidebar } = props; // states const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); // hooks @@ -22,14 +24,13 @@ export const InboxContentRoot: FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); - const { data: swrIssueDetails } = useSWR( + useSWR( workspaceSlug && projectId && inboxIssueId ? `PROJECT_INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}` : null, workspaceSlug && projectId && inboxIssueId ? () => fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId) - : null, - { revalidateOnFocus: true } + : null ); const isEditable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -43,13 +44,15 @@ export const InboxContentRoot: FC = observer((props) => {
-
+
= observer((props) => { isEditable={isEditable && !isIssueDisabled} isSubmitting={isSubmitting} setIsSubmitting={setIsSubmitting} - swrIssueDescription={swrIssueDetails?.issue.description_html} />
diff --git a/web/components/inbox/inbox-filter/applied-filters/member.tsx b/web/components/inbox/inbox-filter/applied-filters/member.tsx index 5d488a23c45..b234d3c57de 100644 --- a/web/components/inbox/inbox-filter/applied-filters/member.tsx +++ b/web/components/inbox/inbox-filter/applied-filters/member.tsx @@ -34,8 +34,8 @@ export const InboxIssueAppliedFiltersMember: FC if (!optionDetail) return <>; return (
-
- +
+
{optionDetail?.display_name}
{ {/* priority */} {/* assignees */} - + {/* created_by */} {/* label */} diff --git a/web/components/inbox/inbox-filter/filters/filter-selection.tsx b/web/components/inbox/inbox-filter/filters/filter-selection.tsx index fd1dc951c89..1d7ddd0edf8 100644 --- a/web/components/inbox/inbox-filter/filters/filter-selection.tsx +++ b/web/components/inbox/inbox-filter/filters/filter-selection.tsx @@ -60,8 +60,8 @@ export const InboxIssueFilterSelection: FC = observer(() => { {/* assignees */}
diff --git a/web/components/inbox/inbox-filter/filters/members.tsx b/web/components/inbox/inbox-filter/filters/members.tsx index f2776104bd9..f86c385e73a 100644 --- a/web/components/inbox/inbox-filter/filters/members.tsx +++ b/web/components/inbox/inbox-filter/filters/members.tsx @@ -6,7 +6,7 @@ import { Avatar, Loader } from "@plane/ui"; // components import { FilterHeader, FilterOption } from "@/components/issues"; // hooks -import { useMember, useProjectInbox } from "@/hooks/store"; +import { useMember, useProjectInbox, useUser } from "@/hooks/store"; type Props = { filterKey: TInboxIssueFilterMemberKeys; @@ -20,6 +20,7 @@ export const FilterMember: FC = observer((props: Props) => { // hooks const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); const { getUserDetails } = useMember(); + const { currentUser } = useUser(); // states const [itemsToRender, setItemsToRender] = useState(5); const [previewEnabled, setPreviewEnabled] = useState(true); @@ -34,6 +35,7 @@ export const FilterMember: FC = observer((props: Props) => { return sortBy(filteredOptions, [ (memberId) => !filterValue.includes(memberId), + (memberId) => memberId !== currentUser?.id, (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), ]); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -71,7 +73,7 @@ export const FilterMember: FC = observer((props: Props) => { isChecked={filterValue?.includes(member.id) ? true : false} onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(member.id))} icon={} - title={member.display_name} + title={currentUser?.id === member.id ? "You" : member?.display_name} /> ); })} diff --git a/web/components/inbox/modals/create-edit-modal/create-root.tsx b/web/components/inbox/modals/create-edit-modal/create-root.tsx index d2a0d1a2f25..010884451bf 100644 --- a/web/components/inbox/modals/create-edit-modal/create-root.tsx +++ b/web/components/inbox/modals/create-edit-modal/create-root.tsx @@ -116,10 +116,16 @@ export const InboxIssueCreateRoot: FC = observer((props) setFormSubmitting(false); }; + const isTitleLengthMoreThan255Character = formData?.name ? formData.name.length > 255 : false; + if (!workspaceSlug || !projectId || !workspaceId) return <>; return (
- + = observer((props) data={formData} handleData={handleFormData} editorRef={descriptionEditorRef} + containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]" />
-
setCreateMore((prevData) => !prevData)}> +
setCreateMore((prevData) => !prevData)} + > + {}} size="sm" /> Create more - {}} size="md" />
-
diff --git a/web/components/inbox/modals/create-edit-modal/edit-root.tsx b/web/components/inbox/modals/create-edit-modal/edit-root.tsx index 77472450609..90bb0e2582c 100644 --- a/web/components/inbox/modals/create-edit-modal/edit-root.tsx +++ b/web/components/inbox/modals/create-edit-modal/edit-root.tsx @@ -121,10 +121,16 @@ export const InboxIssueEditRoot: FC = observer((props) => { setFormSubmitting(false); }; + const isTitleLengthMoreThan255Character = formData?.name ? formData.name.length > 255 : false; + if (!workspaceSlug || !projectId || !workspaceId || !formData) return <>; return (
- + = observer((props) => { data={formData} handleData={handleFormData} editorRef={descriptionEditorRef} + containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]" />
-
diff --git a/web/components/inbox/modals/create-edit-modal/issue-description.tsx b/web/components/inbox/modals/create-edit-modal/issue-description.tsx index c749a6700c4..2c9a9cfd2fa 100644 --- a/web/components/inbox/modals/create-edit-modal/issue-description.tsx +++ b/web/components/inbox/modals/create-edit-modal/issue-description.tsx @@ -5,10 +5,13 @@ import { TIssue } from "@plane/types"; import { Loader } from "@plane/ui"; // components import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor"; +// helpers +import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; // hooks import { useProjectInbox } from "@/hooks/store"; type TInboxIssueDescription = { + containerClassName?: string; workspaceSlug: string; projectId: string; workspaceId: string; @@ -19,7 +22,7 @@ type TInboxIssueDescription = { // TODO: have to implement GPT Assistance export const InboxIssueDescription: FC = observer((props) => { - const { workspaceSlug, projectId, workspaceId, data, handleData, editorRef } = props; + const { containerClassName, workspaceSlug, projectId, workspaceId, data, handleData, editorRef } = props; // hooks const { loader } = useProjectInbox(); @@ -39,10 +42,8 @@ export const InboxIssueDescription: FC = observer((props projectId={projectId} dragDropEnabled={false} onChange={(_description: object, description_html: string) => handleData("description_html", description_html)} - placeholder={(isFocused) => { - if (isFocused) return "Press '/' for commands..."; - else return "Click to add description"; - }} + placeholder={getDescriptionPlaceholder} + containerClassName={containerClassName} />
); diff --git a/web/components/inbox/modals/create-edit-modal/issue-title.tsx b/web/components/inbox/modals/create-edit-modal/issue-title.tsx index 27361b1f4ee..31a44bf7670 100644 --- a/web/components/inbox/modals/create-edit-modal/issue-title.tsx +++ b/web/components/inbox/modals/create-edit-modal/issue-title.tsx @@ -6,10 +6,11 @@ import { Input } from "@plane/ui"; type TInboxIssueTitle = { data: Partial; handleData: (issueKey: keyof Partial, issueValue: Partial[keyof Partial]) => void; + isTitleLengthMoreThan255Character?: boolean; }; export const InboxIssueTitle: FC = observer((props) => { - const { data, handleData } = props; + const { data, handleData, isTitleLengthMoreThan255Character } = props; return (
@@ -21,9 +22,11 @@ export const InboxIssueTitle: FC = observer((props) => { onChange={(e) => handleData("name", e.target.value)} placeholder="Title" className="w-full resize-none text-xl" - maxLength={255} required /> + {isTitleLengthMoreThan255Character && ( + Title should be less than 255 characters + )}
); }); diff --git a/web/components/inbox/modals/create-issue-modal.tsx b/web/components/inbox/modals/create-issue-modal.tsx deleted file mode 100644 index 3cc642be890..00000000000 --- a/web/components/inbox/modals/create-issue-modal.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import { Fragment, useRef, useState } from "react"; -import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -import { Controller, useForm } from "react-hook-form"; -import { Sparkle } from "lucide-react"; -import { Transition, Dialog } from "@headlessui/react"; -import { EditorRefApi } from "@plane/rich-text-editor"; -// types -import { TIssue } from "@plane/types"; -// ui -import { Button, Input, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; -// components -import { GptAssistantPopover } from "@/components/core"; -import { PriorityDropdown } from "@/components/dropdowns"; -import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor"; -import { ISSUE_CREATED } from "@/constants/event-tracker"; -import { useApplication, useEventTracker, useWorkspace, useProjectInbox } from "@/hooks/store"; -// services -import { AIService } from "@/services/ai.service"; -// components -// ui -// types -// constants - -type Props = { - isOpen: boolean; - onClose: () => void; -}; - -const defaultValues: Partial = { - name: "", - description_html: "

", - priority: "none", -}; - -// services -const aiService = new AIService(); - -export const CreateInboxIssueModal: React.FC = observer((props) => { - const { isOpen, onClose } = props; - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - if (!workspaceSlug || !projectId) return null; - // states - const [createMore, setCreateMore] = useState(false); - const [gptAssistantModal, setGptAssistantModal] = useState(false); - const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); - // refs - const editorRef = useRef(null); - // hooks - const workspaceStore = useWorkspace(); - const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug.toString() as string)?.id.toString() as string; - - // store hooks - const { createInboxIssue } = useProjectInbox(); - const { - config: { envConfig }, - } = useApplication(); - const { captureIssueEvent } = useEventTracker(); - // form info - const { - control, - formState: { errors, isSubmitting }, - handleSubmit, - reset, - watch, - getValues, - } = useForm>({ defaultValues }); - const issueName = watch("name"); - - const handleClose = () => { - onClose(); - reset(defaultValues); - editorRef?.current?.clearEditor(); - }; - - const handleFormSubmit = async (formData: Partial) => { - if (!workspaceSlug || !projectId) return; - await createInboxIssue(workspaceSlug.toString(), projectId.toString(), formData) - .then((res) => { - if (!createMore) { - router.push(`/${workspaceSlug}/projects/${projectId}/inbox/?currentTab=open&inboxIssueId=${res?.issue?.id}`); - handleClose(); - } else { - reset(defaultValues); - editorRef?.current?.clearEditor(); - } - captureIssueEvent({ - eventName: ISSUE_CREATED, - payload: { - ...formData, - state: "SUCCESS", - element: "Inbox page", - }, - path: router.pathname, - }); - }) - .catch((error) => { - console.error(error); - captureIssueEvent({ - eventName: ISSUE_CREATED, - payload: { - ...formData, - state: "FAILED", - element: "Inbox page", - }, - path: router.pathname, - }); - }); - }; - - const handleAiAssistance = async (response: string) => { - if (!workspaceSlug || !projectId) return; - editorRef.current?.setEditorValueAtCursorPosition(response); - }; - - const handleAutoGenerateDescription = async () => { - const issueName = getValues("name"); - if (!workspaceSlug || !projectId || !issueName) return; - - setIAmFeelingLucky(true); - - aiService - .createGptTask(workspaceSlug as string, projectId as string, { - prompt: issueName, - task: "Generate a proper description for this issue.", - }) - .then((res) => { - if (res.response === "") - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: - "Issue title isn't informative enough to generate the description. Please try with a different title.", - }); - else handleAiAssistance(res.response_html); - }) - .catch((err) => { - const error = err?.data?.error; - - if (err.status === 429) - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: error || "You have reached the maximum number of requests of 50 requests per month per user.", - }); - else - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: error || "Some error occurred. Please try again.", - }); - }) - .finally(() => setIAmFeelingLucky(false)); - }; - - return ( - - - -
- - -
-
- - - -
-

Create Inbox Issue

-
-
-
- ( - - )} - /> -
-
-
- {watch("name") && issueName !== "" && ( - - )} - - {envConfig?.has_openai_configured && ( - { - setGptAssistantModal((prevData) => !prevData); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - onResponse={(response) => { - handleAiAssistance(response); - }} - button={ - - } - className="!min-w-[38rem]" - placement="top-end" - /> - )} -
- ( -

" : value} - ref={editorRef} - workspaceSlug={workspaceSlug.toString()} - workspaceId={workspaceId} - projectId={projectId.toString()} - dragDropEnabled={false} - onChange={(_description: object, description_html: string) => { - onChange(description_html); - }} - /> - )} - /> -
- -
- ( -
- -
- )} - /> -
-
-
-
-
-
setCreateMore((prevData) => !prevData)} - > - Create more - {}} size="md" /> -
-
- - -
-
- -
-
-
-
-
-
- ); -}); diff --git a/web/components/inbox/root.tsx b/web/components/inbox/root.tsx index 258421708d2..7d4aff955df 100644 --- a/web/components/inbox/root.tsx +++ b/web/components/inbox/root.tsx @@ -1,13 +1,15 @@ -import { FC } from "react"; +import { FC, useState } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; -import { Inbox } from "lucide-react"; +import { Inbox, PanelLeft } from "lucide-react"; // components import { EmptyState } from "@/components/empty-state"; import { InboxSidebar, InboxContentRoot } from "@/components/inbox"; import { InboxLayoutLoader } from "@/components/ui"; // constants import { EmptyStateType } from "@/constants/empty-state"; +// helpers +import { cn } from "@/helpers/common.helper"; // hooks import { useProjectInbox } from "@/hooks/store"; @@ -20,6 +22,8 @@ type TInboxIssueRoot = { export const InboxIssueRoot: FC = observer((props) => { const { workspaceSlug, projectId, inboxIssueId, inboxAccessible } = props; + // states + const [isMobileSidebar, setIsMobileSidebar] = useState(true); // hooks const { loader, error, fetchInboxIssues } = useProjectInbox(); @@ -52,20 +56,43 @@ export const InboxIssueRoot: FC = observer((props) => { ); return ( -
- - - {inboxIssueId ? ( - - ) : ( -
- + <> + {!inboxIssueId && ( +
+ setIsMobileSidebar(!isMobileSidebar)} + className={cn("w-4 h-4 ", isMobileSidebar ? "text-custom-primary-100" : " text-custom-text-200")} + />
)} -
+
+
+ +
+ + {inboxIssueId ? ( + + ) : ( +
+ +
+ )} +
+ ); }); diff --git a/web/components/inbox/sidebar/inbox-list-item.tsx b/web/components/inbox/sidebar/inbox-list-item.tsx index e407f0b3c86..593d114d17d 100644 --- a/web/components/inbox/sidebar/inbox-list-item.tsx +++ b/web/components/inbox/sidebar/inbox-list-item.tsx @@ -19,10 +19,11 @@ type InboxIssueListItemProps = { projectId: string; projectIdentifier?: string; inboxIssue: IInboxIssueStore; + setIsMobileSidebar: (value: boolean) => void; }; export const InboxIssueListItem: FC = observer((props) => { - const { workspaceSlug, projectId, inboxIssue, projectIdentifier } = props; + const { workspaceSlug, projectId, inboxIssue, projectIdentifier, setIsMobileSidebar } = props; // router const router = useRouter(); const { inboxIssueId } = router.query; @@ -34,6 +35,7 @@ export const InboxIssueListItem: FC = observer((props) const handleIssueRedirection = (event: MouseEvent, currentIssueId: string | undefined) => { if (inboxIssueId === currentIssueId) event.preventDefault(); + setIsMobileSidebar(false); }; if (!issue) return <>; @@ -48,7 +50,7 @@ export const InboxIssueListItem: FC = observer((props)
diff --git a/web/components/inbox/sidebar/inbox-list.tsx b/web/components/inbox/sidebar/inbox-list.tsx index 4bde1c43e1d..be435cd7752 100644 --- a/web/components/inbox/sidebar/inbox-list.tsx +++ b/web/components/inbox/sidebar/inbox-list.tsx @@ -10,16 +10,18 @@ export type InboxIssueListProps = { projectId: string; projectIdentifier?: string; inboxIssues: IInboxIssueStore[]; + setIsMobileSidebar: (value: boolean) => void; }; export const InboxIssueList: FC = observer((props) => { - const { workspaceSlug, projectId, projectIdentifier, inboxIssues } = props; + const { workspaceSlug, projectId, projectIdentifier, inboxIssues, setIsMobileSidebar } = props; return ( <> {inboxIssues.map((inboxIssue) => ( void; }; const tabNavigationOptions: { key: TInboxIssueCurrentTab; label: string }[] = [ @@ -33,7 +34,7 @@ const tabNavigationOptions: { key: TInboxIssueCurrentTab; label: string }[] = [ ]; export const InboxSidebar: FC = observer((props) => { - const { workspaceSlug, projectId } = props; + const { workspaceSlug, projectId, setIsMobileSidebar } = props; // ref const containerRef = useRef(null); const elementRef = useRef(null); @@ -64,14 +65,14 @@ export const InboxSidebar: FC = observer((props) => { }); return ( -
+
-
+
{tabNavigationOptions.map((option) => (
{ @@ -109,6 +110,7 @@ export const InboxSidebar: FC = observer((props) => { > {inboxIssuesArray.length > 0 ? ( = observer((props) => { getAppliedFiltersCount > 0 ? EmptyStateType.INBOX_SIDEBAR_FILTER_EMPTY_STATE : currentTab === EInboxIssueCurrentTab.OPEN - ? EmptyStateType.INBOX_SIDEBAR_OPEN_TAB - : EmptyStateType.INBOX_SIDEBAR_CLOSED_TAB + ? EmptyStateType.INBOX_SIDEBAR_OPEN_TAB + : EmptyStateType.INBOX_SIDEBAR_CLOSED_TAB } layout="screen-simple" /> diff --git a/web/components/issues/attachment/attachment-detail.tsx b/web/components/issues/attachment/attachment-detail.tsx index 8a1ade183ce..9e6a35f5fca 100644 --- a/web/components/issues/attachment/attachment-detail.tsx +++ b/web/components/issues/attachment/attachment-detail.tsx @@ -2,19 +2,19 @@ import { FC } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { AlertCircle, X } from "lucide-react"; +// ui import { Tooltip } from "@plane/ui"; +// icons import { getFileIcon } from "@/components/icons"; +// components +import { IssueAttachmentDeleteModal } from "@/components/issues"; +// helpers import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; import { truncateText } from "@/helpers/string.helper"; +// hooks import { useIssueDetail, useMember } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; -// hooks -// ui -// components -// icons -// helper -import { IssueAttachmentDeleteModal } from "./delete-attachment-confirmation-modal"; // types import { TAttachmentOperations } from "./root"; @@ -36,24 +36,24 @@ export const IssueAttachmentsDetail: FC = observer((pro isDeleteAttachmentModalOpen, toggleDeleteAttachmentModal, } = useIssueDetail(); - // states + // derived values + const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; + // hooks const { isMobile } = usePlatformOS(); - const attachment = attachmentId && getAttachmentById(attachmentId); if (!attachment) return <>; + return ( <> - toggleDeleteAttachmentModal(false)} - handleAttachmentOperations={handleAttachmentOperations} - data={attachment} - /> - -
+ {isDeleteAttachmentModalOpen === attachment.id && ( + toggleDeleteAttachmentModal(null)} + handleAttachmentOperations={handleAttachmentOperations} + data={attachment} + /> + )} +
{getFileIcon(getFileExtension(attachment.asset))}
@@ -83,7 +83,7 @@ export const IssueAttachmentsDetail: FC = observer((pro {!disabled && ( - )} diff --git a/web/components/issues/attachment/attachment-upload.tsx b/web/components/issues/attachment/attachment-upload.tsx index 46b697691cf..dcb52ec13cc 100644 --- a/web/components/issues/attachment/attachment-upload.tsx +++ b/web/components/issues/attachment/attachment-upload.tsx @@ -1,11 +1,11 @@ import { useCallback, useState } from "react"; import { observer } from "mobx-react-lite"; import { useDropzone } from "react-dropzone"; -// hooks // constants import { MAX_FILE_SIZE } from "@/constants/common"; // helpers import { generateFileName } from "@/helpers/attachment.helper"; +// hooks import { useApplication } from "@/hooks/store"; // types import { TAttachmentOperations } from "./root"; @@ -27,24 +27,28 @@ export const IssueAttachmentUpload: React.FC = observer((props) => { // states const [isLoading, setIsLoading] = useState(false); - const onDrop = useCallback((acceptedFiles: File[]) => { - const currentFile: File = acceptedFiles[0]; - if (!currentFile || !workspaceSlug) return; + const onDrop = useCallback( + (acceptedFiles: File[]) => { + const currentFile: File = acceptedFiles[0]; + if (!currentFile || !workspaceSlug) return; - const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { type: currentFile.type }); - const formData = new FormData(); - formData.append("asset", uploadedFile); - formData.append( - "attributes", - JSON.stringify({ - name: uploadedFile.name, - size: uploadedFile.size, - }) - ); - setIsLoading(true); - handleAttachmentOperations.create(formData).finally(() => setIsLoading(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { + type: currentFile.type, + }); + const formData = new FormData(); + formData.append("asset", uploadedFile); + formData.append( + "attributes", + JSON.stringify({ + name: uploadedFile.name, + size: uploadedFile.size, + }) + ); + setIsLoading(true); + handleAttachmentOperations.create(formData).finally(() => setIsLoading(false)); + }, + [handleAttachmentOperations, workspaceSlug] + ); const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({ onDrop, diff --git a/web/components/issues/attachment/delete-attachment-confirmation-modal.tsx b/web/components/issues/attachment/delete-attachment-modal.tsx similarity index 94% rename from web/components/issues/attachment/delete-attachment-confirmation-modal.tsx rename to web/components/issues/attachment/delete-attachment-modal.tsx index bd69c3cac1e..4f58674f120 100644 --- a/web/components/issues/attachment/delete-attachment-confirmation-modal.tsx +++ b/web/components/issues/attachment/delete-attachment-modal.tsx @@ -1,4 +1,4 @@ -import { FC, Fragment, Dispatch, SetStateAction, useState } from "react"; +import { FC, Fragment, useState } from "react"; import { AlertTriangle } from "lucide-react"; import { Dialog, Transition } from "@headlessui/react"; import type { TIssueAttachment } from "@plane/types"; @@ -14,18 +14,18 @@ export type TAttachmentOperationsRemoveModal = Exclude>; + onClose: () => void; data: TIssueAttachment; handleAttachmentOperations: TAttachmentOperationsRemoveModal; }; export const IssueAttachmentDeleteModal: FC = (props) => { - const { isOpen, setIsOpen, data, handleAttachmentOperations } = props; - // state + const { isOpen, onClose, data, handleAttachmentOperations } = props; + // states const [loader, setLoader] = useState(false); const handleClose = () => { - setIsOpen(false); + onClose(); setLoader(false); }; diff --git a/web/components/issues/attachment/index.ts b/web/components/issues/attachment/index.ts index d4385e7da7c..928cd461396 100644 --- a/web/components/issues/attachment/index.ts +++ b/web/components/issues/attachment/index.ts @@ -1,7 +1,5 @@ -export * from "./root"; - +export * from "./attachment-detail"; export * from "./attachment-upload"; -export * from "./delete-attachment-confirmation-modal"; - export * from "./attachments-list"; -export * from "./attachment-detail"; +export * from "./delete-attachment-modal"; +export * from "./root"; diff --git a/web/components/issues/attachment/root.tsx b/web/components/issues/attachment/root.tsx index c915ebce945..b9dd5e290f6 100644 --- a/web/components/issues/attachment/root.tsx +++ b/web/components/issues/attachment/root.tsx @@ -95,7 +95,7 @@ export const IssueAttachmentRoot: FC = (props) => { } }, }), - [workspaceSlug, projectId, issueId, createAttachment, removeAttachment] + [captureIssueEvent, workspaceSlug, projectId, issueId, createAttachment, removeAttachment] ); return ( diff --git a/web/components/issues/description-input.tsx b/web/components/issues/description-input.tsx index aededa28f9f..538f5444eeb 100644 --- a/web/components/issues/description-input.tsx +++ b/web/components/issues/description-input.tsx @@ -9,23 +9,27 @@ import { Loader } from "@plane/ui"; // components import { RichTextEditor, RichTextReadOnlyEditor } from "@/components/editor"; import { TIssueOperations } from "@/components/issues/issue-detail"; +// helpers +import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; // hooks import { useWorkspace } from "@/hooks/store"; export type IssueDescriptionInputProps = { + containerClassName?: string; workspaceSlug: string; projectId: string; issueId: string; initialValue: string | undefined; disabled?: boolean; issueOperations: TIssueOperations; - placeholder?: string | ((isFocused: boolean) => string); + placeholder?: string | ((isFocused: boolean, value: string) => string); setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void; swrIssueDescription: string | null | undefined; }; export const IssueDescriptionInput: FC = observer((props) => { const { + containerClassName, workspaceSlug, projectId, issueId, @@ -106,18 +110,14 @@ export const IssueDescriptionInput: FC = observer((p debouncedFormSave(); }} placeholder={ - placeholder - ? placeholder - : (isFocused) => { - if (isFocused) return "Press '/' for commands..."; - else return "Click to add description"; - } + placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value) } + containerClassName={containerClassName} /> ) : ( ) } diff --git a/web/components/issues/issue-detail/issue-activity/activity/root.tsx b/web/components/issues/issue-detail/issue-activity/activity/root.tsx deleted file mode 100644 index 88dbead96ca..00000000000 --- a/web/components/issues/issue-detail/issue-activity/activity/root.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// hooks -import { useIssueDetail } from "@/hooks/store"; -// components -import { IssueActivityList } from "./activity-list"; - -type TIssueActivityRoot = { - issueId: string; -}; - -export const IssueActivityRoot: FC = observer((props) => { - const { issueId } = props; - // hooks - const { - activity: { getActivitiesByIssueId }, - } = useIssueDetail(); - - const activityIds = getActivitiesByIssueId(issueId); - - if (!activityIds) return <>; - return ( -
- {activityIds.map((activityId, index) => ( - - ))} -
- ); -}); diff --git a/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx b/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx index 0263a37e107..ae6e75c79cd 100644 --- a/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx +++ b/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx @@ -18,10 +18,11 @@ type TIssueCommentCreate = { workspaceSlug: string; activityOperations: TActivityOperations; showAccessSpecifier?: boolean; + issueId: string; }; export const IssueCommentCreate: FC = (props) => { - const { workspaceSlug, projectId, activityOperations, showAccessSpecifier = false } = props; + const { workspaceSlug, projectId, issueId, activityOperations, showAccessSpecifier = false } = props; // refs const editorRef = useRef(null); // store hooks @@ -72,6 +73,8 @@ export const IssueCommentCreate: FC = (props) => { render={({ field: { value, onChange } }) => (

"} projectId={projectId} workspaceSlug={workspaceSlug} onEnterKeyPress={(e) => { diff --git a/web/components/issues/issue-detail/issue-activity/index.ts b/web/components/issues/issue-detail/issue-activity/index.ts index 13b2088357d..bb6b1405a40 100644 --- a/web/components/issues/issue-detail/issue-activity/index.ts +++ b/web/components/issues/issue-detail/issue-activity/index.ts @@ -3,7 +3,6 @@ export * from "./root"; export * from "./activity-comment-root"; // activity -export * from "./activity/root"; export * from "./activity/activity-list"; // issue comment diff --git a/web/components/issues/issue-detail/issue-activity/root.tsx b/web/components/issues/issue-detail/issue-activity/root.tsx index cdcd63057dc..d8de769c196 100644 --- a/web/components/issues/issue-detail/issue-activity/root.tsx +++ b/web/components/issues/issue-detail/issue-activity/root.tsx @@ -1,12 +1,12 @@ import { FC, useMemo, useState } from "react"; import { observer } from "mobx-react-lite"; -import { History, LucideIcon, MessageCircle, ListRestart } from "lucide-react"; +import { History, LucideIcon, MessageCircle } from "lucide-react"; // types import { TIssueComment } from "@plane/types"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; // components -import { IssueActivityCommentRoot, IssueActivityRoot, IssueCommentRoot, IssueCommentCreate } from "@/components/issues"; +import { IssueActivityCommentRoot, IssueCommentRoot, IssueCommentCreate } from "@/components/issues"; // hooks import { useIssueDetail, useProject } from "@/hooks/store"; @@ -17,24 +17,19 @@ type TIssueActivity = { disabled?: boolean; }; -type TActivityTabs = "all" | "activity" | "comments"; +type TActivityTabs = "all" | "comments"; const activityTabs: { key: TActivityTabs; title: string; icon: LucideIcon }[] = [ - { - key: "all", - title: "All activity", - icon: History, - }, - { - key: "activity", - title: "Updates", - icon: ListRestart, - }, { key: "comments", title: "Comments", icon: MessageCircle, }, + { + key: "all", + title: "All activity", + icon: History, + }, ]; export type TActivityOperations = { @@ -49,7 +44,7 @@ export const IssueActivity: FC = observer((props) => { const { createComment, updateComment, removeComment } = useIssueDetail(); const { getProjectById } = useProject(); // state - const [activityTab, setActivityTab] = useState("all"); + const [activityTab, setActivityTab] = useState("comments"); const activityOperations: TActivityOperations = useMemo( () => ({ @@ -151,6 +146,7 @@ export const IssueActivity: FC = observer((props) => { /> {!disabled && ( = observer((props) => { /> )}
- ) : activityTab === "activity" ? ( - ) : (
= observer((props) => { /> {!disabled && ( = (props) => { rel="noopener noreferrer" className="flex items-center justify-center p-1 hover:bg-custom-background-80" > - +
diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index 79cc84ecb1b..c8c52ce1e2d 100644 --- a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -1,19 +1,19 @@ import { useState } from "react"; import { Draggable } from "@hello-pangea/dnd"; -import { Placement } from "@popperjs/core"; import { observer } from "mobx-react-lite"; import { TIssue, TIssueMap } from "@plane/types"; // components import { CalendarQuickAddIssueForm, CalendarIssueBlockRoot } from "@/components/issues"; // helpers import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +import { TRenderQuickActions } from "../list/list-view-types"; // types type Props = { date: Date; issues: TIssueMap | undefined; issueIdList: string[] | null; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; + quickActions: TRenderQuickActions; isDragDisabled?: boolean; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; diff --git a/web/components/issues/issue-layouts/calendar/week-days.tsx b/web/components/issues/issue-layouts/calendar/week-days.tsx index 429c237e205..c8524adccd4 100644 --- a/web/components/issues/issue-layouts/calendar/week-days.tsx +++ b/web/components/issues/issue-layouts/calendar/week-days.tsx @@ -1,4 +1,3 @@ -import { Placement } from "@popperjs/core"; import { observer } from "mobx-react-lite"; import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; // components @@ -10,6 +9,7 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle"; import { IModuleIssuesFilter } from "@/store/issue/module"; import { IProjectIssuesFilter } from "@/store/issue/project"; import { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import { TRenderQuickActions } from "../list/list-view-types"; import { ICalendarDate, ICalendarWeek } from "./types"; type Props = { @@ -17,7 +17,7 @@ type Props = { issues: TIssueMap | undefined; groupedIssueIds: TGroupedIssues; week: ICalendarWeek | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; + quickActions: TRenderQuickActions; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; quickAddCallback?: ( diff --git a/web/components/issues/issue-layouts/empty-states/project-view.tsx b/web/components/issues/issue-layouts/empty-states/project-view.tsx index fc4bc6ea99c..2939c524ff3 100644 --- a/web/components/issues/issue-layouts/empty-states/project-view.tsx +++ b/web/components/issues/issue-layouts/empty-states/project-view.tsx @@ -1,10 +1,12 @@ import { observer } from "mobx-react-lite"; import { PlusIcon } from "lucide-react"; -// hooks +// components import { EmptyState } from "@/components/common"; +// constants import { EIssuesStoreType } from "@/constants/issue"; -import { useApplication, useEventTracker } from "@/hooks/store"; -// components +import { EUserProjectRoles } from "@/constants/project"; +// hooks +import { useApplication, useEventTracker, useUser } from "@/hooks/store"; // assets import emptyIssue from "public/empty-state/issue.svg"; @@ -12,6 +14,11 @@ export const ProjectViewEmptyState: React.FC = observer(() => { // store hooks const { commandPalette: commandPaletteStore } = useApplication(); const { setTrackElement } = useEventTracker(); + const { + membership: { currentProjectRole }, + } = useUser(); + // auth + const isCreatingIssueAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; return (
@@ -19,14 +26,18 @@ export const ProjectViewEmptyState: React.FC = observer(() => { title="View issues will appear here" description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done." image={emptyIssue} - primaryButton={{ - text: "New issue", - icon: , - onClick: () => { - setTrackElement("View issue empty state"); - commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT_VIEW); - }, - }} + primaryButton={ + isCreatingIssueAllowed + ? { + text: "New issue", + icon: , + onClick: () => { + setTrackElement("View issue empty state"); + commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT_VIEW); + }, + } + : undefined + } />
); diff --git a/web/components/issues/issue-layouts/filters/applied-filters/cycle.tsx b/web/components/issues/issue-layouts/filters/applied-filters/cycle.tsx index 4f139727f13..592a1212370 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/cycle.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/cycle.tsx @@ -28,9 +28,9 @@ export const AppliedCycleFilters: React.FC = observer((props) => { const cycleStatus = (cycleDetails?.status ? cycleDetails?.status.toLocaleLowerCase() : "draft") as TCycleGroups; return ( -
+
- {cycleDetails.name} + {cycleDetails.name} {editable && ( + + + - removeRoutePeekId()}> - - + + removeRoutePeekId()}> + + + {currentMode && (
setPeekMode(val)} customButton={ - + + + } > {PEEK_OPTIONS.map((mode) => ( @@ -172,7 +178,7 @@ export const IssuePeekOverviewHeader: FC = observer((pr })} onClick={() => { if (!isInArchivableGroup) return; - toggleArchiveIssueModal(true); + toggleArchiveIssueModal(issueId); }} > diff --git a/web/components/issues/peek-overview/issue-detail.tsx b/web/components/issues/peek-overview/issue-detail.tsx index 29b83f78887..6d4718fe745 100644 --- a/web/components/issues/peek-overview/issue-detail.tsx +++ b/web/components/issues/peek-overview/issue-detail.tsx @@ -69,6 +69,7 @@ export const PeekOverviewIssueDetails: FC = observer( issueOperations={issueOperations} disabled={disabled} value={issue.name} + containerClassName="-ml-3" /> = observer( issueId={issue.id} initialValue={issueDescription} // for now peek overview doesn't have live syncing while tab changes - swrIssueDescription={null} + swrIssueDescription={issueDescription} disabled={disabled} issueOperations={issueOperations} setIsSubmitting={(value) => setIsSubmitting(value)} + containerClassName="-ml-3 !mb-6 border-none" /> {currentUser && ( diff --git a/web/components/issues/peek-overview/view.tsx b/web/components/issues/peek-overview/view.tsx index aaa150674a8..3aefa0438f2 100644 --- a/web/components/issues/peek-overview/view.tsx +++ b/web/components/issues/peek-overview/view.tsx @@ -87,8 +87,8 @@ export const IssueView: FC = observer((props) => { <> {issue && !is_archived && ( toggleArchiveIssueModal(false)} + isOpen={isArchiveIssueModalOpen === issueId} + handleClose={() => toggleArchiveIssueModal(null)} data={issue} onSubmit={async () => { if (issueOperations.archive) await issueOperations.archive(workspaceSlug, projectId, issueId); diff --git a/web/components/issues/sub-issues/issue-list-item.tsx b/web/components/issues/sub-issues/issue-list-item.tsx index a2eaad398fc..7d974cf8a2a 100644 --- a/web/components/issues/sub-issues/issue-list-item.tsx +++ b/web/components/issues/sub-issues/issue-list-item.tsx @@ -42,7 +42,7 @@ export const IssueListItem: React.FC = observer((props) => { } = props; const { - peekIssue, + getIsIssuePeeked, setPeekIssue, issue: { getIssueById }, subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers }, @@ -65,7 +65,7 @@ export const IssueListItem: React.FC = observer((props) => { issue && issue.project_id && issue.id && - peekIssue?.issueId !== issue.id && + !getIsIssuePeeked(issue.id) && setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); if (!issue) return <>; diff --git a/web/components/issues/sub-issues/root.tsx b/web/components/issues/sub-issues/root.tsx index 25cbc2b1769..deff31f1e07 100644 --- a/web/components/issues/sub-issues/root.tsx +++ b/web/components/issues/sub-issues/root.tsx @@ -381,7 +381,7 @@ export const SubIssuesRoot: FC = observer((props) => { onClick={() => { setTrackElement("Issue detail nested sub-issue"); handleIssueCrudState("existing", parentIssueId, null); - toggleSubIssuesModal(true); + toggleSubIssuesModal(issue.id); }} >
@@ -439,7 +439,7 @@ export const SubIssuesRoot: FC = observer((props) => { onClick={() => { setTrackElement("Issue detail nested sub-issue"); handleIssueCrudState("existing", parentIssueId, null); - toggleSubIssuesModal(true); + toggleSubIssuesModal(issue.id); }} >
@@ -476,7 +476,7 @@ export const SubIssuesRoot: FC = observer((props) => { isOpen={issueCrudState?.existing?.toggle} handleClose={() => { handleIssueCrudState("existing", null, null); - toggleSubIssuesModal(false); + toggleSubIssuesModal(null); }} searchParams={{ sub_issue: true, issue_id: issueCrudState?.existing?.parentIssueId }} handleOnSubmit={(_issue) => diff --git a/web/components/issues/title-input.tsx b/web/components/issues/title-input.tsx index 11a82e623b7..db9a4199a58 100644 --- a/web/components/issues/title-input.tsx +++ b/web/components/issues/title-input.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react"; // components import { TextArea } from "@plane/ui"; // types +import { cn } from "@/helpers/common.helper"; import useDebounce from "@/hooks/use-debounce"; import { TIssueOperations } from "./issue-detail"; // hooks @@ -16,12 +17,26 @@ export type IssueTitleInputProps = { issueOperations: TIssueOperations; projectId: string; issueId: string; + className?: string; + containerClassName?: string; }; export const IssueTitleInput: FC = observer((props) => { - const { disabled, value, workspaceSlug, isSubmitting, setIsSubmitting, issueId, issueOperations, projectId } = props; + const { + disabled, + value, + workspaceSlug, + isSubmitting, + setIsSubmitting, + issueId, + issueOperations, + projectId, + className, + containerClassName, + } = props; // states const [title, setTitle] = useState(""); + const [isLengthVisible, setIsLengthVisible] = useState(false); // hooks const debouncedValue = useDebounce(title, 1500); @@ -76,19 +91,32 @@ export const IssueTitleInput: FC = observer((props) => { if (disabled) return
{title}
; return ( -
+