diff --git a/.github/workflows/build-branch-ee.yml b/.github/workflows/build-branch-ee.yml new file mode 100644 index 0000000000..c6098ef633 --- /dev/null +++ b/.github/workflows/build-branch-ee.yml @@ -0,0 +1,220 @@ +name: Branch Build Enterprise + +on: + workflow_dispatch: + inputs: + branch_name: + description: "Branch Name" + required: true + default: "develop" + push: + branches: + - master + - preview + - develop + release: + types: [released, prereleased] + +env: + TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }} + +jobs: + branch_build_setup: + name: Build-Push Web/Space/API/Proxy Docker Image + runs-on: ubuntu-20.04 + steps: + - name: Check out the repo + uses: actions/checkout@v3.3.0 + outputs: + gh_branch_name: ${{ env.TARGET_BRANCH }} + + branch_build_push_frontend: + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + env: + FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-ee:${{ needs.branch_build_setup.outputs.gh_branch_name }} + steps: + - name: Set Frontend Docker Tag + run: | + if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-ee:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-ee:${{ github.event.release.tag_name }} + elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-ee:stable + else + TAG=${{ env.FRONTEND_TAG }} + fi + echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV + - name: Docker Setup QEMU + uses: docker/setup-qemu-action@v3.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.0.0 + with: + platforms: linux/amd64,linux/arm64 + buildkitd-flags: "--allow-insecure-entitlement security.insecure" + + - name: Login to Docker Hub + uses: docker/login-action@v3.0.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Check out the repo + uses: actions/checkout@v4.1.1 + + - name: Build and Push Frontend to Docker Container Registry + uses: docker/build-push-action@v5.1.0 + with: + context: . + file: ./web/Dockerfile.web + platforms: linux/amd64 + tags: ${{ env.FRONTEND_TAG }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + + branch_build_push_space: + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + env: + SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space-ee:${{ needs.branch_build_setup.outputs.gh_branch_name }} + steps: + - name: Set Space Docker Tag + run: | + if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space-ee:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space-ee:${{ github.event.release.tag_name }} + elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space-ee:stable + else + TAG=${{ env.SPACE_TAG }} + fi + echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV + + - name: Docker Setup QEMU + uses: docker/setup-qemu-action@v3.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.0.0 + with: + platforms: linux/amd64,linux/arm64 + buildkitd-flags: "--allow-insecure-entitlement security.insecure" + + - name: Login to Docker Hub + uses: docker/login-action@v3.0.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Check out the repo + uses: actions/checkout@v4.1.1 + + - name: Build and Push Space to Docker Hub + uses: docker/build-push-action@v5.1.0 + with: + context: . + file: ./space/Dockerfile.space + platforms: linux/amd64 + tags: ${{ env.SPACE_TAG }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + + branch_build_push_backend: + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + env: + BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-ee:${{ needs.branch_build_setup.outputs.gh_branch_name }} + steps: + - name: Set Backend Docker Tag + run: | + if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-ee:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-ee:${{ github.event.release.tag_name }} + elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-ee:stable + else + TAG=${{ env.BACKEND_TAG }} + fi + echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV + + - name: Docker Setup QEMU + uses: docker/setup-qemu-action@v3.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.0.0 + with: + platforms: linux/amd64,linux/arm64 + buildkitd-flags: "--allow-insecure-entitlement security.insecure" + + - name: Login to Docker Hub + uses: docker/login-action@v3.0.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Check out the repo + uses: actions/checkout@v4.1.1 + + - name: Build and Push Backend to Docker Hub + uses: docker/build-push-action@v5.1.0 + with: + context: ./apiserver + file: ./apiserver/Dockerfile.api + platforms: linux/amd64 + push: true + tags: ${{ env.BACKEND_TAG }} + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + + branch_build_push_proxy: + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + env: + PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-ee:${{ needs.branch_build_setup.outputs.gh_branch_name }} + steps: + - name: Set Proxy Docker Tag + run: | + if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-ee:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-ee:${{ github.event.release.tag_name }} + elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-ee:stable + else + TAG=${{ env.PROXY_TAG }} + fi + echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV + + - name: Docker Setup QEMU + uses: docker/setup-qemu-action@v3.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.0.0 + with: + platforms: linux/amd64,linux/arm64 + buildkitd-flags: "--allow-insecure-entitlement security.insecure" + + - name: Login to Docker Hub + uses: docker/login-action@v3.0.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Check out the repo + uses: actions/checkout@v4.1.1 + + - name: Build and Push Plane-Proxy to Docker Hub + uses: docker/build-push-action@v5.1.0 + with: + context: ./nginx + file: ./nginx/Dockerfile + platforms: linux/amd64 + tags: ${{ env.PROXY_TAG }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/apiserver/bin/takeoff.cloud b/apiserver/bin/takeoff.cloud new file mode 100644 index 0000000000..18feb40875 --- /dev/null +++ b/apiserver/bin/takeoff.cloud @@ -0,0 +1,28 @@ +#!/bin/bash +set -e +python manage.py wait_for_db +python manage.py migrate + +# Create the default bucket +#!/bin/bash + +# Collect system information +HOSTNAME=$(hostname) +MAC_ADDRESS=$(ip link show | awk '/ether/ {print $2}' | head -n 1) +CPU_INFO=$(cat /proc/cpuinfo) +MEMORY_INFO=$(free -h) +DISK_INFO=$(df -h) + +# Concatenate information and compute SHA-256 hash +SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256sum | awk '{print $1}') + +# Export the variables +export MACHINE_SIGNATURE=$SIGNATURE + +# Register instance +python manage.py setup_instance $INSTANCE_ADMIN_EMAIL + +# Create the default bucket +python manage.py create_bucket + +exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile - diff --git a/apiserver/plane/app/urls/cycle.py b/apiserver/plane/app/urls/cycle.py index 740b0ab438..60f99a4c3a 100644 --- a/apiserver/plane/app/urls/cycle.py +++ b/apiserver/plane/app/urls/cycle.py @@ -8,10 +8,16 @@ from plane.app.views import ( CycleFavoriteViewSet, TransferCycleIssueEndpoint, CycleUserPropertiesEndpoint, + ActiveCycleEndpoint, ) urlpatterns = [ + path( + "workspaces//active-cycles/", + ActiveCycleEndpoint.as_view(), + name="workspace-active-cycle", + ), path( "workspaces//projects//cycles/", CycleViewSet.as_view( diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 0a959a667b..3707d09fb8 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -64,6 +64,7 @@ from .cycle import ( CycleFavoriteViewSet, TransferCycleIssueEndpoint, CycleUserPropertiesEndpoint, + ActiveCycleEndpoint, ) from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet from .issue import ( diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 3c54f7f95a..d884e17047 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -37,6 +37,7 @@ from plane.app.serializers import ( CycleUserPropertiesSerializer, ) from plane.app.permissions import ( + WorkspaceUserPermission, ProjectEntityPermission, ProjectLitePermission, ) @@ -915,3 +916,255 @@ class CycleUserPropertiesEndpoint(BaseAPIView): ) serializer = CycleUserPropertiesSerializer(cycle_properties) return Response(serializer.data, status=status.HTTP_200_OK) + + +class ActiveCycleEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceUserPermission, + ] + + def get_results_controller(self, results, active_cycles=None): + for cycle in results: + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle["id"], + project_id=cycle["project"], + workspace__slug=self.kwargs.get("slug"), + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_issues=Count( + "assignee_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "assignee_id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "assignee_id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle["id"], + project_id=cycle["project"], + workspace__slug=self.kwargs.get("slug"), + ) + .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( + "label_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ) + ) + .annotate( + completed_issues=Count( + "label_id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "label_id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + cycle["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + if cycle["start_date"] and cycle["end_date"]: + cycle["distribution"][ + "completion_chart" + ] = burndown_plot( + queryset=active_cycles.get(pk=cycle["id"]), + slug=self.kwargs.get("slug"), + project_id=cycle["project"], + cycle_id=cycle["id"], + ) + + priority_issues = Issue.objects.filter(issue_cycle__cycle_id=cycle["id"], priority__in=["urgent", "high"]) + # Priority Ordering + priority_order = ["urgent", "high"] + priority_issues = priority_issues.annotate( + priority_order=Case( + *[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)], + output_field=CharField(), + ) + ).order_by("priority_order")[:5] + + cycle["issues"] = IssueSerializer(priority_issues, many=True).data + + return results + + def get(self, request, slug): + 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"), + ) + active_cycles = ( + Cycle.objects.filter( + workspace__slug=slug, + project__project_projectmember__member=self.request.user, + start_date__lte=timezone.now(), + end_date__gte=timezone.now(), + ) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate(is_favorite=Exists(subquery)) + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + 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__state__group", + 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__state__group", + 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__state__group", + 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__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point")) + .annotate( + completed_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="started", + 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(), + ) + ) + .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(), + ) + ) + .order_by("-created_at") + ) + + return self.paginate( + request=request, + queryset=active_cycles, + on_results=lambda active_cycles: CycleSerializer(active_cycles, many=True).data, + controller=lambda results: self.get_results_controller(results, active_cycles), + default_per_page=int(request.GET.get("per_page", 3)) + ) diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index 4215213635..49d58cd032 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -26,6 +26,7 @@ from plane.db.models import ( IssueProperty, UserNotificationPreference, ) +from plane.bgtasks.user_welcome_task import send_welcome_slack @shared_task diff --git a/apiserver/plane/bgtasks/user_welcome_task.py b/apiserver/plane/bgtasks/user_welcome_task.py new file mode 100644 index 0000000000..33f4b56863 --- /dev/null +++ b/apiserver/plane/bgtasks/user_welcome_task.py @@ -0,0 +1,36 @@ +# Django imports +from django.conf import settings + +# Third party imports +from celery import shared_task +from sentry_sdk import capture_exception +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +# Module imports +from plane.db.models import User + + +@shared_task +def send_welcome_slack(user_id, created, message): + try: + instance = User.objects.get(pk=user_id) + + if created and not instance.is_bot: + # Send message on slack as well + if settings.SLACK_BOT_TOKEN: + client = WebClient(token=settings.SLACK_BOT_TOKEN) + try: + _ = client.chat_postMessage( + channel="#trackers", + text=message, + ) + except SlackApiError as e: + print(f"Got an error: {e.response['error']}") + return + except Exception as e: + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) + capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index 06dd6e8cd5..ded2404dc1 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -20,6 +20,18 @@ from plane.db.models import Workspace, WorkspaceMemberInvite, User from plane.license.utils.instance_value import get_email_configuration +def push_updated_to_slack(workspace, workspace_member_invite): + # Send message on slack as well + client = WebClient(token=settings.SLACK_BOT_TOKEN) + try: + _ = client.chat_postMessage( + channel="#trackers", + text=f"{workspace_member_invite.email} has been invited to {workspace.name} as a {workspace_member_invite.role}", + ) + except SlackApiError as e: + print(f"Got an error: {e.response['error']}") + + @shared_task def workspace_invitation(email, workspace_id, token, current_site, invitor): try: @@ -82,6 +94,10 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): msg.attach_alternative(html_content, "text/html") msg.send() + # Send message on slack as well + if settings.SLACK_BOT_TOKEN: + push_updated_to_slack(workspace, workspace_member_invite) + return except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e: print("Workspace or WorkspaceMember Invite Does not exists") diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 6f8a82e567..a9f8856404 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -15,6 +15,9 @@ from django.db.models.signals import post_save from django.conf import settings from django.dispatch import receiver from django.utils import timezone +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.conf import settings # Third party imports from sentry_sdk import capture_exception @@ -170,6 +173,7 @@ def create_user_notification(sender, instance, created, **kwargs): if created and not instance.is_bot: # Module imports from plane.db.models import UserNotificationPreference + UserNotificationPreference.objects.create( user=instance, ) diff --git a/apiserver/plane/license/management/commands/setup_instance.py b/apiserver/plane/license/management/commands/setup_instance.py new file mode 100644 index 0000000000..36d06c5b14 --- /dev/null +++ b/apiserver/plane/license/management/commands/setup_instance.py @@ -0,0 +1,74 @@ +# Python imports +import json +import secrets +import uuid + +# Django imports +from django.contrib.auth.hashers import make_password +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +# Module imports +from plane.license.models import Instance, InstanceAdmin +from plane.db.models import User + + +class Command(BaseCommand): + help = "Check if instance in registered else register" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument("admin_email", type=str, help="Admin Email") + + def handle(self, *args, **options): + with open("package.json", "r") as file: + # Load JSON content from the file + data = json.load(file) + + admin_email = options.get("admin_email", False) + + if not admin_email: + raise CommandError("admin email is required") + + user_count = User.objects.filter(is_bot=False).count() + + user = User.objects.filter(email=admin_email).first() + if user is None: + user = User.objects.create( + email=admin_email, + username=uuid.uuid4().hex, + password=make_password(uuid.uuid4().hex), + ) + + try: + # Check if the instance is registered + instance = Instance.objects.first() + + if instance is None: + instance = Instance.objects.create( + instance_name="Plane Enterprise", + instance_id=secrets.token_hex(12), + license_key=None, + api_key=secrets.token_hex(8), + version=data.get("version"), + last_checked_at=timezone.now(), + user_count=user_count, + is_verified=True, + is_setup_done=True, + is_signup_screen_visited=True, + ) + + # Get or create an instance admin + _, created = InstanceAdmin.objects.get_or_create( + user=user, instance=instance, role=20, is_verified=True + ) + + if not created: + self.stdout.write( + self.style.WARNING("given email is already an instance admin") + ) + + self.stdout.write(self.style.SUCCESS(f"Successful")) + except Exception as e: + print(e) + raise CommandError("Failure") diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 444248382f..b233b19ca4 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -315,7 +315,7 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get( # Application Envs PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) # For External - +SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False) FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) # Unsplash Access key diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index 948eb1b916..0f4480481c 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -13,6 +13,7 @@ from django.db.models.functions import ( ExtractYear, Concat, ) +from django.utils import timezone # Module imports from plane.db.models import Issue diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx index 2576d0d74e..c2a63aa093 100644 --- a/packages/editor/document-editor/src/ui/extensions/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/index.tsx @@ -1,29 +1,57 @@ import Placeholder from "@tiptap/extension-placeholder"; -import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-widget"; - +// plane imports import { SlashCommand, DragAndDrop } from "@plane/editor-extensions"; -import { UploadImage } from "@plane/editor-core"; +import { UploadImage, ISlashCommandItem } from "@plane/editor-core"; +// local +import { IssueWidgetExtension } from "src/ui/extensions/widgets/issue-embed-widget"; +import { IssueSuggestions } from "src/ui/extensions/widgets/issue-embed-suggestion-list"; +import { IIssueEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types"; +// ui +import { LayersIcon } from "@plane/ui"; export const DocumentEditorExtensions = ( uploadFile: UploadImage, - setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void -) => [ - SlashCommand(uploadFile, setIsSubmitting), - DragAndDrop(setHideDragHandle), - Placeholder.configure({ - placeholder: ({ node }) => { - if (node.type.name === "heading") { - return `Heading ${node.attrs.level}`; - } - if (node.type.name === "image" || node.type.name === "table") { - return ""; - } - - return "Press '/' for commands..."; + issueEmbedConfig?: IIssueEmbedConfig, + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, + setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void +) => { + const additionalOptions: ISlashCommandItem[] = [ + { + key: "issue_embed", + title: "Issue embed", + description: "Embed an issue from the project.", + searchTerms: ["issue", "link", "embed"], + icon: , + command: ({ editor, range }) => { + editor + .chain() + .focus() + .insertContentAt( + range, + "

#issue_

" + ) + .run(); + }, }, - includeChildren: true, - }), - IssueWidgetPlaceholder(), -]; + ]; + return [ + SlashCommand(uploadFile, setIsSubmitting, additionalOptions), + DragAndDrop(setHideDragHandle), + Placeholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === "heading") { + return `Heading ${node.attrs.level}`; + } + if (node.type.name === "image" || node.type.name === "table") { + return ""; + } + + return "Press '/' for commands..."; + }, + includeChildren: true, + }), + IssueWidgetExtension({ issueEmbedConfig }), + IssueSuggestions(issueEmbedConfig ? issueEmbedConfig.issues : []), + ]; +}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx index 869c7a8c6f..c51233017a 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx @@ -55,11 +55,17 @@ const IssueSuggestionList = ({ useEffect(() => { const newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {}; let totalLength = 0; + let selectedSection = "Backlog"; + let selectedCurrentSection = false; sections.forEach((section) => { newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5); - + if (newDisplayedItems[section].length > 0 && selectedCurrentSection === false) { + selectedSection = section; + selectedCurrentSection = true; + } totalLength += newDisplayedItems[section].length; }); + setCurrentSection(selectedSection); setDisplayedTotalLength(totalLength); setDisplayedItems(newDisplayedItems); }, [items]); @@ -240,7 +246,7 @@ export const IssueListRenderer = () => { } return false; }, - onExit: (e) => { + onExit: () => { const container = document.querySelector(".frame-renderer") as HTMLElement; if (container) { container.removeEventListener("scroll", () => {}); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/types.ts b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/types.ts new file mode 100644 index 0000000000..82aa689546 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/types.ts @@ -0,0 +1,9 @@ +export interface IEmbedConfig { + issueEmbedConfig: IIssueEmbedConfig; +} + +export interface IIssueEmbedConfig { + fetchIssue: (issueId: string) => any; + clickAction: (issueId: string, issueTitle: string) => void; + issues: Array; +} diff --git a/packages/types/src/cycles.d.ts b/packages/types/src/cycles.d.ts index 91c6ef1d5f..b1efb48c5b 100644 --- a/packages/types/src/cycles.d.ts +++ b/packages/types/src/cycles.d.ts @@ -47,6 +47,7 @@ export interface ICycle { }; workspace: string; workspace_detail: IWorkspaceLite; + issues?: TIssue[]; } export type TAssigneesDistribution = { diff --git a/packages/types/src/projects.d.ts b/packages/types/src/projects.d.ts index a412180b80..9c963258b1 100644 --- a/packages/types/src/projects.d.ts +++ b/packages/types/src/projects.d.ts @@ -1,5 +1,11 @@ import { EUserProjectRoles } from "constants/project"; -import type { IUser, IUserLite, IWorkspace, IWorkspaceLite, TStateGroups } from "."; +import type { + IUser, + IUserLite, + IWorkspace, + IWorkspaceLite, + TStateGroups, +} from "."; export interface IProject { archive_in: number; @@ -52,6 +58,11 @@ export interface IProjectLite { id: string; name: string; identifier: string; + emoji: string | null; + icon_prop: { + name: string; + color: string; + } | null; } type ProjectPreferences = { diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index 2d7e94d959..4261838603 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -1,5 +1,6 @@ import { EUserWorkspaceRoles } from "constants/workspace"; import type { + ICycle, IProjectMember, IUser, IUserLite, @@ -182,3 +183,14 @@ export interface IProductUpdateResponse { eyes: number; }; } + +export interface IWorkspaceActiveCyclesResponse { + count: number; + extra_stats: null; + next_cursor: string; + next_page_results: boolean; + prev_cursor: string; + prev_page_results: boolean; + results: ICycle[]; + total_pages: number; +} diff --git a/packages/ui/src/progress/linear-progress-indicator.tsx b/packages/ui/src/progress/linear-progress-indicator.tsx index 467285024b..b9035b86f1 100644 --- a/packages/ui/src/progress/linear-progress-indicator.tsx +++ b/packages/ui/src/progress/linear-progress-indicator.tsx @@ -12,25 +12,30 @@ export const LinearProgressIndicator: React.FC = ({ data, noTooltip = fal // eslint-disable-next-line @typescript-eslint/no-unused-vars let progress = 0; - const bars = data.map((item: any) => { + const bars = data.map((item: any, index: Number) => { const width = `${(item.value / total) * 100}%`; if (width === "0%") return <>; const style = { width, backgroundColor: item.color, + borderTopLeftRadius: index === 0 ? "99px" : 0, + borderBottomLeftRadius: index === 0 ? "99px" : 0, + borderTopRightRadius: index === data.length - 1 ? "99px" : 0, + borderBottomRightRadius: index === data.length - 1 ? "99px" : 0, }; progress += item.value; if (noTooltip) return
; + if (width === "0%") return <>; else return ( - -
+ +
); }); return ( -
+
{total === 0 ? (
{bars}
) : ( diff --git a/turbo.json b/turbo.json index bd5ee34b59..9dfeb88621 100644 --- a/turbo.json +++ b/turbo.json @@ -17,17 +17,14 @@ "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_POSTHOG_HOST", "JITSU_TRACKER_ACCESS_KEY", - "JITSU_TRACKER_HOST" + "JITSU_TRACKER_HOST", + "NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL", + "NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL" ], "pipeline": { "build": { - "dependsOn": [ - "^build" - ], - "outputs": [ - ".next/**", - "dist/**" - ] + "dependsOn": ["^build"], + "outputs": [".next/**", "dist/**"] }, "develop": { "cache": false, @@ -44,9 +41,7 @@ ] }, "test": { - "dependsOn": [ - "^build" - ], + "dependsOn": ["^build"], "outputs": [] }, "lint": { diff --git a/web/components/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx index d42f9f5432..418dc26da9 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle-details.tsx @@ -99,11 +99,10 @@ export const ActiveCycleDetails: React.FC = observer((props const startDate = new Date(activeCycle.start_date ?? ""); const groupedIssues: any = { - backlog: activeCycle.backlog_issues, - unstarted: activeCycle.unstarted_issues, - started: activeCycle.started_issues, completed: activeCycle.completed_issues, - cancelled: activeCycle.cancelled_issues, + started: activeCycle.started_issues, + unstarted: activeCycle.unstarted_issues, + backlog: activeCycle.backlog_issues, }; const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups; diff --git a/web/components/cycles/active-cycle-info.tsx b/web/components/cycles/active-cycle-info.tsx new file mode 100644 index 0000000000..5b58bd8c1f --- /dev/null +++ b/web/components/cycles/active-cycle-info.tsx @@ -0,0 +1,260 @@ +import { FC, useCallback } from "react"; +import Link from "next/link"; +// hooks +import useLocalStorage from "hooks/use-local-storage"; +// ui +import { Tooltip, LinearProgressIndicator, Loader, PriorityIcon, Button, CycleGroupIcon } from "@plane/ui"; +import { CalendarCheck } from "lucide-react"; +// components +import ProgressChart from "components/core/sidebar/progress-chart"; +import { StateDropdown } from "components/dropdowns"; +// types +import { ICycle, TCycleGroups, TCycleLayout, TCycleView } from "@plane/types"; +// helpers +import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper"; +import { truncateText } from "helpers/string.helper"; +import { renderEmoji } from "helpers/emoji.helper"; +// constants +import { CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle"; + +export type ActiveCycleInfoProps = { + cycle: ICycle; + workspaceSlug: string; + projectId: string; +}; + +export const ActiveCycleInfo: FC = (props) => { + const { cycle, workspaceSlug, projectId } = props; + // local storage + const { setValue: setCycleTab } = useLocalStorage("cycle_tab", "active"); + const { setValue: setCycleLayout } = useLocalStorage("cycle_layout", "list"); + + const cycleIssues = cycle.issues ?? []; + + const handleCurrentLayout = useCallback( + (_layout: TCycleLayout) => { + setCycleLayout(_layout); + }, + [setCycleLayout] + ); + + const handleCurrentView = useCallback( + (_view: TCycleView) => { + setCycleTab(_view); + if (_view === "draft") handleCurrentLayout("list"); + }, + [handleCurrentLayout, setCycleTab] + ); + + const groupedIssues: any = { + completed: cycle.completed_issues, + started: cycle.started_issues, + unstarted: cycle.unstarted_issues, + backlog: cycle.backlog_issues, + }; + + const progressIndicatorData = CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({ + id: index, + name: group.title, + value: cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0, + color: group.color, + })); + + const cuurentCycle = cycle.status.toLowerCase() as TCycleGroups; + + const daysLeft = findHowManyDaysLeft(cycle.end_date ?? new Date()); + + return ( + <> +
+ {cycle.project_detail.emoji ? ( + + {renderEmoji(cycle.project_detail.emoji)} + + ) : cycle.project_detail.icon_prop ? ( +
+ {renderEmoji(cycle.project_detail.icon_prop)} +
+ ) : ( + + {cycle.project_detail?.name.charAt(0)} + + )} +

{cycle.project_detail.name}

+
+
+
+
+ + +

{truncateText(cycle.name, 70)}

+
+ + + {`${daysLeft} ${daysLeft > 1 ? "Days" : "Day"} Left`} + + +
+
+ + + Lead: +
+ {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( + {cycle.owned_by.display_name} + ) : ( + + {cycle.owned_by.display_name.charAt(0)} + + )} + {cycle.owned_by.display_name} +
+
+
+ + + +
+
+
+
+
+

Progress

+ + {`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${ + cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue" + } closed`} + +
+ +
+
+ {Object.keys(groupedIssues).map((group, index) => ( + <> + {groupedIssues[group] > 0 && ( +
+
+ + {group} +
+ {`: ${groupedIssues[group]} ${groupedIssues[group] > 1 ? "Issues" : "Issue"}`} +
+ )} + + ))} + {cycle.cancelled_issues > 0 && ( + + + {`${cycle.cancelled_issues} cancelled ${ + cycle.cancelled_issues > 1 ? "issues are" : "issue is" + } excluded from this report.`}{" "} + + + )} +
+
+
+ +
+
+

Issue Burndown

+
+ +
+ +
+
+
+
+

Priority

+
+
+ {cycleIssues ? ( + cycleIssues.length > 0 ? ( + cycleIssues.map((issue: any) => ( + +
+ + + + {cycle.project_detail?.identifier}-{issue.sequence_id} + + + + {issue.name} + +
+
+ {}} + projectId={projectId?.toString() ?? ""} + disabled={true} + buttonVariant="background-with-text" + /> + {issue.target_date && ( + +
+ + {renderFormattedDateWithoutYear(issue.target_date)} +
+
+ )} +
+ + )) + ) : ( +
+ There are no high priority issues present in this cycle. +
+ ) + ) : ( + + + + + + )} +
+
+
+
+ + ); +}; diff --git a/web/components/cycles/index.ts b/web/components/cycles/index.ts index db5e9de9ee..e95b329720 100644 --- a/web/components/cycles/index.ts +++ b/web/components/cycles/index.ts @@ -1,5 +1,6 @@ export * from "./cycles-view"; export * from "./active-cycle-details"; +export * from "./active-cycle-info"; export * from "./active-cycle-stats"; export * from "./gantt-chart"; export * from "./cycles-view"; diff --git a/web/components/headers/workspace-active-cycle.tsx b/web/components/headers/workspace-active-cycle.tsx new file mode 100644 index 0000000000..79e6bcaf3c --- /dev/null +++ b/web/components/headers/workspace-active-cycle.tsx @@ -0,0 +1,23 @@ +import { observer } from "mobx-react-lite"; +import { SendToBack } from "lucide-react"; +// ui +import { Breadcrumbs } from "@plane/ui"; + +export const WorkspaceActiveCycleHeader = observer(() => ( +
+
+
+ + } + label="Active Cycles" + /> + + + Beta + +
+
+
+)); diff --git a/web/components/license/index.ts b/web/components/license/index.ts new file mode 100644 index 0000000000..ddc06de25f --- /dev/null +++ b/web/components/license/index.ts @@ -0,0 +1 @@ +export * from "./pro-plan-modal"; diff --git a/web/components/license/pro-plan-modal.tsx b/web/components/license/pro-plan-modal.tsx new file mode 100644 index 0000000000..f67fc825ea --- /dev/null +++ b/web/components/license/pro-plan-modal.tsx @@ -0,0 +1,191 @@ +import { FC, Fragment, useState } from "react"; +import { Dialog, Transition, Tab } from "@headlessui/react"; +import { CheckCircle } from "lucide-react"; +import { useApplication } from "hooks/store"; + +function classNames(...classes: any[]) { + return classes.filter(Boolean).join(" "); +} + +const PRICING_CATEGORIES = ["Monthly", "Yearly"]; + +const MONTHLY_PLAN_ITEMS = [ + "White-glove onboarding for your use-cases", + "Bespoke implementation", + "Priority integrations", + "Priority Support and SLAs", + "Early access to all paid features", + "Locked-in discount for a whole year", +]; + +const YEARLY_PLAN_ITEMS = [ + "White-glove onboarding for your use-cases", + "Bespoke implementation", + "Priority integrations", + "Priority Support and SLAs", + "Early access to all paid features", + "Tiered discounts for the second and third years", +]; + +export type ProPlanModalProps = { + isOpen: boolean; + handleClose: () => void; +}; + +export const ProPlanModal: FC = (props) => { + const { isOpen, handleClose } = props; + // store + const { + eventTracker: { captureEvent }, + } = useApplication(); + // states + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [tabIndex, setTabIndex] = useState(0); + + const handleProPlaneMonthRedirection = () => { + if (process.env.NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL) { + window.open(process.env.NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL, "_blank"); + captureEvent("pro_plan_modal_month_redirection"); + } + }; + + const handleProPlanYearlyRedirection = () => { + if (process.env.NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL) { + window.open(process.env.NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL, "_blank"); + captureEvent("pro_plan_modal_yearly_redirection"); + } + }; + + return ( + + + +
+ + +
+
+ + + + Early-adopter pricing for believers + +
+

+ Build Plane to your specs. You decide what we prioritize and build for everyone. Also get tailored + onboarding + implementation and priority support. +

+ +
+ + {PRICING_CATEGORIES.map((category, index) => ( + + classNames( + "w-full rounded-lg py-2 text-sm font-medium leading-5", + "ring-white/60 ring-offset-2 ring-offset-custom-primary-90 focus:outline-none", + selected + ? "bg-custom-background-100 text-custom-primary-100 shadow" + : "hover:bg-custom-background-80 text-custom-text-300 hover:text-custom-text-100" + ) + } + onClick={() => setTabIndex(index)} + > + <> + {category} + {category === "Yearly" && ( + + -28% + + )} + + + ))} + +
+ + + +

