fix: merge conflicts resolved

This commit is contained in:
sriram veeraghanta
2024-01-24 21:02:37 +05:30
35 changed files with 1374 additions and 55 deletions

220
.github/workflows/build-branch-ee.yml vendored Normal file
View File

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

View File

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

View File

@@ -8,10 +8,16 @@ from plane.app.views import (
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
ActiveCycleEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/active-cycles/",
ActiveCycleEndpoint.as_view(),
name="workspace-active-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/",
CycleViewSet.as_view(

View File

@@ -64,6 +64,7 @@ from .cycle import (
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
ActiveCycleEndpoint,
)
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .issue import (

View File

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

View File

@@ -26,6 +26,7 @@ from plane.db.models import (
IssueProperty,
UserNotificationPreference,
)
from plane.bgtasks.user_welcome_task import send_welcome_slack
@shared_task

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: <LayersIcon className="h-3.5 w-3.5" />,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.insertContentAt(
range,
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>"
)
.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 : []),
];
};

View File

@@ -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", () => {});

View File

@@ -0,0 +1,9 @@
export interface IEmbedConfig {
issueEmbedConfig: IIssueEmbedConfig;
}
export interface IIssueEmbedConfig {
fetchIssue: (issueId: string) => any;
clickAction: (issueId: string, issueTitle: string) => void;
issues: Array<any>;
}

View File

@@ -47,6 +47,7 @@ export interface ICycle {
};
workspace: string;
workspace_detail: IWorkspaceLite;
issues?: TIssue[];
}
export type TAssigneesDistribution = {

View File

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

View File

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

View File

@@ -12,25 +12,30 @@ export const LinearProgressIndicator: React.FC<Props> = ({ 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 <div style={style} />;
if (width === "0%") return <></>;
else
return (
<Tooltip key={item.id} tooltipContent={`${item.name} ${Math.round(item.value)}%`}>
<div style={style} className="first:rounded-l-full last:rounded-r-full" />
<Tooltip key={item.id} tooltipContent={`${item.name} ${Math.round(item.value)}${inPercentage ? "%" : ""}`}>
<div style={style} />
</Tooltip>
);
});
return (
<div className="flex h-1 w-full items-center justify-between gap-1">
<div className="flex h-1.5 w-full items-center justify-between gap-1 rounded-l-full rounded-r-full">
{total === 0 ? (
<div className="flex h-full w-full gap-1 bg-neutral-500">{bars}</div>
) : (

View File

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

View File

@@ -99,11 +99,10 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = 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;

View File

@@ -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<ActiveCycleInfoProps> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
// local storage
const { setValue: setCycleTab } = useLocalStorage<TCycleView>("cycle_tab", "active");
const { setValue: setCycleLayout } = useLocalStorage<TCycleLayout>("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 (
<>
<div className="flex items-center gap-1.5 px-3 py-1.5">
{cycle.project_detail.emoji ? (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
{renderEmoji(cycle.project_detail.emoji)}
</span>
) : cycle.project_detail.icon_prop ? (
<div className="grid h-7 w-7 flex-shrink-0 place-items-center">
{renderEmoji(cycle.project_detail.icon_prop)}
</div>
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{cycle.project_detail?.name.charAt(0)}
</span>
)}
<h2 className="text-xl font-semibold">{cycle.project_detail.name}</h2>
</div>
<div className="flex flex-col gap-2 rounded border border-custom-border-200">
<div className="flex items-center justify-between px-3 pt-3 pb-1">
<div className="flex items-center gap-2 cursor-default">
<CycleGroupIcon cycleGroup={cuurentCycle} className="h-4 w-4" />
<Tooltip tooltipContent={cycle.name} position="top-left">
<h3 className="break-words text-lg font-medium">{truncateText(cycle.name, 70)}</h3>
</Tooltip>
<Tooltip
tooltipContent={`Start date: ${renderFormattedDate(
cycle.start_date ?? ""
)} Due Date: ${renderFormattedDate(cycle.end_date ?? "")}`}
position="top-left"
>
<span className="flex gap-1 whitespace-nowrap rounded-sm text-sm px-3 py-0.5 bg-amber-500/10 text-amber-500">
{`${daysLeft} ${daysLeft > 1 ? "Days" : "Day"} Left`}
</span>
</Tooltip>
</div>
<div className="flex items-center gap-2.5">
<span className="rounded-sm text-sm px-3 py-1 bg-custom-background-80">
<span className="flex gap-2 text-sm whitespace-nowrap font-medium">
<span>Lead:</span>
<div className="flex items-center gap-1.5">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<img
src={cycle.owned_by.avatar}
height={18}
width={18}
className="rounded-full"
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-background-100 capitalize">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
<span>{cycle.owned_by.display_name}</span>
</div>
</span>
</span>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles`}>
<Button
variant="primary"
size="sm"
onClick={() => {
handleCurrentView("active");
}}
>
View Cycle
</Button>
</Link>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
<div className="flex flex-col gap-4 px-3 pt-2 min-h-52 border-r-0 border-t border-custom-border-300 lg:border-r">
<div className="flex items-center justify-between gap-4">
<h3 className="text-xl font-medium">Progress</h3>
<span className="flex gap-1 text-sm whitespace-nowrap rounded-sm px-3 py-1 ">
{`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${
cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue"
} closed`}
</span>
</div>
<LinearProgressIndicator data={progressIndicatorData} />
<div>
<div className="flex flex-col gap-2">
{Object.keys(groupedIssues).map((group, index) => (
<>
{groupedIssues[group] > 0 && (
<div className="flex items-center justify-start gap-2 text-sm">
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: CYCLE_STATE_GROUPS_DETAILS[index].color,
}}
/>
<span className="capitalize font-medium w-16">{group}</span>
</div>
<span>{`: ${groupedIssues[group]} ${groupedIssues[group] > 1 ? "Issues" : "Issue"}`}</span>
</div>
)}
</>
))}
{cycle.cancelled_issues > 0 && (
<span className="flex items-center gap-2 text-sm text-custom-text-300">
<span>
{`${cycle.cancelled_issues} cancelled ${
cycle.cancelled_issues > 1 ? "issues are" : "issue is"
} excluded from this report.`}{" "}
</span>
</span>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-4 px-3 pt-2 min-h-52 border-r-0 border-t border-custom-border-300 lg:border-r">
<div className="flex items-center justify-between gap-4">
<h3 className="text-xl font-medium">Issue Burndown</h3>
</div>
<div className="relative ">
<ProgressChart
distribution={cycle.distribution?.completion_chart ?? {}}
startDate={cycle.start_date ?? ""}
endDate={cycle.end_date ?? ""}
totalIssues={cycle.total_issues}
/>
</div>
</div>
<div className="flex flex-col gap-4 px-3 pt-2 min-h-52 overflow-hidden col-span-1 lg:col-span-2 xl:col-span-1 border-t border-custom-border-300">
<div className="flex items-center justify-between gap-4">
<h3 className="text-xl font-medium">Priority</h3>
</div>
<div className="flex flex-col gap-4 h-full w-full max-h-40 overflow-y-auto pb-3">
{cycleIssues ? (
cycleIssues.length > 0 ? (
cycleIssues.map((issue: any) => (
<Link
key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
className="flex cursor-pointer items-center justify-between gap-2 rounded-md border border-custom-border-200 px-3 py-1.5"
>
<div className="flex items-center gap-1.5 flex-grow w-full truncate">
<PriorityIcon priority={issue.priority} withContainer size={12} />
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${cycle.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-custom-text-200">
{cycle.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-[0.825rem] text-custom-text-100 truncate">{issue.name}</span>
</Tooltip>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<StateDropdown
value={issue.state_id ?? undefined}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
disabled={true}
buttonVariant="background-with-text"
/>
{issue.target_date && (
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}>
<div className="h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80 cursor-not-allowed">
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
<span className="text-xs">{renderFormattedDateWithoutYear(issue.target_date)}</span>
</div>
</Tooltip>
)}
</div>
</Link>
))
) : (
<div className="flex items-center justify-center h-full text-sm text-custom-text-200">
<span>There are no high priority issues present in this cycle.</span>
</div>
)
) : (
<Loader className="space-y-3">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</div>
</div>
</div>
</div>
</>
);
};

View File

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

View File

@@ -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(() => (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
icon={<SendToBack className="h-4 w-4 text-custom-text-300" />}
label="Active Cycles"
/>
</Breadcrumbs>
<span className="flex items-center justify-center px-3.5 py-0.5 text-xs leading-4 rounded-xl text-orange-500 bg-orange-500/20">
Beta
</span>
</div>
</div>
</div>
));

View File

@@ -0,0 +1 @@
export * from "./pro-plan-modal";

View File

@@ -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<ProPlanModalProps> = (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 (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-lg transform overflow-hidden rounded-2xl bg-custom-background-100 p-6 text-left align-middle shadow-xl transition-all border-[0.5px] border-custom-border-100">
<Dialog.Title as="h2" className="text-2xl font-bold leading-6 mt-4 flex justify-center items-center">
Early-adopter pricing for believers
</Dialog.Title>
<div className="mt-2 mb-5">
<p className="text-center text-sm mb-6 px-10 text-custom-text-200">
Build Plane to your specs. You decide what we prioritize and build for everyone. Also get tailored
onboarding + implementation and priority support.
</p>
<Tab.Group>
<div className="flex w-full justify-center">
<Tab.List className="flex space-x-1 rounded-xl bg-custom-background-80 p-1 w-[72%]">
{PRICING_CATEGORIES.map((category, index) => (
<Tab
key={category}
className={({ selected }) =>
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" && (
<span className="bg-custom-primary-100 text-white rounded-full px-2 py-1 ml-1 text-xs">
-28%
</span>
)}
</>
</Tab>
))}
</Tab.List>
</div>
<Tab.Panels className="mt-2">
<Tab.Panel className={classNames("rounded-xl bg-custom-background-100 p-3")}>
<p className="ml-4 text-4xl font-bold mb-2">
$7
<span className="text-sm ml-3 text-custom-text-300">/user/month</span>
</p>
<ul>
{MONTHLY_PLAN_ITEMS.map((item) => (
<li key={item} className="relative rounded-md p-3 flex">
<p className="text-sm font-medium leading-5 flex items-center">
<CheckCircle className="h-4 w-4 mr-4" />
<span>{item}</span>
</p>
</li>
))}
</ul>
<div className="flex justify-center w-full">
<div className="relative inline-flex group mt-8">
<div className="absolute transition-all duration-1000 opacity-50 -inset-px bg-gradient-to-r from-[#44BCFF] via-[#FF44EC] to-[#FF675E] rounded-xl blur-lg group-hover:opacity-100 group-hover:-inset-1 group-hover:duration-200 animate-tilt" />
<button
type="button"
className="relative inline-flex items-center justify-center px-8 py-4 text-sm font-medium border-custom-border-100 border-[1.5px] transition-all duration-200 bg-custom-background-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-custom-border-200"
onClick={handleProPlaneMonthRedirection}
>
Become Early Adopter
</button>
</div>
</div>
</Tab.Panel>
<Tab.Panel className={classNames("rounded-xl bg-custom-background-100 p-3")}>
<p className="ml-4 text-4xl font-bold mb-2">
$5
<span className="text-sm ml-3 text-custom-text-300">/user/month</span>
</p>
<ul>
{YEARLY_PLAN_ITEMS.map((item) => (
<li key={item} className="relative rounded-md p-3 flex">
<p className="text-sm font-medium leading-5 flex items-center">
<CheckCircle className="h-4 w-4 mr-4" />
<span>{item}</span>
</p>
</li>
))}
</ul>
<div className="flex justify-center w-full">
<div className="relative inline-flex group mt-8">
<div className="absolute transition-all duration-1000 opacity-50 -inset-px bg-gradient-to-r from-[#44BCFF] via-[#FF44EC] to-[#FF675E] rounded-xl blur-lg group-hover:opacity-100 group-hover:-inset-1 group-hover:duration-200 animate-tilt" />
<button
type="button"
className="relative inline-flex items-center justify-center px-8 py-4 text-sm font-medium border-custom-border-100 border-[1.5px] transition-all duration-200 bg-custom-background-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-custom-border-200"
onClick={handleProPlanYearlyRedirection}
>
Become Early Adopter
</button>
</div>
</div>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
};

View File

@@ -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<WorkspaceHelpSectionProps> = 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<WorkspaceHelpSectionProps> = observe
const isCollapsed = sidebarCollapsed || false;
const handleProPlanModalOpen = () => {
setIsProPlanModalOpen(true);
captureEvent("pro_plan_modal_opened");
};
return (
<>
<ProPlanModal isOpen={isProPlanModalOpen} handleClose={() => setIsProPlanModalOpen(false)} />
<div
className={`flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 px-4 py-2 ${
isCollapsed ? "flex-col" : ""
}`}
>
{!isCollapsed && (
<div className="w-1/2 cursor-default rounded-md bg-green-500/10 px-2.5 py-1.5 text-center text-sm font-medium text-green-500 outline-none">
Free Plan
</div>
<Button
variant="outline-primary"
className="w-1/2 cursor-pointer rounded-2xl px-2.5 py-1.5 text-center text-sm font-medium outline-none"
onClick={handleProPlanModalOpen}
>
Plane Pro
</Button>
)}
<div className={`flex items-center gap-1 ${isCollapsed ? "flex-col justify-center" : "w-1/2 justify-evenly"}`}>
<button

View File

@@ -9,3 +9,6 @@ export * from "./sidebar-dropdown";
export * from "./sidebar-menu";
export * from "./sidebar-quick-action";
export * from "./workspace-active-cycles-upgrade";
// ee imports
export * from "./workspace-active-cycles-list";

View File

@@ -8,7 +8,6 @@ import { useApplication, useUser } from "hooks/store";
import { NotificationPopover } from "components/notifications";
// ui
import { Tooltip } from "@plane/ui";
import { Crown } from "lucide-react";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
import { SIDEBAR_MENU_ITEMS } from "constants/dashboard";
@@ -56,7 +55,9 @@ export const WorkspaceSidebarMenu = observer(() => {
}
{!themeStore?.sidebarCollapsed && link.label}
{!themeStore?.sidebarCollapsed && link.key === "active-cycles" && (
<Crown className="h-3.5 w-3.5 text-amber-400" />
<span className="flex items-center justify-center px-3.5 py-0.5 text-xs leading-4 rounded-xl text-orange-500 bg-orange-500/20">
Beta
</span>
)}
</div>
</Tooltip>

View File

@@ -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<string | undefined>(`3:0:0`);
const [allCyclesData, setAllCyclesData] = useState<ICycle[]>([]);
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 (
<div className="flex items-center justify-center h-full w-full">
<Spinner />
</div>
);
}
return (
<div className="h-full w-full">
{allCyclesData.length > 0 ? (
<>
{workspaceSlug &&
allCyclesData.map((cycle) => (
<div key={cycle.id} className="px-5 py-5">
<ActiveCycleInfo workspaceSlug={workspaceSlug?.toString()} projectId={cycle.project} cycle={cycle} />
</div>
))}
{hasMoreResults && (
<div className="flex items-center justify-center gap-4 text-xs w-full py-5">
<Button variant="outline-primary" size="sm" onClick={handleLoadMore}>
{isLoading ? "Loading..." : "Load More"}
</Button>
</div>
)}
</>
) : (
<div className="grid h-full place-items-center text-center">
<div className="space-y-2">
<div className="mx-auto flex justify-center">
<ContrastIcon className="h-40 w-40 text-custom-text-300" />
</div>
<h4 className="text-base text-custom-text-200">
Cycles running across all your projects can be seen here. Use this to track how the org is delivering
value across teams
</h4>
</div>
</div>
)}
</div>
);
});

View File

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

View File

@@ -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 = () => <WorkspaceActiveCyclesUpgrade />;
const WorkspaceActiveCyclesPage: NextPageWithLayout = () => <WorkspaceActiveCyclesList />;
WorkspaceActiveCyclesPage.getLayout = function getLayout(page: ReactElement) {
return <AppLayout header={<WorkspaceActiveCycleHeader />}>{page}</AppLayout>;

View File

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

View File

@@ -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<IWorkspaceActiveCyclesResponse> {
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<ICycle> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`, data)
.then((response) => response?.data)

View File

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