+ $7 + /user/month +

+
    + {MONTHLY_PLAN_ITEMS.map((item) => ( +
  • +

    + + {item} +

    +
  • + ))} +
+
+
+
+ +
+
+ + +

+ $5 + /user/month +

+
    + {YEARLY_PLAN_ITEMS.map((item) => ( +
  • +

    + + {item} +

    +
  • + ))} +
+
+
+
+ +
+
+ + + +
+ + +
+
+
+
+ ); +}; diff --git a/web/components/workspace/help-section.tsx b/web/components/workspace/help-section.tsx index aa58fcd65f..353775e820 100644 --- a/web/components/workspace/help-section.tsx +++ b/web/components/workspace/help-section.tsx @@ -7,9 +7,11 @@ import { useApplication } from "hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // icons import { FileText, HelpCircle, MessagesSquare, MoveLeft, Zap } from "lucide-react"; -import { DiscordIcon, GithubIcon } from "@plane/ui"; +import { DiscordIcon, GithubIcon, Button } from "@plane/ui"; // assets import packageJson from "package.json"; +// components +import { ProPlanModal } from "components/license"; const helpOptions = [ { @@ -40,10 +42,13 @@ export interface WorkspaceHelpSectionProps { } export const WorkspaceHelpSection: React.FC = observer(() => { + // states + const [isProPlanModalOpen, setIsProPlanModalOpen] = useState(false); // store hooks const { theme: { sidebarCollapsed, toggleSidebar }, commandPalette: { toggleShortcutModal }, + eventTracker: { captureEvent }, } = useApplication(); // states const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); @@ -54,17 +59,27 @@ export const WorkspaceHelpSection: React.FC = observe const isCollapsed = sidebarCollapsed || false; + const handleProPlanModalOpen = () => { + setIsProPlanModalOpen(true); + captureEvent("pro_plan_modal_opened"); + }; + return ( <> + setIsProPlanModalOpen(false)} />
{!isCollapsed && ( -
- Free Plan -
+ )}
diff --git a/web/components/workspace/workspace-active-cycles-list.tsx b/web/components/workspace/workspace-active-cycles-list.tsx new file mode 100644 index 0000000000..0dc28065e5 --- /dev/null +++ b/web/components/workspace/workspace-active-cycles-list.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; +import useSWR from "swr"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; +import isEqual from "lodash/isEqual"; +// components +import { ActiveCycleInfo } from "components/cycles"; +import { Button, ContrastIcon, Spinner } from "@plane/ui"; +// services +import { CycleService } from "services/cycle.service"; +const cycleService = new CycleService(); +// constants +import { WORKSPACE_ACTIVE_CYCLES_LIST } from "constants/fetch-keys"; +// types +import { ICycle } from "@plane/types"; + +const per_page = 3; + +export const WorkspaceActiveCyclesList = observer(() => { + // state + const [cursor, setCursor] = useState(`3:0:0`); + const [allCyclesData, setAllCyclesData] = useState([]); + const [hasMoreResults, setHasMoreResults] = useState(true); + // router + const router = useRouter(); + const { workspaceSlug } = router.query; + + // fetching active cycles in workspace + const { data: workspaceActiveCycles, isLoading } = useSWR( + workspaceSlug && cursor ? WORKSPACE_ACTIVE_CYCLES_LIST(workspaceSlug as string, cursor, `${per_page}`) : null, + workspaceSlug && cursor + ? () => cycleService.workspaceActiveCycles(workspaceSlug.toString(), cursor, per_page) + : null + ); + + useEffect(() => { + if (workspaceActiveCycles && !isEqual(workspaceActiveCycles.results, allCyclesData)) { + setAllCyclesData((prevData) => [...prevData, ...workspaceActiveCycles.results]); + setHasMoreResults(workspaceActiveCycles.next_page_results); + } + }, [workspaceActiveCycles]); + + const handleLoadMore = () => { + if (hasMoreResults) { + setCursor(workspaceActiveCycles?.next_cursor); + } + }; + + if (allCyclesData.length === 0 && !workspaceActiveCycles) { + return ( +
+ +
+ ); + } + + return ( +
+ {allCyclesData.length > 0 ? ( + <> + {workspaceSlug && + allCyclesData.map((cycle) => ( +
+ +
+ ))} + + {hasMoreResults && ( +
+ +
+ )} + + ) : ( +
+
+
+ +
+

+ Cycles running across all your projects can be seen here. Use this to track how the org is delivering + value across teams +

+
+
+ )} +
+ ); +}); diff --git a/web/constants/fetch-keys.ts b/web/constants/fetch-keys.ts index ec88c8c877..719d889f41 100644 --- a/web/constants/fetch-keys.ts +++ b/web/constants/fetch-keys.ts @@ -142,6 +142,8 @@ export const WORKSPACE_LABELS = (workspaceSlug: string) => `WORKSPACE_LABELS_${w export const PROJECT_GITHUB_REPOSITORY = (projectId: string) => `PROJECT_GITHUB_REPOSITORY_${projectId.toUpperCase()}`; // cycles +export const WORKSPACE_ACTIVE_CYCLES_LIST = (workspaceSlug: string, cursor: string, per_page: string) => + `WORKSPACE_ACTIVE_CYCLES_LIST_${workspaceSlug.toUpperCase()}_${cursor.toUpperCase()}_${per_page.toUpperCase()}`; export const CYCLES_LIST = (projectId: string) => `CYCLE_LIST_${projectId.toUpperCase()}`; export const INCOMPLETE_CYCLES_LIST = (projectId: string) => `INCOMPLETE_CYCLES_LIST_${projectId.toUpperCase()}`; export const CURRENT_CYCLE_LIST = (projectId: string) => `CURRENT_CYCLE_LIST_${projectId.toUpperCase()}`; diff --git a/web/pages/[workspaceSlug]/active-cycles.tsx b/web/pages/[workspaceSlug]/active-cycles.tsx index c047898152..61d57e2e69 100644 --- a/web/pages/[workspaceSlug]/active-cycles.tsx +++ b/web/pages/[workspaceSlug]/active-cycles.tsx @@ -1,13 +1,13 @@ import { ReactElement } from "react"; // components +import { WorkspaceActiveCyclesList } from "components/workspace"; import { WorkspaceActiveCycleHeader } from "components/headers"; -import { WorkspaceActiveCyclesUpgrade } from "components/workspace"; // layouts import { AppLayout } from "layouts/app-layout"; // types import { NextPageWithLayout } from "lib/types"; -const WorkspaceActiveCyclesPage: NextPageWithLayout = () => ; +const WorkspaceActiveCyclesPage: NextPageWithLayout = () => ; WorkspaceActiveCyclesPage.getLayout = function getLayout(page: ReactElement) { return }>{page}; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index be512dda03..c0ed6c0b6c 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/router"; import { ReactElement, useEffect, useRef, useState } from "react"; import { Controller, useForm } from "react-hook-form"; // hooks - import { useApplication, usePage, useUser, useWorkspace } from "hooks/store"; import useReloadConfirmations from "hooks/use-reload-confirmation"; import useToast from "hooks/use-toast"; diff --git a/web/services/cycle.service.ts b/web/services/cycle.service.ts index 6b6d17231c..f624aa2710 100644 --- a/web/services/cycle.service.ts +++ b/web/services/cycle.service.ts @@ -1,7 +1,7 @@ // services import { APIService } from "services/api.service"; // types -import type { CycleDateCheckData, ICycle, TIssue, TIssueMap } from "@plane/types"; +import type { CycleDateCheckData, ICycle, IWorkspaceActiveCyclesResponse, TIssue } from "@plane/types"; // helpers import { API_BASE_URL } from "helpers/common.helper"; @@ -10,6 +10,23 @@ export class CycleService extends APIService { super(API_BASE_URL); } + async workspaceActiveCycles( + workspaceSlug: string, + cursor: string, + per_page: number + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/active-cycles/`, { + params: { + per_page, + cursor, + }, + }) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } + async createCycle(workspaceSlug: string, projectId: string, data: any): Promise { return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`, data) .then((response) => response?.data) diff --git a/web/store/application/event-tracker.store.ts b/web/store/application/event-tracker.store.ts index cc0ac22b28..e3641c0d84 100644 --- a/web/store/application/event-tracker.store.ts +++ b/web/store/application/event-tracker.store.ts @@ -11,6 +11,7 @@ export interface IEventTrackerStore { payload: object | [] | null, group?: { isGrouping: boolean | null; groupType: string | null; groupId: string | null } | null ) => void; + captureEvent: (eventName: string, payload?: any) => void; } export class EventTrackerStore implements IEventTrackerStore { @@ -76,4 +77,12 @@ export class EventTrackerStore implements IEventTrackerStore { } this.setTrackElement(""); }; + + captureEvent = (eventName: string, payload?: any) => { + try { + posthog?.capture(eventName, payload); + } catch (error) { + console.log(error); + } + }; }