Merge pull request #1095 from makeplane/develop

promote: develop to stage-release
This commit is contained in:
guru_sainath 2023-05-20 20:24:27 +05:30 committed by GitHub
commit e081395857
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
325 changed files with 17505 additions and 7542 deletions

View File

@ -1,5 +1,4 @@
# Replace with your instance Public IP # Replace with your instance Public IP
# NEXT_PUBLIC_API_BASE_URL = "http://localhost"
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS= NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
NEXT_PUBLIC_GOOGLE_CLIENTID="" NEXT_PUBLIC_GOOGLE_CLIENTID=""
NEXT_PUBLIC_GITHUB_APP_NAME="" NEXT_PUBLIC_GITHUB_APP_NAME=""
@ -10,3 +9,12 @@ NEXT_PUBLIC_ENABLE_SENTRY=0
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0 NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
NEXT_PUBLIC_TRACK_EVENTS=0 NEXT_PUBLIC_TRACK_EVENTS=0
NEXT_PUBLIC_SLACK_CLIENT_ID="" NEXT_PUBLIC_SLACK_CLIENT_ID=""
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
AWS_REGION=""
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_S3_BUCKET_NAME=""
OPENAI_API_KEY=""
GPT_ENGINE=""

View File

@ -1,4 +1,4 @@
name: Build Api Server Docker Image name: Build and Push Backend Docker Image
on: on:
push: push:
@ -10,11 +10,8 @@ on:
jobs: jobs:
build_push_backend: build_push_backend:
name: Build Api Server Docker Image name: Build and Push Api Server Docker Image
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
permissions:
contents: read
packages: write
steps: steps:
- name: Check out the repo - name: Check out the repo
@ -28,20 +25,33 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0 uses: docker/setup-buildx-action@v2.5.0
- name: Login to Github Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v2.1.0 uses: docker/login-action@v2.1.0
with: with:
registry: "ghcr.io" registry: "ghcr.io"
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Login to Docker Hub
id: meta uses: docker/login-action@v2.1.0
with:
registry: "registry.hub.docker.com"
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker (Docker Hub)
id: ghmeta
uses: docker/metadata-action@v4.3.0
with:
images: makeplane/plane-backend
- name: Extract metadata (tags, labels) for Docker (Github)
id: dkrmeta
uses: docker/metadata-action@v4.3.0 uses: docker/metadata-action@v4.3.0
with: with:
images: ghcr.io/${{ github.repository }}-backend images: ghcr.io/${{ github.repository }}-backend
- name: Build Api Server - name: Build and Push to GitHub Container Registry
uses: docker/build-push-action@v4.0.0 uses: docker/build-push-action@v4.0.0
with: with:
context: ./apiserver context: ./apiserver
@ -50,5 +60,18 @@ jobs:
push: true push: true
cache-from: type=gha cache-from: type=gha
cache-to: type=gha cache-to: type=gha
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.ghmeta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.ghmeta.outputs.labels }}
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: linux/arm64,linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.dkrmeta.outputs.tags }}
labels: ${{ steps.dkrmeta.outputs.labels }}

View File

@ -1,4 +1,4 @@
name: Build Frontend Docker Image name: Build and Push Frontend Docker Image
on: on:
push: push:
@ -12,9 +12,6 @@ jobs:
build_push_frontend: build_push_frontend:
name: Build Frontend Docker Image name: Build Frontend Docker Image
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
permissions:
contents: read
packages: write
steps: steps:
- name: Check out the repo - name: Check out the repo
@ -35,13 +32,26 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
registry: "registry.hub.docker.com"
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker (Docker Hub)
id: ghmeta
uses: docker/metadata-action@v4.3.0
with:
images: makeplane/plane-frontend
- name: Extract metadata (tags, labels) for Docker (Github)
id: meta id: meta
uses: docker/metadata-action@v4.3.0 uses: docker/metadata-action@v4.3.0
with: with:
images: ghcr.io/${{ github.repository }}-frontend images: ghcr.io/${{ github.repository }}-frontend
- name: Build Frontend Server - name: Build and Push to GitHub Container Registry
uses: docker/build-push-action@v4.0.0 uses: docker/build-push-action@v4.0.0
with: with:
context: . context: .
@ -50,5 +60,18 @@ jobs:
push: true push: true
cache-from: type=gha cache-from: type=gha
cache-to: type=gha cache-to: type=gha
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.ghmeta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.ghmeta.outputs.labels }}
- name: Build and Push to Docker Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./apps/app/Dockerfile.web
platforms: linux/arm64,linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.dkrmeta.outputs.tags }}
labels: ${{ steps.dkrmeta.outputs.labels }}

View File

@ -3,6 +3,7 @@ RUN apk add --no-cache libc6-compat
RUN apk update RUN apk update
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo RUN yarn global add turbo
COPY . . COPY . .
@ -16,7 +17,7 @@ FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
RUN apk update RUN apk update
WORKDIR /app WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
# First install the dependencies (as they change less often) # First install the dependencies (as they change less often)
COPY .gitignore .gitignore COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ . COPY --from=builder /app/out/json/ .
@ -26,9 +27,16 @@ RUN yarn install
# Build the project # Build the project
COPY --from=builder /app/out/full/ . COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build --filter=app RUN yarn turbo run build --filter=app
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL}
FROM python:3.11.1-alpine3.17 AS backend FROM python:3.11.1-alpine3.17 AS backend
@ -108,6 +116,16 @@ COPY nginx/nginx-single-docker-image.conf /etc/nginx/http.d/default.conf
COPY nginx/supervisor.conf /code/supervisor.conf COPY nginx/supervisor.conf /code/supervisor.conf
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
USER root
COPY replace-env-vars.sh /usr/local/bin/
COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh
CMD ["supervisord","-c","/code/supervisor.conf"] CMD ["supervisord","-c","/code/supervisor.conf"]

View File

@ -26,7 +26,7 @@
</a> </a>
</p> </p>
Meet Plane. An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘‍♀️. Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘‍♀️.
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases. > Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
@ -58,11 +58,18 @@ cd plane
> If running in a cloud env replace localhost with public facing IP address of the VM > If running in a cloud env replace localhost with public facing IP address of the VM
- Export Environment Variables
```bash
set -a
source .env
set +a
```
- Run Docker compose up - Run Docker compose up
```bash ```bash
docker-compose up docker-compose -f docker-compose-hub.yml up
``` ```
<strong>You can use the default email and password for your first login `captain@plane.so` and `password123`.</strong> <strong>You can use the default email and password for your first login `captain@plane.so` and `password123`.</strong>

View File

@ -1,28 +0,0 @@
DJANGO_SETTINGS_MODULE="plane.settings.production"
# Database
DATABASE_URL=postgres://plane:xyzzyspoon@db:5432/plane
# Cache
REDIS_URL=redis://redis:6379/
# SMTP
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT="587"
EMAIL_USE_TLS="1"
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
# AWS
AWS_REGION=""
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_S3_BUCKET_NAME=""
AWS_S3_ENDPOINT_URL=""
# FE
WEB_URL="localhost/"
# OAUTH
GITHUB_CLIENT_SECRET=""
# Flags
DISABLE_COLLECTSTATIC=1
DOCKERIZED=1
# GPT Envs
OPENAI_API_KEY=0
GPT_ENGINE=0

View File

@ -204,7 +204,21 @@ def update_integration_verified():
Integration.objects.bulk_update( Integration.objects.bulk_update(
updated_integrations, ["verified"], batch_size=10 updated_integrations, ["verified"], batch_size=10
) )
print("Sucess") print("Success")
except Exception as e:
print(e)
print("Failed")
def update_start_date():
try:
issues = Issue.objects.filter(state__group__in=["started", "completed"])
updated_issues = []
for issue in issues:
issue.start_date = issue.created_at.date()
updated_issues.append(issue)
Issue.objects.bulk_update(updated_issues, ["start_date"], batch_size=500)
print("Success")
except Exception as e: except Exception as e:
print(e) print(e)
print("Failed") print("Failed")

View File

@ -70,3 +70,5 @@ from .importer import ImporterSerializer
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer
from .analytic import AnalyticViewSerializer

View File

@ -0,0 +1,30 @@
from .base import BaseSerializer
from plane.db.models import AnalyticView
from plane.utils.issue_filters import issue_filters
class AnalyticViewSerializer(BaseSerializer):
class Meta:
model = AnalyticView
fields = "__all__"
read_only_fields = [
"workspace",
"query",
]
def create(self, validated_data):
query_params = validated_data.get("query_dict", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
return AnalyticView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)

View File

@ -19,10 +19,32 @@ class CycleSerializer(BaseSerializer):
started_issues = serializers.IntegerField(read_only=True) started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True) unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True) backlog_issues = serializers.IntegerField(read_only=True)
assignees = serializers.SerializerMethodField(read_only=True)
total_estimates = serializers.IntegerField(read_only=True)
completed_estimates = serializers.IntegerField(read_only=True)
started_estimates = serializers.IntegerField(read_only=True)
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project") project_detail = ProjectLiteSerializer(read_only=True, source="project")
def get_assignees(self, obj):
members = [
{
"avatar": assignee.avatar,
"first_name": assignee.first_name,
"id": assignee.id,
}
for issue_cycle in obj.issue_cycle.all()
for assignee in issue_cycle.issue.assignees.all()
]
# Use a set comprehension to return only the unique objects
unique_objects = {frozenset(item.items()) for item in members}
# Convert the set back to a list of dictionaries
unique_list = [dict(item) for item in unique_objects]
return unique_list
class Meta: class Meta:
model = Cycle model = Cycle
fields = "__all__" fields = "__all__"

View File

@ -2,9 +2,13 @@
from .base import BaseSerializer from .base import BaseSerializer
from plane.db.models import Estimate, EstimatePoint from plane.db.models import Estimate, EstimatePoint
from plane.api.serializers import WorkspaceLiteSerializer, ProjectLiteSerializer
class EstimateSerializer(BaseSerializer): class EstimateSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta: class Meta:
model = Estimate model = Estimate
fields = "__all__" fields = "__all__"
@ -27,6 +31,8 @@ class EstimatePointSerializer(BaseSerializer):
class EstimateReadSerializer(BaseSerializer): class EstimateReadSerializer(BaseSerializer):
points = EstimatePointSerializer(read_only=True, many=True) points = EstimatePointSerializer(read_only=True, many=True)
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta: class Meta:
model = Estimate model = Estimate

View File

@ -2,12 +2,14 @@
from .base import BaseSerializer from .base import BaseSerializer
from .user import UserLiteSerializer from .user import UserLiteSerializer
from .project import ProjectLiteSerializer from .project import ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from plane.db.models import Importer from plane.db.models import Importer
class ImporterSerializer(BaseSerializer): class ImporterSerializer(BaseSerializer):
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True) initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True) project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
class Meta: class Meta:
model = Importer model = Importer

View File

@ -82,6 +82,9 @@ class ProjectDetailSerializer(BaseSerializer):
default_assignee = UserLiteSerializer(read_only=True) default_assignee = UserLiteSerializer(read_only=True)
project_lead = UserLiteSerializer(read_only=True) project_lead = UserLiteSerializer(read_only=True)
is_favorite = serializers.BooleanField(read_only=True) is_favorite = serializers.BooleanField(read_only=True)
total_members = serializers.IntegerField(read_only=True)
total_cycles = serializers.IntegerField(read_only=True)
total_modules = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Project model = Project

View File

@ -148,6 +148,13 @@ from plane.api.views import (
# Release Notes # Release Notes
ReleaseNotesEndpoint, ReleaseNotesEndpoint,
## End Release Notes ## End Release Notes
# Analytics
AnalyticsEndpoint,
AnalyticViewViewset,
SavedAnalyticEndpoint,
ExportAnalyticsEndpoint,
DefaultAnalyticsEndpoint,
## End Analytics
) )
@ -308,7 +315,6 @@ urlpatterns = [
"workspaces/<str:slug>/members/<uuid:pk>/", "workspaces/<str:slug>/members/<uuid:pk>/",
WorkSpaceMemberViewSet.as_view( WorkSpaceMemberViewSet.as_view(
{ {
"put": "update",
"patch": "partial_update", "patch": "partial_update",
"delete": "destroy", "delete": "destroy",
"get": "retrieve", "get": "retrieve",
@ -418,7 +424,6 @@ urlpatterns = [
ProjectMemberViewSet.as_view( ProjectMemberViewSet.as_view(
{ {
"get": "retrieve", "get": "retrieve",
"put": "update",
"patch": "partial_update", "patch": "partial_update",
"delete": "destroy", "delete": "destroy",
} }
@ -1285,4 +1290,38 @@ urlpatterns = [
name="release-notes", name="release-notes",
), ),
## End Release Notes ## End Release Notes
# Analytics
path(
"workspaces/<str:slug>/analytics/",
AnalyticsEndpoint.as_view(),
name="plane-analytics",
),
path(
"workspaces/<str:slug>/analytic-view/",
AnalyticViewViewset.as_view({"get": "list", "post": "create"}),
name="analytic-view",
),
path(
"workspaces/<str:slug>/analytic-view/<uuid:pk>/",
AnalyticViewViewset.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
name="analytic-view",
),
path(
"workspaces/<str:slug>/saved-analytic-view/<uuid:analytic_id>/",
SavedAnalyticEndpoint.as_view(),
name="saved-analytic-view",
),
path(
"workspaces/<str:slug>/export-analytics/",
ExportAnalyticsEndpoint.as_view(),
name="export-analytics",
),
path(
"workspaces/<str:slug>/default-analytics/",
DefaultAnalyticsEndpoint.as_view(),
name="default-analytics",
),
## End Analytics
] ]

View File

@ -140,3 +140,11 @@ from .estimate import (
from .release import ReleaseNotesEndpoint from .release import ReleaseNotesEndpoint
from .analytic import (
AnalyticsEndpoint,
AnalyticViewViewset,
SavedAnalyticEndpoint,
ExportAnalyticsEndpoint,
DefaultAnalyticsEndpoint,
)

View File

@ -0,0 +1,295 @@
# Django imports
from django.db.models import (
Count,
Sum,
F,
)
from django.db.models.functions import ExtractMonth
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Module imports
from plane.api.views import BaseAPIView, BaseViewSet
from plane.api.permissions import WorkSpaceAdminPermission
from plane.db.models import Issue, AnalyticView, Workspace, State, Label
from plane.api.serializers import AnalyticViewSerializer
from plane.utils.analytics_plot import build_graph_plot
from plane.bgtasks.analytic_plot_export import analytic_export_task
from plane.utils.issue_filters import issue_filters
class AnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def get(self, request, slug):
try:
x_axis = request.GET.get("x_axis", False)
y_axis = request.GET.get("y_axis", False)
if not x_axis or not y_axis:
return Response(
{"error": "x-axis and y-axis dimensions are required"},
status=status.HTTP_400_BAD_REQUEST,
)
segment = request.GET.get("segment", False)
filters = issue_filters(request.GET, "GET")
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
total_issues = queryset.count()
distribution = build_graph_plot(
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
)
colors = dict()
if x_axis in ["state__name", "state__group"] or segment in [
"state__name",
"state__group",
]:
if x_axis in ["state__name", "state__group"]:
key = "name" if x_axis == "state__name" else "group"
else:
key = "name" if segment == "state__name" else "group"
colors = (
State.objects.filter(
workspace__slug=slug, project_id__in=filters.get("project__in")
).values(key, "color")
if filters.get("project__in", False)
else State.objects.filter(workspace__slug=slug).values(key, "color")
)
if x_axis in ["labels__name"] or segment in ["labels__name"]:
colors = (
Label.objects.filter(
workspace__slug=slug, project_id__in=filters.get("project__in")
).values("name", "color")
if filters.get("project__in", False)
else Label.objects.filter(workspace__slug=slug).values(
"name", "color"
)
)
assignee_details = {}
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
assignee_details = (
Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
.order_by("assignees__id")
.distinct("assignees__id")
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
)
return Response(
{
"total": total_issues,
"distribution": distribution,
"extras": {"colors": colors, "assignee_details": assignee_details},
},
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class AnalyticViewViewset(BaseViewSet):
permission_classes = [
WorkSpaceAdminPermission,
]
model = AnalyticView
serializer_class = AnalyticViewSerializer
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(workspace_id=workspace.id)
def get_queryset(self):
return self.filter_queryset(
super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))
)
class SavedAnalyticEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def get(self, request, slug, analytic_id):
try:
analytic_view = AnalyticView.objects.get(
pk=analytic_id, workspace__slug=slug
)
filter = analytic_view.query
queryset = Issue.objects.filter(**filter)
x_axis = analytic_view.query_dict.get("x_axis", False)
y_axis = analytic_view.query_dict.get("y_axis", False)
if not x_axis or not y_axis:
return Response(
{"error": "x-axis and y-axis dimensions are required"},
status=status.HTTP_400_BAD_REQUEST,
)
segment = request.GET.get("segment", False)
distribution = build_graph_plot(
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
)
total_issues = queryset.count()
return Response(
{"total": total_issues, "distribution": distribution},
status=status.HTTP_200_OK,
)
except AnalyticView.DoesNotExist:
return Response(
{"error": "Analytic View Does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ExportAnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def post(self, request, slug):
try:
x_axis = request.data.get("x_axis", False)
y_axis = request.data.get("y_axis", False)
if not x_axis or not y_axis:
return Response(
{"error": "x-axis and y-axis dimensions are required"},
status=status.HTTP_400_BAD_REQUEST,
)
analytic_export_task.delay(
email=request.user.email, data=request.data, slug=slug
)
return Response(
{
"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"
},
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class DefaultAnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def get(self, request, slug):
try:
filters = issue_filters(request.GET, "GET")
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
total_issues = queryset.count()
total_issues_classified = (
queryset.annotate(state_group=F("state__group"))
.values("state_group")
.annotate(state_count=Count("state_group"))
.order_by("state_group")
)
open_issues = queryset.filter(
state__group__in=["backlog", "unstarted", "started"]
).count()
open_issues_classified = (
queryset.filter(state__group__in=["backlog", "unstarted", "started"])
.annotate(state_group=F("state__group"))
.values("state_group")
.annotate(state_count=Count("state_group"))
.order_by("state_group")
)
issue_completed_month_wise = (
queryset.filter(completed_at__isnull=False)
.annotate(month=ExtractMonth("completed_at"))
.values("month")
.annotate(count=Count("*"))
.order_by("month")
)
most_issue_created_user = (
queryset.exclude(created_by=None)
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__email")
.annotate(count=Count("id"))
.order_by("-count")
)[:5]
most_issue_closed_user = (
queryset.filter(completed_at__isnull=False, assignees__isnull=False)
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
.annotate(count=Count("id"))
.order_by("-count")
)[:5]
pending_issue_user = (
queryset.filter(completed_at__isnull=True)
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
.annotate(count=Count("id"))
.order_by("-count")
)
open_estimate_sum = (
queryset.filter(
state__group__in=["backlog", "unstarted", "started"]
).aggregate(open_estimate_sum=Sum("estimate_point"))
)["open_estimate_sum"]
print(open_estimate_sum)
total_estimate_sum = queryset.aggregate(
total_estimate_sum=Sum("estimate_point")
)["total_estimate_sum"]
return Response(
{
"total_issues": total_issues,
"total_issues_classified": total_issues_classified,
"open_issues": open_issues,
"open_issues_classified": open_issues_classified,
"issue_completed_month_wise": issue_completed_month_wise,
"most_issue_created_user": most_issue_created_user,
"most_issue_closed_user": most_issue_closed_user,
"pending_issue_user": pending_issue_user,
"open_estimate_sum": open_estimate_sum,
"total_estimate_sum": total_estimate_sum,
},
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -3,7 +3,17 @@ import json
# Django imports # Django imports
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef, Count, Prefetch from django.db.models import (
OuterRef,
Func,
F,
Q,
Exists,
OuterRef,
Count,
Prefetch,
Sum,
)
from django.core import serializers from django.core import serializers
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@ -24,6 +34,7 @@ from plane.api.serializers import (
) )
from plane.api.permissions import ProjectEntityPermission from plane.api.permissions import ProjectEntityPermission
from plane.db.models import ( from plane.db.models import (
User,
Cycle, Cycle,
CycleIssue, CycleIssue,
Issue, Issue,
@ -118,6 +129,25 @@ class CycleViewSet(BaseViewSet):
filter=Q(issue_cycle__issue__state__group="backlog"), filter=Q(issue_cycle__issue__state__group="backlog"),
) )
) )
.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"),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
)
)
.order_by("-is_favorite", "name") .order_by("-is_favorite", "name")
.distinct() .distinct()
) )
@ -413,7 +443,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
try: try:
start_date = request.data.get("start_date", False) start_date = request.data.get("start_date", False)
end_date = request.data.get("end_date", False) end_date = request.data.get("end_date", False)
cycle_id = request.data.get("cycle_id")
if not start_date or not end_date: if not start_date or not end_date:
return Response( return Response(
{"error": "Start date and end date both are required"}, {"error": "Start date and end date both are required"},
@ -421,12 +451,14 @@ class CycleDateCheckEndpoint(BaseAPIView):
) )
cycles = Cycle.objects.filter( cycles = Cycle.objects.filter(
Q(start_date__lte=start_date, end_date__gte=start_date) Q(workspace__slug=slug)
| Q(start_date__lte=end_date, end_date__gte=end_date) & Q(project_id=project_id)
| Q(start_date__gte=start_date, end_date__lte=end_date), & (
workspace__slug=slug, Q(start_date__lte=start_date, end_date__gte=start_date)
project_id=project_id, | Q(start_date__lte=end_date, end_date__gte=end_date)
) | Q(start_date__gte=start_date, end_date__lte=end_date)
)
).exclude(pk=cycle_id)
if cycles.exists(): if cycles.exists():
return Response( return Response(
@ -501,6 +533,27 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
filter=Q(issue_cycle__issue__state__group="backlog"), filter=Q(issue_cycle__issue__state__group="backlog"),
) )
) )
.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"),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite") .order_by("name", "-is_favorite")
) )
@ -545,6 +598,27 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
filter=Q(issue_cycle__issue__state__group="backlog"), filter=Q(issue_cycle__issue__state__group="backlog"),
) )
) )
.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"),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite") .order_by("name", "-is_favorite")
) )
@ -618,6 +692,27 @@ class CompletedCyclesEndpoint(BaseAPIView):
filter=Q(issue_cycle__issue__state__group="backlog"), filter=Q(issue_cycle__issue__state__group="backlog"),
) )
) )
.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"),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite") .order_by("name", "-is_favorite")
) )
@ -693,6 +788,27 @@ class DraftCyclesEndpoint(BaseAPIView):
filter=Q(issue_cycle__issue__state__group="backlog"), filter=Q(issue_cycle__issue__state__group="backlog"),
) )
) )
.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"),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite") .order_by("name", "-is_favorite")
) )

View File

@ -53,11 +53,11 @@ class BulkEstimatePointEndpoint(BaseViewSet):
try: try:
estimates = Estimate.objects.filter( estimates = Estimate.objects.filter(
workspace__slug=slug, project_id=project_id workspace__slug=slug, project_id=project_id
).prefetch_related("points") ).prefetch_related("points").select_related("workspace", "project")
serializer = EstimateReadSerializer(estimates, many=True) serializer = EstimateReadSerializer(estimates, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e: except Exception as e:
print(e) capture_exception(e)
return Response( return Response(
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@ -211,7 +211,7 @@ class BulkEstimatePointEndpoint(BaseViewSet):
try: try:
EstimatePoint.objects.bulk_update( EstimatePoint.objects.bulk_update(
updated_estimate_points, ["value"], batch_size=10 updated_estimate_points, ["value"], batch_size=10,
) )
except IntegrityError as e: except IntegrityError as e:
return Response( return Response(

View File

@ -363,6 +363,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
start_date=issue_data.get("start_date", None), start_date=issue_data.get("start_date", None),
target_date=issue_data.get("target_date", None), target_date=issue_data.get("target_date", None),
priority=issue_data.get("priority", None), priority=issue_data.get("priority", None),
created_by=request.user,
) )
) )
@ -400,7 +401,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
project_id=project_id, project_id=project_id,
workspace_id=project.workspace_id, workspace_id=project.workspace_id,
created_by=request.user, created_by=request.user,
updated_by=request.user,
) )
for label_id in labels_list for label_id in labels_list
] ]
@ -420,7 +420,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
project_id=project_id, project_id=project_id,
workspace_id=project.workspace_id, workspace_id=project.workspace_id,
created_by=request.user, created_by=request.user,
updated_by=request.user,
) )
for assignee_id in assignees_list for assignee_id in assignees_list
] ]
@ -439,6 +438,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
workspace_id=project.workspace_id, workspace_id=project.workspace_id,
comment=f"{request.user.email} importer the issue from {service}", comment=f"{request.user.email} importer the issue from {service}",
verb="created", verb="created",
created_by=request.user,
) )
for issue in issues for issue in issues
], ],
@ -457,7 +457,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
project_id=project_id, project_id=project_id,
workspace_id=project.workspace_id, workspace_id=project.workspace_id,
created_by=request.user, created_by=request.user,
updated_by=request.user,
) )
for comment in comments_list for comment in comments_list
] ]
@ -474,7 +473,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
project_id=project_id, project_id=project_id,
workspace_id=project.workspace_id, workspace_id=project.workspace_id,
created_by=request.user, created_by=request.user,
updated_by=request.user,
) )
for issue, issue_data in zip(issues, issues_data) for issue, issue_data in zip(issues, issues_data)
] ]
@ -512,7 +510,6 @@ class BulkImportModulesEndpoint(BaseAPIView):
project_id=project_id, project_id=project_id,
workspace_id=project.workspace_id, workspace_id=project.workspace_id,
created_by=request.user, created_by=request.user,
updated_by=request.user,
) )
for module in modules_data for module in modules_data
], ],
@ -536,7 +533,6 @@ class BulkImportModulesEndpoint(BaseAPIView):
project_id=project_id, project_id=project_id,
workspace_id=project.workspace_id, workspace_id=project.workspace_id,
created_by=request.user, created_by=request.user,
updated_by=request.user,
) )
for module, module_data in zip(modules, modules_data) for module, module_data in zip(modules, modules_data)
], ],
@ -554,7 +550,6 @@ class BulkImportModulesEndpoint(BaseAPIView):
project_id=project_id, project_id=project_id,
workspace_id=project.workspace_id, workspace_id=project.workspace_id,
created_by=request.user, created_by=request.user,
updated_by=request.user,
) )
for issue in module_issues_list for issue in module_issues_list
] ]

View File

@ -5,7 +5,7 @@ from datetime import datetime
# Django imports # Django imports
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Q, Exists, OuterRef from django.db.models import Q, Exists, OuterRef, Func, F
from django.core.validators import validate_email from django.core.validators import validate_email
from django.conf import settings from django.conf import settings
@ -46,6 +46,8 @@ from plane.db.models import (
ProjectMemberInvite, ProjectMemberInvite,
User, User,
ProjectIdentifier, ProjectIdentifier,
Cycle,
Module,
) )
from plane.bgtasks.project_invitation_task import project_invitation from plane.bgtasks.project_invitation_task import project_invitation
@ -92,6 +94,26 @@ class ProjectViewSet(BaseViewSet):
self.get_queryset() self.get_queryset()
.annotate(is_favorite=Exists(subquery)) .annotate(is_favorite=Exists(subquery))
.order_by("-is_favorite", "name") .order_by("-is_favorite", "name")
.annotate(
total_members=ProjectMember.objects.filter(
project_id=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
total_cycles=Cycle.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
total_modules=Module.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
) )
return Response(ProjectDetailSerializer(projects, many=True).data) return Response(ProjectDetailSerializer(projects, many=True).data)
except Exception as e: except Exception as e:
@ -161,6 +183,7 @@ class ProjectViewSet(BaseViewSet):
workspace=serializer.instance.workspace, workspace=serializer.instance.workspace,
group=state["group"], group=state["group"],
default=state.get("default", False), default=state.get("default", False),
created_by=request.user,
) )
for state in states for state in states
] ]
@ -344,6 +367,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
workspace=invitation.project.workspace, workspace=invitation.project.workspace,
member=request.user, member=request.user,
role=invitation.role, role=invitation.role,
created_by=request.user,
) )
for invitation in project_invitations for invitation in project_invitations
] ]
@ -385,6 +409,41 @@ class ProjectMemberViewSet(BaseViewSet):
.select_related("workspace", "workspace__owner") .select_related("workspace", "workspace__owner")
) )
def partial_update(self, request, slug, project_id, pk):
try:
project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
if request.user.id == project_member.member_id:
return Response(
{"error": "You cannot update your own role"},
status=status.HTTP_400_BAD_REQUEST,
)
if request.data.get("role", 10) > project_member.role:
return Response(
{
"error": "You cannot update a role that is higher than your own role"
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ProjectMemberSerializer(
project_member, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except ProjectMember.DoesNotExist:
return Response(
{"error": "Project Member does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST)
class AddMemberToProjectEndpoint(BaseAPIView): class AddMemberToProjectEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
@ -465,6 +524,7 @@ class AddTeamToProjectEndpoint(BaseAPIView):
project_id=project_id, project_id=project_id,
member_id=member, member_id=member,
workspace=workspace, workspace=workspace,
created_by=request.user,
) )
) )
@ -612,6 +672,7 @@ class ProjectJoinEndpoint(BaseAPIView):
if workspace_role >= 15 if workspace_role >= 15
else (15 if workspace_role == 10 else workspace_role), else (15 if workspace_role == 10 else workspace_role),
workspace=workspace, workspace=workspace,
created_by=request.user,
) )
for project_id in project_ids for project_id in project_ids
], ],

View File

@ -18,10 +18,6 @@ from plane.api.permissions import ProjectEntityPermission
from plane.db.models import ( from plane.db.models import (
IssueView, IssueView,
Issue, Issue,
IssueBlocker,
IssueLink,
CycleIssue,
ModuleIssue,
IssueViewFavorite, IssueViewFavorite,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters

View File

@ -223,6 +223,7 @@ class InviteWorkspaceEndpoint(BaseAPIView):
algorithm="HS256", algorithm="HS256",
), ),
role=email.get("role", 10), role=email.get("role", 10),
created_by=request.user,
) )
) )
except ValidationError: except ValidationError:
@ -381,6 +382,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
workspace=invitation.workspace, workspace=invitation.workspace,
member=request.user, member=request.user,
role=invitation.role, role=invitation.role,
created_by=request.user,
) )
for invitation in workspace_invitations for invitation in workspace_invitations
], ],
@ -421,6 +423,43 @@ class WorkSpaceMemberViewSet(BaseViewSet):
.select_related("member") .select_related("member")
) )
def partial_update(self, request, slug, pk):
try:
workspace_member = WorkspaceMember.objects.get(pk=pk, workspace__slug=slug)
if request.user.id == workspace_member.member_id:
return Response(
{"error": "You cannot update your own role"},
status=status.HTTP_400_BAD_REQUEST,
)
if request.data.get("role", 10) > workspace_member.role:
return Response(
{
"error": "You cannot update a role that is higher than your own role"
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = WorkSpaceMemberSerializer(
workspace_member, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except WorkspaceMember.DoesNotExist:
return Response(
{"error": "Workspace Member does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class TeamMemberViewSet(BaseViewSet): class TeamMemberViewSet(BaseViewSet):
serializer_class = TeamSerializer serializer_class = TeamSerializer
@ -783,4 +822,3 @@ class WorkspaceThemeViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )

View File

@ -0,0 +1,174 @@
# Python imports
import csv
import io
# Django imports
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Issue
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters
row_mapping = {
"state__name": "State",
"state__group": "State Group",
"labels__name": "Label",
"assignees__email": "Assignee Name",
"start_date": "Start Date",
"target_date": "Due Date",
"completed_at": "Completed At",
"created_at": "Created At",
"issue_count": "Issue Count",
"priority": "Priority",
"estimate": "Estimate",
}
@shared_task
def analytic_export_task(email, data, slug):
try:
filters = issue_filters(data, "POST")
queryset = Issue.objects.filter(**filters, workspace__slug=slug)
x_axis = data.get("x_axis", False)
y_axis = data.get("y_axis", False)
segment = data.get("segment", False)
distribution = build_graph_plot(
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
)
key = "count" if y_axis == "issue_count" else "estimate"
segmented = segment
assignee_details = {}
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
assignee_details = (
Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
.order_by("assignees__id")
.distinct("assignees__id")
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
)
if segment:
segment_zero = []
for item in distribution:
current_dict = distribution.get(item)
for current in current_dict:
segment_zero.append(current.get("segment"))
segment_zero = list(set(segment_zero))
row_zero = (
[
row_mapping.get(x_axis, "X-Axis"),
]
+ [
row_mapping.get(y_axis, "Y-Axis"),
]
+ segment_zero
)
rows = []
for item in distribution:
generated_row = [
item,
]
data = distribution.get(item)
# Add y axis values
generated_row.append(sum(obj.get(key) for obj in data if obj.get(key, None) is not None))
for segment in segment_zero:
value = [x for x in data if x.get("segment") == segment]
if len(value):
generated_row.append(value[0].get(key))
else:
generated_row.append("0")
# x-axis replacement for names
if x_axis in ["assignees__email"]:
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
if len(assignee):
generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows.append(tuple(generated_row))
# If segment is ["assignees__email"] then replace segment_zero rows with first and last names
if segmented in ["assignees__email"]:
for index, segm in enumerate(row_zero[2:]):
# find the name of the user
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(segm)]
if len(assignee):
row_zero[index] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows = [tuple(row_zero)] + rows
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
# Write CSV data to the buffer
for row in rows:
writer.writerow(row)
subject = "Your Export is ready"
html_content = render_to_string("emails/exports/analytics.html", {})
text_content = strip_tags(html_content)
csv_buffer.seek(0)
msg = EmailMultiAlternatives(
subject, text_content, settings.EMAIL_FROM, [email]
)
msg.attach(f"{slug}-analytics.csv", csv_buffer.read())
msg.send(fail_silently=False)
else:
row_zero = [
row_mapping.get(x_axis, "X-Axis"),
row_mapping.get(y_axis, "Y-Axis"),
]
rows = []
for item in distribution:
row = [
item,
distribution.get(item)[0].get("count")
if y_axis == "issue_count"
else distribution.get(item)[0].get("estimate "),
]
# x-axis replacement to names
if x_axis in ["assignees__email"]:
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
if len(assignee):
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows.append(tuple(row))
rows = [tuple(row_zero)] + rows
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
# Write CSV data to the buffer
for row in rows:
writer.writerow(row)
subject = "Your Export is ready"
html_content = render_to_string("emails/exports/analytics.html", {})
text_content = strip_tags(html_content)
csv_buffer.seek(0)
msg = EmailMultiAlternatives(
subject, text_content, settings.EMAIL_FROM, [email]
)
msg.attach(f"{slug}-analytics.csv", csv_buffer.read())
msg.send(fail_silently=False)
except Exception as e:
print(e)
capture_exception(e)
return

View File

@ -27,7 +27,7 @@ from plane.db.models import (
User, User,
) )
from .workspace_invitation_task import workspace_invitation from .workspace_invitation_task import workspace_invitation
from plane.bgtasks.user_welcome_task import send_welcome_email from plane.bgtasks.user_welcome_task import send_welcome_slack
@shared_task @shared_task
@ -58,7 +58,7 @@ def service_importer(service, importer_id):
) )
[ [
send_welcome_email.delay( send_welcome_slack.delay(
str(user.id), str(user.id),
True, True,
f"{user.email} was imported to Plane from {service}", f"{user.email} was imported to Plane from {service}",
@ -78,7 +78,11 @@ def service_importer(service, importer_id):
# Add new users to Workspace and project automatically # Add new users to Workspace and project automatically
WorkspaceMember.objects.bulk_create( WorkspaceMember.objects.bulk_create(
[ [
WorkspaceMember(member=user, workspace_id=importer.workspace_id) WorkspaceMember(
member=user,
workspace_id=importer.workspace_id,
created_by=importer.created_by,
)
for user in workspace_users for user in workspace_users
], ],
batch_size=100, batch_size=100,
@ -91,6 +95,7 @@ def service_importer(service, importer_id):
project_id=importer.project_id, project_id=importer.project_id,
workspace_id=importer.workspace_id, workspace_id=importer.workspace_id,
member=user, member=user,
created_by=importer.created_by,
) )
for user in workspace_users for user in workspace_users
], ],

View File

@ -136,7 +136,6 @@ def track_priority(
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}", comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
) )
) )
print(issue_activities)
# Track chnages in state of the issue # Track chnages in state of the issue

View File

@ -1,8 +1,5 @@
# Django imports # Django imports
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
# Third party imports # Third party imports
from celery import shared_task from celery import shared_task
@ -15,31 +12,11 @@ from plane.db.models import User
@shared_task @shared_task
def send_welcome_email(user_id, created, message): def send_welcome_slack(user_id, created, message):
try: try:
instance = User.objects.get(pk=user_id) instance = User.objects.get(pk=user_id)
if created and not instance.is_bot: if created and not instance.is_bot:
first_name = instance.first_name.capitalize()
to_email = instance.email
from_email_string = settings.EMAIL_FROM
subject = f"Welcome to Plane ✈️!"
context = {"first_name": first_name, "email": instance.email}
html_content = render_to_string(
"emails/auth/user_welcome_email.html", context
)
text_content = strip_tags(html_content)
msg = EmailMultiAlternatives(
subject, text_content, from_email_string, [to_email]
)
msg.attach_alternative(html_content, "text/html")
msg.send()
# Send message on slack as well # Send message on slack as well
if settings.SLACK_BOT_TOKEN: if settings.SLACK_BOT_TOKEN:
client = WebClient(token=settings.SLACK_BOT_TOKEN) client = WebClient(token=settings.SLACK_BOT_TOKEN)

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.18 on 2023-05-05 14:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('db', '0029_auto_20230502_0126'),
]
operations = [
migrations.AlterUniqueTogether(
name='estimatepoint',
unique_together=set(),
),
]

View File

@ -0,0 +1,37 @@
# Generated by Django 3.2.18 on 2023-05-12 11:31
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0030_alter_estimatepoint_unique_together'),
]
operations = [
migrations.CreateModel(
name='AnalyticView',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('query', models.JSONField()),
('query_dict', models.JSONField(default=dict)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='analyticview_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='analyticview_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='analytics', to='db.workspace')),
],
options={
'verbose_name': 'Analytic',
'verbose_name_plural': 'Analytics',
'db_table': 'analytic_views',
'ordering': ('-created_at',),
},
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.19 on 2023-05-20 14:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0031_analyticview'),
]
operations = [
migrations.RenameField(
model_name='project',
old_name='icon',
new_name='emoji',
),
migrations.AddField(
model_name='project',
name='icon_prop',
field=models.JSONField(null=True),
),
]

View File

@ -67,3 +67,5 @@ from .importer import Importer
from .page import Page, PageBlock, PageFavorite, PageLabel from .page import Page, PageBlock, PageFavorite, PageLabel
from .estimate import Estimate, EstimatePoint from .estimate import Estimate, EstimatePoint
from .analytic import AnalyticView

View File

@ -0,0 +1,25 @@
# Django models
from django.db import models
from django.conf import settings
from .base import BaseModel
class AnalyticView(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", related_name="analytics", on_delete=models.CASCADE
)
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
query = models.JSONField()
query_dict = models.JSONField(default=dict)
class Meta:
verbose_name = "Analytic"
verbose_name_plural = "Analytics"
db_table = "analytic_views"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the analytic view"""
return f"{self.name} <{self.workspace.name}>"

View File

@ -39,7 +39,6 @@ class EstimatePoint(ProjectBaseModel):
return f"{self.estimate.name} <{self.key}> <{self.value}>" return f"{self.estimate.name} <{self.key}> <{self.value}>"
class Meta: class Meta:
unique_together = ["value", "estimate"]
verbose_name = "Estimate Point" verbose_name = "Estimate Point"
verbose_name_plural = "Estimate Points" verbose_name_plural = "Estimate Points"
db_table = "estimate_points" db_table = "estimate_points"

View File

@ -85,8 +85,13 @@ class Issue(ProjectBaseModel):
).first() ).first()
# if there is no default state assign any random state # if there is no default state assign any random state
if default_state is None: if default_state is None:
self.state = State.objects.filter(project=self.project).first() random_state = State.objects.filter(project=self.project).first()
self.state = random_state
if random_state.group == "started":
self.start_date = timezone.now().date()
else: else:
if default_state.group == "started":
self.start_date = timezone.now().date()
self.state = default_state self.state = default_state
except ImportError: except ImportError:
pass pass
@ -94,18 +99,15 @@ class Issue(ProjectBaseModel):
try: try:
from plane.db.models import State, PageBlock from plane.db.models import State, PageBlock
# Get the completed states of the project
completed_states = State.objects.filter(
group="completed", project=self.project
).values_list("pk", flat=True)
# Check if the current issue state and completed state id are same # Check if the current issue state and completed state id are same
if self.state.id in completed_states: if self.state.group == "completed":
self.completed_at = timezone.now() self.completed_at = timezone.now()
# check if there are any page blocks # check if there are any page blocks
PageBlock.objects.filter(issue_id=self.id).filter().update( PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=timezone.now() completed_at=timezone.now()
) )
elif self.state.group == "started":
self.start_date = timezone.now().date()
else: else:
PageBlock.objects.filter(issue_id=self.id).filter().update( PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=None completed_at=None
@ -116,7 +118,6 @@ class Issue(ProjectBaseModel):
pass pass
if self._state.adding: if self._state.adding:
# Get the maximum display_id value from the database # Get the maximum display_id value from the database
last_id = IssueSequence.objects.filter(project=self.project).aggregate( last_id = IssueSequence.objects.filter(project=self.project).aggregate(
largest=models.Max("sequence") largest=models.Max("sequence")
)["largest"] )["largest"]
@ -131,6 +132,9 @@ class Issue(ProjectBaseModel):
if largest_sort_order is not None: if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000 self.sort_order = largest_sort_order + 10000
# If adding it to started state
if self.state.group == "started":
self.start_date = timezone.now().date()
# Strip the html tags using html parser # Strip the html tags using html parser
self.description_stripped = ( self.description_stripped = (
None None

View File

@ -63,7 +63,8 @@ class Project(BaseModel):
null=True, null=True,
blank=True, blank=True,
) )
icon = models.CharField(max_length=255, null=True, blank=True) emoji = models.CharField(max_length=255, null=True, blank=True)
icon_prop = models.JSONField(null=True)
module_view = models.BooleanField(default=True) module_view = models.BooleanField(default=True)
cycle_view = models.BooleanField(default=True) cycle_view = models.BooleanField(default=True)
issue_views_view = models.BooleanField(default=True) issue_views_view = models.BooleanField(default=True)

View File

@ -104,29 +104,9 @@ class User(AbstractBaseUser, PermissionsMixin):
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def send_welcome_email(sender, instance, created, **kwargs): def send_welcome_slack(sender, instance, created, **kwargs):
try: try:
if created and not instance.is_bot: if created and not instance.is_bot:
first_name = instance.first_name.capitalize()
to_email = instance.email
from_email_string = settings.EMAIL_FROM
subject = f"Welcome to Plane ✈️!"
context = {"first_name": first_name, "email": instance.email}
html_content = render_to_string(
"emails/auth/user_welcome_email.html", context
)
text_content = strip_tags(html_content)
msg = EmailMultiAlternatives(
subject, text_content, from_email_string, [to_email]
)
msg.attach_alternative(html_content, "text/html")
msg.send()
# Send message on slack as well # Send message on slack as well
if settings.SLACK_BOT_TOKEN: if settings.SLACK_BOT_TOKEN:
client = WebClient(token=settings.SLACK_BOT_TOKEN) client = WebClient(token=settings.SLACK_BOT_TOKEN)

View File

@ -0,0 +1,76 @@
# Python imports
from itertools import groupby
# Django import
from django.db import models
from django.db.models import Count, F, Sum, Value, Case, When, CharField
from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Concat
def build_graph_plot(queryset, x_axis, y_axis, segment=None):
temp_axis = x_axis
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
year = ExtractYear(x_axis)
month = ExtractMonth(x_axis)
dimension = Concat(year, Value("-"), month, output_field=CharField())
queryset = queryset.annotate(dimension=dimension)
x_axis = "dimension"
else:
queryset = queryset.annotate(dimension=F(x_axis))
x_axis = "dimension"
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
queryset = queryset.exclude(x_axis__is_null=True)
if segment in ["created_at", "start_date", "target_date", "completed_at"]:
year = ExtractYear(segment)
month = ExtractMonth(segment)
dimension = Concat(year, Value("-"), month, output_field=CharField())
queryset = queryset.annotate(segmented=dimension)
segment = "segmented"
queryset = queryset.values(x_axis)
# Group queryset by x_axis field
if y_axis == "issue_count":
queryset = queryset.annotate(
is_null=Case(
When(dimension__isnull=True, then=Value("None")),
default=Value("not_null"),
output_field=models.CharField(max_length=8),
),
dimension_ex=Coalesce("dimension", Value("null")),
).values("dimension")
if segment:
queryset = queryset.annotate(segment=F(segment)).values(
"dimension", "segment"
)
else:
queryset = queryset.values("dimension")
queryset = queryset.annotate(count=Count("*")).order_by("dimension")
if y_axis == "estimate":
queryset = queryset.annotate(estimate=Sum("estimate_point")).order_by(x_axis)
if segment:
queryset = queryset.annotate(segment=F(segment)).values(
"dimension", "segment", "estimate"
)
else:
queryset = queryset.values("dimension", "estimate")
result_values = list(queryset)
grouped_data = {}
for key, items in groupby(result_values, key=lambda x: x[str("dimension")]):
grouped_data[str(key)] = list(items)
sorted_data = grouped_data
if temp_axis == "priority":
order = ["low", "medium", "high", "urgent", "None"]
sorted_data = {key: grouped_data[key] for key in order if key in grouped_data}
else:
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0])))
return sorted_data

View File

@ -198,6 +198,39 @@ def filter_issue_state_type(params, filter, method):
return filter return filter
def filter_project(params, filter, method):
if method == "GET":
projects = params.get("project").split(",")
if len(projects) and "" not in projects:
filter["project__in"] = projects
else:
if params.get("project", None) and len(params.get("project")):
filter["project__in"] = params.get("project")
return filter
def filter_cycle(params, filter, method):
if method == "GET":
cycles = params.get("cycle").split(",")
if len(cycles) and "" not in cycles:
filter["issue_cycle__cycle_id__in"] = cycles
else:
if params.get("cycle", None) and len(params.get("cycle")):
filter["issue_cycle__cycle_id__in"] = params.get("cycle")
return filter
def filter_module(params, filter, method):
if method == "GET":
modules = params.get("module").split(",")
if len(modules) and "" not in modules:
filter["issue_module__module_id__in"] = modules
else:
if params.get("module", None) and len(params.get("module")):
filter["issue_module__module_id__in"] = params.get("module")
return filter
def issue_filters(query_params, method): def issue_filters(query_params, method):
filter = dict() filter = dict()
@ -216,6 +249,9 @@ def issue_filters(query_params, method):
"target_date": filter_target_date, "target_date": filter_target_date,
"completed_at": filter_completed_at, "completed_at": filter_completed_at,
"type": filter_issue_state_type, "type": filter_issue_state_type,
"project": filter_project,
"cycle": filter_cycle,
"module": filter_module,
} }
for key, value in ISSUE_FILTER.items(): for key, value in ISSUE_FILTER.items():

View File

@ -1,6 +1,6 @@
# base requirements # base requirements
Django==3.2.18 Django==3.2.19
django-braces==1.15.0 django-braces==1.15.0
django-taggit==3.1.0 django-taggit==3.1.0
psycopg2==2.9.5 psycopg2==2.9.5

View File

@ -1,481 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Plane ✈️!</title>
<style type="text/css" emogrify="no">#outlook a { padding:0; } .ExternalClass { width:100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide:all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0; } img { outline:none; text-decoration:none; -ms-interpolation-mode: bicubic; } a img { border:none; } table { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} } </style>
<style type="text/css" emogrify="no">@media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} .r0-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 320px !important } .r1-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important } .r2-i { background-color: #ffffff !important } .r3-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important } .r4-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important } .r5-i { background-color: #f8f9fa !important; padding-bottom: 20px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 20px !important } .r6-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important } .r7-o { border-style: solid !important; width: 100% !important } .r8-i { padding-left: 0px !important; padding-right: 0px !important } .r9-i { padding-bottom: 15px !important; padding-top: 15px !important } .r10-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important } .r11-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important } .r12-i { padding-left: 20px !important; padding-right: 20px !important; padding-top: 0px !important; text-align: left !important } .r13-i { padding-bottom: 15px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 15px !important; text-align: left !important } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 15px !important; margin-top: 15px !important; width: 100% !important } .r15-i { text-align: center !important } .r16-r { background-color: #ffffff !important; border-color: #3f76ff !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r17-r { background-color: #ffffff !important; border-color: #000000 !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r18-i { background-color: #eff2f7 !important; padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important } .r19-i { color: #3b3f44 !important; padding-bottom: 0px !important; padding-top: 0px !important; text-align: center !important } .r20-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important } .r21-c { box-sizing: border-box !important; width: 100% !important } .r22-i { font-size: 0px !important; padding-bottom: 15px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 15px !important } .r23-c { box-sizing: border-box !important; width: 32px !important } .r24-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important } .r25-i { padding-bottom: 5px !important; padding-top: 5px !important } .r26-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important } .r27-i { color: #3b3f44 !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: center !important } .r28-i { padding-bottom: 15px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 0px !important } .r29-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 129px !important } .r30-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 129px !important } body { -webkit-text-size-adjust: none } .nl2go-responsive-hide { display: none } .nl2go-body-table { min-width: unset !important } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important } .resp-table { display: inline-table !important } .magic-resp { display: table-cell !important } } </style>
<!--[if !mso]><!-->
<style type="text/css" emogrify="no">@import url("https://fonts.googleapis.com/css2?family=Bitter&family=Roboto"); </style>
<!--<![endif]-->
<style type="text/css">p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #0092ff; text-decoration: underline } .nl2go-default-textstyle { color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5 } .default-button { border-radius: 4px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; width: 50% } .default-heading1 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 36px } .default-heading2 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 32px } .default-heading3 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 24px } .default-heading4 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 18px } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style type="text/css">a:link{color: #0092ff; text-decoration: underline;}</style>
</head>
<body bgcolor="#ffffff" text="#3b3f44" link="#0092ff" yahoo="fix" style="background-color: #ffffff; padding-bottom: 0px; padding-top: 0px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" class="nl2go-body-table" width="100%" style="background-color: #ffffff; width: 100%;">
<tr>
<td align="center" class="r0-c">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="600" class="r1-o" style="table-layout: fixed; width: 600px;">
<tr>
<td valign="top" class="r2-i" style="background-color: #ffffff;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r4-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;">­</td>
</tr>
<tr>
<td class="r5-i" style="background-color: #f8f9fa;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr>
<td valign="top" class="r8-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="120" class="r4-o" style="table-layout: fixed; width: 120px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td class="r9-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/Plane_Logo_pIhtbyIoa.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670873447444" width="120" border="0" class="" style="display: block; width: 100%;"></td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r12-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<h3 class="default-heading3" style="margin: 0; color: #1f2d3d; font-family: arial,helvetica,sans-serif; font-size: 24px;">Welcome to Plane!</h3>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;">We're thrilled you're here. We know this is the beginning of a long and exciting<br>journey, and we want to be there every step of the way.</p>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;"><strong>Plane is an open-source issue planning and tracking tool</strong> that allows teams to collaborate on projects and prioritize tasks. With Plane, you can easily create and assign issues, set deadlines, and track progress.</p>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;">We have put together some resources to help you get started. Please find them below:</p>
<p style="margin: 0;"> </p>
<ul style="margin: 0; margin-top:20px;">
<li><a href="https://docs.plane.so/quick-start" target="_blank" style="color: #0092ff; text-decoration: underline;">Getting started with Plane</a></li>
<li><a href="https://plane.so/changelog" target="_blank" style="color: #0092ff; text-decoration: underline;">Plane Changelog</a></li>
</ul>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="table-layout: fixed; width: 300px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
<w:anchorlock/>
<div style="display:none;">
<center class="default-button">
<p><span style="color:#3F76FF;font-size:14px;">Open Plane</span></p>
</center>
</div>
</v:roundrect>
<![endif]--> <!--[if !mso]><!-- -->
<a href="https://app.plane.so/" class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 5px; padding-right: 5px; padding-top: 7px; width: 288px;">
<p style="margin: 0;"><span style="color: #3F76FF; font-size: 14px;">Open Plane</span></p>
</a>
<!--<![endif]-->
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;">Also, if you like Plane, please consider starring us on GitHub. This helps us to grow our community and make Plane even better.</p>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="table-layout: fixed; width: 300px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://github.com/makeplane/plane" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#000000" strokeweight="1px" data-btn="2">
<w:anchorlock/>
<div style="display:none;">
<center class="default-button">
<p><span style="color:#000000;font-size:14px;">⭐ Star us on GitHub</span></p>
</center>
</div>
</v:roundrect>
<![endif]--> <!--[if !mso]><!-- -->
<a href="https://github.com/makeplane/plane" class="r17-r default-button" target="_blank" data-btn="2" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #000000; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 5px; padding-right: 5px; padding-top: 7px; width: 288px;">
<p style="margin: 0;"><span style="color: #000000; font-size: 14px;">⭐ Star us on GitHub</span></p>
</a>
<!--<![endif]-->
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
<div>
<p style="margin: 0;"><span style="font-size: 12px;">Note: Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our </span><a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">Discord</span></a><span style="font-size: 12px;"> or </span><a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">GitHub</span></a><span style="font-size: 12px;">, and we will use your feedback to improve on our upcoming releases.</span></p>
</div>
</td>
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="20" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</th>
</tr>
</table>
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r4-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;">­</td>
</tr>
<tr>
<td class="r18-i" style="background-color: #eff2f7;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
<td valign="top" class="r8-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r3-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="57" class="r4-o" style="table-layout: fixed; width: 57px;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td class="r9-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/115727700_n9t8rrnwT.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670872429989" width="57" border="0" class="" style="display: block; width: 100%;"></td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr>
<td align="center" valign="top" class="r19-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
<div>
<p style="margin: 0; font-size: 14px;">Proudly made on <strong>Planet Earth 🌍</strong>.</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r20-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r4-o" style="table-layout: fixed; width: 570px;">
<!-- -->
<tr>
<td valign="top" class="">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td class="r21-c" style="display: inline-block;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r7-o" style="table-layout: fixed; width: 570px;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
<tr>
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;">­ </td>
<td class="r22-i">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/github_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/discord_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
<tr>
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://twitter.com/planepowers" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/twitter_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
<td height="5" width="8" style="font-size: 5px; line-height: 5px;">­ </td>
</tr>
</table>
</th>
<th width="32" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style="table-layout: fixed; width: 100%;">
<!-- -->
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
</tr>
<tr>
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/linkedin_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="5" style="font-size: 5px; line-height: 5px;">­</td>
</tr>
</table>
</th>
</tr>
</table>
</td>
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
<td height="15" width="209" style="font-size: 15px; line-height: 15px;">­ </td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="r10-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
<tr>
<td align="center" valign="top" class="r27-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
<div>
<p style="margin: 0; font-size: 14px;"><a href="{{ mirror }}" style="color: #0092ff; text-decoration: underline;">View in browser</a> | <a href="{{ unsubscribe }}" style="color: #0092ff; text-decoration: underline;">Unsubscribe</a></p>
</div>
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="15" style="font-size: 15px; line-height: 15px;">­</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
</th>
</tr>
</table>
</td>
</tr>
<tr class="nl2go-responsive-hide">
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;">­</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,4 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
Your Export is ready
</html>

View File

@ -3,6 +3,7 @@ RUN apk add --no-cache libc6-compat
RUN apk update RUN apk update
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo RUN yarn global add turbo
COPY . . COPY . .
@ -12,10 +13,10 @@ RUN turbo prune --scope=app --docker
# Add lockfile and package.json's of isolated subworkspace # Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
RUN apk update RUN apk update
WORKDIR /app WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
# First install the dependencies (as they change less often) # First install the dependencies (as they change less often)
COPY .gitignore .gitignore COPY .gitignore .gitignore
@ -26,9 +27,17 @@ RUN yarn install
# Build the project # Build the project
COPY --from=builder /app/out/full/ . COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build --filter=app RUN yarn turbo run build --filter=app
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL}
FROM node:18-alpine AS runner FROM node:18-alpine AS runner
WORKDIR /app WORKDIR /app
@ -43,8 +52,20 @@ COPY --from=installer /app/apps/app/package.json .
# Automatically leverage output traces to reduce image size # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./ COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
# COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone/node_modules ./apps/app/node_modules
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static COPY --from=installer --chown=captain:plane /app/apps/app/.next ./apps/app/.next
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
USER root
COPY replace-env-vars.sh /usr/local/bin/
COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh
USER captain
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED 1

View File

@ -0,0 +1,158 @@
import React from "react";
import { useRouter } from "next/router";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import analyticsService from "services/analytics.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
// types
import { IAnalyticsParams, ISaveAnalyticsFormData } from "types";
// types
type Props = {
isOpen: boolean;
handleClose: () => void;
params?: IAnalyticsParams;
};
type FormValues = {
name: string;
description: string;
};
const defaultValues: FormValues = {
name: "",
description: "",
};
export const CreateUpdateAnalyticsModal: React.FC<Props> = ({ isOpen, handleClose, params }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm<FormValues>({
defaultValues,
});
const onClose = () => {
handleClose();
reset(defaultValues);
};
const onSubmit = async (formData: FormValues) => {
if (!workspaceSlug) return;
const payload: ISaveAnalyticsFormData = {
name: formData.name,
description: formData.description,
query_dict: {
x_axis: "priority",
y_axis: "issue_count",
...params,
project: params?.project ?? [],
},
};
await analyticsService
.saveAnalytics(workspaceSlug.toString(), payload)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Analytics saved successfully.",
});
onClose();
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Analytics could not be saved. Please try again.",
})
);
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={onClose}>
<Transition.Child
as={React.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-brand-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg border border-brand-base bg-brand-base px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base">
Save Analytics
</Dialog.Title>
<div className="mt-5">
<Input
type="text"
id="name"
name="name"
placeholder="Title"
autoComplete="off"
error={errors.name}
register={register}
width="full"
validations={{
required: "Title is required",
}}
/>
<TextArea
id="description"
name="description"
placeholder="Description"
className="mt-3 h-32 resize-none text-sm"
error={errors.description}
register={register}
/>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Analytics"}
</PrimaryButton>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,130 @@
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { Control, UseFormSetValue } from "react-hook-form";
// hooks
import useProjects from "hooks/use-projects";
// components
import {
AnalyticsGraph,
AnalyticsSelectBar,
AnalyticsSidebar,
AnalyticsTable,
} from "components/analytics";
// ui
import { Loader, PrimaryButton } from "components/ui";
// helpers
import { convertResponseToBarGraphData } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
type Props = {
analytics: IAnalyticsResponse | undefined;
analyticsError: any;
params: IAnalyticsParams;
control: Control<IAnalyticsParams, any>;
setValue: UseFormSetValue<IAnalyticsParams>;
fullScreen: boolean;
};
export const CustomAnalytics: React.FC<Props> = ({
analytics,
analyticsError,
params,
control,
setValue,
fullScreen,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const isProjectLevel = projectId ? true : false;
const yAxisKey = params.y_axis === "issue_count" ? "count" : "estimate";
const barGraphData = convertResponseToBarGraphData(analytics?.distribution, params);
const { projects } = useProjects();
return (
<div
className={`overflow-hidden flex flex-col-reverse ${
fullScreen ? "md:grid md:grid-cols-4 md:h-full" : ""
}`}
>
<div className="col-span-3 flex flex-col h-full overflow-hidden">
<AnalyticsSelectBar
control={control}
setValue={setValue}
projects={projects}
params={params}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
/>
{!analyticsError ? (
analytics ? (
analytics.total > 0 ? (
<div className="h-full overflow-y-auto">
<AnalyticsGraph
analytics={analytics}
barGraphData={barGraphData}
params={params}
yAxisKey={yAxisKey}
fullScreen={fullScreen}
/>
<AnalyticsTable
analytics={analytics}
barGraphData={barGraphData}
params={params}
yAxisKey={yAxisKey}
/>
</div>
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-brand-secondary">
<p className="text-sm">No matching issues found. Try changing the parameters.</p>
</div>
</div>
)
) : (
<Loader className="space-y-6 p-5">
<Loader.Item height="300px" />
<Loader className="space-y-4">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
</Loader>
)
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-brand-secondary">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<PrimaryButton
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
Refresh
</PrimaryButton>
</div>
</div>
</div>
)}
</div>
<AnalyticsSidebar
analytics={analytics}
params={params}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
/>
</div>
);
};

View File

@ -0,0 +1,57 @@
// nivo
import { BarTooltipProps } from "@nivo/bar";
import { DATE_KEYS } from "constants/analytics";
import { renderMonthAndYear } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
type Props = {
datum: BarTooltipProps<any>;
analytics: IAnalyticsResponse;
params: IAnalyticsParams;
};
export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) => {
let tooltipValue: string | number = "";
if (params.segment) {
if (DATE_KEYS.includes(params.segment)) tooltipValue = renderMonthAndYear(datum.id);
else if (params.segment === "assignees__email") {
const assignee = analytics.extras.assignee_details.find(
(a) => a.assignees__email === datum.id
);
if (assignee)
tooltipValue = assignee.assignees__first_name + " " + assignee.assignees__last_name;
else tooltipValue = "No assignees";
} else tooltipValue = datum.id;
} else {
if (DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue;
else tooltipValue = datum.id === "count" ? "Issue count" : "Estimate";
}
return (
<div className="flex items-center gap-2 rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: datum.color,
}}
/>
<span
className={`font-medium text-brand-secondary ${
params.segment
? params.segment === "priority" || params.segment === "state__group"
? "capitalize"
: ""
: params.x_axis === "priority" || params.x_axis === "state__group"
? "capitalize"
: ""
}`}
>
{tooltipValue}:
</span>
<span>{datum.value}</span>
</div>
);
};

View File

@ -0,0 +1,119 @@
// nivo
import { BarDatum } from "@nivo/bar";
// components
import { CustomTooltip } from "./custom-tooltip";
// ui
import { BarGraph } from "components/ui";
// helpers
import { findStringWithMostCharacters } from "helpers/array.helper";
import { generateBarColor } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
// constants
type Props = {
analytics: IAnalyticsResponse;
barGraphData: {
data: BarDatum[];
xAxisKeys: string[];
};
params: IAnalyticsParams;
yAxisKey: "count" | "estimate";
fullScreen: boolean;
};
export const AnalyticsGraph: React.FC<Props> = ({
analytics,
barGraphData,
params,
yAxisKey,
fullScreen,
}) => {
const generateYAxisTickValues = () => {
if (!analytics) return [];
let data: number[] = [];
if (params.segment)
// find the total no of issues in each segment
data = Object.keys(analytics.distribution).map((segment) => {
let totalSegmentIssues = 0;
analytics.distribution[segment].map((s) => {
totalSegmentIssues += s[yAxisKey] as number;
});
return totalSegmentIssues;
});
else data = barGraphData.data.map((d) => d[yAxisKey] as number);
return data;
};
const longestXAxisLabel = findStringWithMostCharacters(barGraphData.data.map((d) => `${d.name}`));
return (
<BarGraph
data={barGraphData.data}
indexBy="name"
keys={barGraphData.xAxisKeys}
colors={(datum) =>
generateBarColor(
params.segment ? `${datum.id}` : `${datum.indexValue}`,
analytics,
params,
params.segment ? "segment" : "x_axis"
)
}
customYAxisTickValues={generateYAxisTickValues()}
tooltip={(datum) => <CustomTooltip datum={datum} analytics={analytics} params={params} />}
height={fullScreen ? "400px" : "300px"}
margin={{
right: 20,
bottom: params.x_axis === "assignees__email" ? 50 : longestXAxisLabel.length * 5 + 20,
}}
axisBottom={{
tickSize: 0,
tickPadding: 10,
tickRotation: barGraphData.data.length > 7 ? -45 : 0,
renderTick:
params.x_axis === "assignees__email"
? (datum) => {
const avatar = analytics.extras.assignee_details?.find(
(a) => a?.assignees__email === datum?.value
)?.assignees__avatar;
if (avatar && avatar !== "")
return (
<g transform={`translate(${datum.x},${datum.y})`}>
<image
x={-8}
y={10}
width={16}
height={16}
xlinkHref={avatar}
style={{ clipPath: "circle(50%)" }}
/>
</g>
);
else
return (
<g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{datum.value && datum.value !== "None"
? `${datum.value}`.toUpperCase()[0]
: "?"}
</text>
</g>
);
}
: undefined,
}}
theme={{
background: "rgb(var(--color-bg-base))",
axis: {},
}}
/>
);
};

View File

@ -0,0 +1,6 @@
export * from "./graph";
export * from "./create-update-analytics-modal";
export * from "./custom-analytics";
export * from "./select-bar";
export * from "./sidebar";
export * from "./table";

View File

@ -0,0 +1,80 @@
// react-hook-form
import { Control, Controller, UseFormSetValue } from "react-hook-form";
// components
import { SelectProject, SelectSegment, SelectXAxis, SelectYAxis } from "components/analytics";
// types
import { IAnalyticsParams, IProject } from "types";
type Props = {
control: Control<IAnalyticsParams, any>;
setValue: UseFormSetValue<IAnalyticsParams>;
projects: IProject[];
params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean;
};
export const AnalyticsSelectBar: React.FC<Props> = ({
control,
setValue,
projects,
params,
fullScreen,
isProjectLevel,
}) => (
<div
className={`grid items-center gap-4 px-5 py-2.5 ${
isProjectLevel ? "grid-cols-3" : "grid-cols-2"
} ${fullScreen ? "lg:grid-cols-4 md:py-5" : ""}`}
>
{!isProjectLevel && (
<div>
<h6 className="text-xs text-brand-secondary">Project</h6>
<Controller
name="project"
control={control}
render={({ field: { value, onChange } }) => (
<SelectProject value={value} onChange={onChange} projects={projects} />
)}
/>
</div>
)}
<div>
<h6 className="text-xs text-brand-secondary">Measure (y-axis)</h6>
<Controller
name="y_axis"
control={control}
render={({ field: { value, onChange } }) => (
<SelectYAxis value={value} onChange={onChange} />
)}
/>
</div>
<div>
<h6 className="text-xs text-brand-secondary">Dimension (x-axis)</h6>
<Controller
name="x_axis"
control={control}
render={({ field: { value, onChange } }) => (
<SelectXAxis
value={value}
onChange={(val: string) => {
if (params.segment === val) setValue("segment", null);
onChange(val);
}}
/>
)}
/>
</div>
<div>
<h6 className="text-xs text-brand-secondary">Group</h6>
<Controller
name="segment"
control={control}
render={({ field: { value, onChange } }) => (
<SelectSegment value={value} onChange={onChange} params={params} />
)}
/>
</div>
</div>
);

View File

@ -0,0 +1,331 @@
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// services
import analyticsService from "services/analytics.service";
import projectService from "services/project.service";
import cyclesService from "services/cycles.service";
import modulesService from "services/modules.service";
// hooks
import useProjects from "hooks/use-projects";
import useToast from "hooks/use-toast";
// ui
import { PrimaryButton, SecondaryButton } from "components/ui";
// icons
import {
ArrowDownTrayIcon,
ArrowPathIcon,
CalendarDaysIcon,
UserGroupIcon,
} from "@heroicons/react/24/outline";
import { ContrastIcon, LayerDiagonalIcon } from "components/icons";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IProject } from "types";
// fetch-keys
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
// constants
import { NETWORK_CHOICES } from "constants/project";
type Props = {
analytics: IAnalyticsResponse | undefined;
params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean;
};
export const AnalyticsSidebar: React.FC<Props> = ({
analytics,
params,
fullScreen,
isProjectLevel = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { projects } = useProjects();
const { setToastAlert } = useToast();
const { data: projectDetails } = useSWR(
workspaceSlug && projectId && !(cycleId || moduleId)
? PROJECT_DETAILS(projectId.toString())
: null,
workspaceSlug && projectId && !(cycleId || moduleId)
? () => projectService.getProject(workspaceSlug.toString(), projectId.toString())
: null
);
const { data: cycleDetails } = useSWR(
workspaceSlug && projectId && cycleId ? CYCLE_DETAILS(cycleId.toString()) : null,
workspaceSlug && projectId && cycleId
? () =>
cyclesService.getCycleDetails(
workspaceSlug.toString(),
projectId.toString(),
cycleId.toString()
)
: null
);
const { data: moduleDetails } = useSWR(
workspaceSlug && projectId && moduleId ? MODULE_DETAILS(moduleId.toString()) : null,
workspaceSlug && projectId && moduleId
? () =>
modulesService.getModuleDetails(
workspaceSlug.toString(),
projectId.toString(),
moduleId.toString()
)
: null
);
const exportAnalytics = () => {
if (!workspaceSlug) return;
const data: IExportAnalyticsFormData = {
x_axis: params.x_axis,
y_axis: params.y_axis,
};
if (params.segment) data.segment = params.segment;
if (params.project) data.project = params.project;
analyticsService
.exportAnalytics(workspaceSlug.toString(), data)
.then((res) =>
setToastAlert({
type: "success",
title: "Success!",
message: res.message,
})
)
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "There was some error in exporting the analytics. Please try again.",
})
);
};
const selectedProjects =
params.project && params.project.length > 0 ? params.project : projects.map((p) => p.id);
return (
<div
className={`px-5 py-2.5 flex items-center justify-between space-y-2 ${
fullScreen
? "border-l border-brand-base md:h-full md:border-l md:border-brand-base md:space-y-4 overflow-hidden md:flex-col md:items-start md:py-5"
: ""
}`}
>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 bg-brand-surface-2 rounded-md px-3 py-1 text-brand-secondary text-xs">
<LayerDiagonalIcon height={14} width={14} />
{analytics ? analytics.total : "..."} Issues
</div>
{isProjectLevel && (
<div className="flex items-center gap-1 bg-brand-surface-2 rounded-md px-3 py-1 text-brand-secondary text-xs">
<CalendarDaysIcon className="h-3.5 w-3.5" />
{renderShortDate(
(cycleId
? cycleDetails?.created_at
: moduleId
? moduleDetails?.created_at
: projectDetails?.created_at) ?? ""
)}
</div>
)}
</div>
<div className="h-full overflow-hidden">
{fullScreen ? (
<>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<div className="hidden h-full overflow-hidden md:flex md:flex-col">
<h4 className="font-medium">Selected Projects</h4>
<div className="space-y-6 mt-4 h-full overflow-y-auto">
{selectedProjects.map((projectId) => {
const project: IProject = projects.find((p) => p.id === projectId);
return (
<div key={project.id}>
<div className="text-sm flex items-center gap-1">
{project.emoji ? (
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">
{String.fromCodePoint(parseInt(project.emoji))}
</span>
) : project.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
<span
style={{ color: project.icon_prop.color }}
className="material-symbols-rounded text-lg"
>
{project.icon_prop.name}
</span>
</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{project?.name.charAt(0)}
</span>
)}
<h5 className="break-all">
{project.name}
<span className="text-brand-secondary text-xs ml-1">
({project.identifier})
</span>
</h5>
</div>
<div className="mt-4 space-y-3 pl-2">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
<h6>Total members</h6>
</div>
<span className="text-brand-secondary">{project.total_members}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<ContrastIcon height={16} width={16} />
<h6>Total cycles</h6>
</div>
<span className="text-brand-secondary">{project.total_cycles}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
<h6>Total modules</h6>
</div>
<span className="text-brand-secondary">{project.total_modules}</span>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{projectId ? (
cycleId && cycleDetails ? (
<div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-all">Analytics for {cycleDetails.name}</h4>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Lead</h6>
<span>
{cycleDetails.owned_by?.first_name} {cycleDetails.owned_by?.last_name}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Start Date</h6>
<span>
{cycleDetails.start_date && cycleDetails.start_date !== ""
? renderShortDate(cycleDetails.start_date)
: "No start date"}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Target Date</h6>
<span>
{cycleDetails.end_date && cycleDetails.end_date !== ""
? renderShortDate(cycleDetails.end_date)
: "No end date"}
</span>
</div>
</div>
</div>
) : moduleId && moduleDetails ? (
<div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-all">Analytics for {moduleDetails.name}</h4>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Lead</h6>
<span>
{moduleDetails.lead_detail?.first_name}{" "}
{moduleDetails.lead_detail?.last_name}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Start Date</h6>
<span>
{moduleDetails.start_date && moduleDetails.start_date !== ""
? renderShortDate(moduleDetails.start_date)
: "No start date"}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Target Date</h6>
<span>
{moduleDetails.target_date && moduleDetails.target_date !== ""
? renderShortDate(moduleDetails.target_date)
: "No end date"}
</span>
</div>
</div>
</div>
) : (
<div className="hidden md:flex md:flex-col h-full overflow-y-auto">
<div className="flex items-center gap-1">
{projectDetails?.emoji ? (
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">
{String.fromCodePoint(parseInt(projectDetails.emoji))}
</div>
) : projectDetails?.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
<span
style={{ color: projectDetails.icon_prop.color }}
className="material-symbols-rounded text-lg"
>
{projectDetails.icon_prop.name}
</span>
</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{projectDetails?.name.charAt(0)}
</span>
)}
<h4 className="font-medium break-all">{projectDetails?.name}</h4>
</div>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Network</h6>
<span>
{
NETWORK_CHOICES[
`${projectDetails?.network}` as keyof typeof NETWORK_CHOICES
]
}
</span>
</div>
</div>
</div>
)
) : null}
</>
) : null}
</div>
<div className="flex items-center gap-2 flex-wrap justify-self-end">
<SecondaryButton
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
<div className="flex items-center gap-2 -my-1">
<ArrowPathIcon className="h-3.5 w-3.5" />
Refresh
</div>
</SecondaryButton>
<PrimaryButton onClick={exportAnalytics}>
<div className="flex items-center gap-2 -my-1">
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
Export as CSV
</div>
</PrimaryButton>
</div>
</div>
);
};

View File

@ -0,0 +1,135 @@
// nivo
import { BarDatum } from "@nivo/bar";
// icons
import { getPriorityIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// helpers
import { generateBarColor, renderMonthAndYear } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
// constants
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, DATE_KEYS } from "constants/analytics";
type Props = {
analytics: IAnalyticsResponse;
barGraphData: {
data: BarDatum[];
xAxisKeys: string[];
};
params: IAnalyticsParams;
yAxisKey: "count" | "estimate";
};
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => {
const renderAssigneeName = (email: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__email === email);
if (!assignee) return "No assignee";
if (assignee.assignees__first_name !== "")
return assignee.assignees__first_name + " " + assignee.assignees__last_name;
return email;
};
return (
<div className="flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<table className="min-w-full divide-y divide-brand-base whitespace-nowrap border-y border-brand-base">
<thead className="bg-brand-surface-2">
<tr className="divide-x divide-brand-base text-sm text-brand-base">
<th scope="col" className="py-3 px-2.5 text-left font-medium">
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === params.x_axis)?.label}
</th>
{params.segment ? (
barGraphData.xAxisKeys.map((key) => (
<th
key={`segment-${key}`}
scope="col"
className={`px-2.5 py-3 text-left font-medium ${
params.segment === "priority" || params.segment === "state__group"
? "capitalize"
: ""
}`}
>
<div className="flex items-center gap-2">
{params.segment === "priority" ? (
getPriorityIcon(key)
) : (
<span
className="h-3 w-3 flex-shrink-0 rounded"
style={{
backgroundColor: generateBarColor(key, analytics, params, "segment"),
}}
/>
)}
{DATE_KEYS.includes(params.segment ?? "")
? renderMonthAndYear(key)
: params.segment === "assignees__email"
? renderAssigneeName(key)
: key}
</div>
</th>
))
) : (
<th scope="col" className="py-3 px-2.5 text-left font-medium sm:pr-0">
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === params.y_axis)?.label}
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-brand-base">
{barGraphData.data.map((item, index) => (
<tr
key={`table-row-${index}`}
className="divide-x divide-brand-base text-xs text-brand-secondary"
>
<td
className={`flex items-center gap-2 whitespace-nowrap py-2 px-2.5 font-medium ${
params.x_axis === "priority" || params.x_axis === "state__group"
? "capitalize"
: ""
}`}
>
{params.x_axis === "priority" ? (
getPriorityIcon(`${item.name}`)
) : (
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: generateBarColor(
`${item.name}`,
analytics,
params,
"x_axis"
),
}}
/>
)}
{params.x_axis === "assignees__email"
? renderAssigneeName(`${item.name}`)
: addSpaceIfCamelCase(`${item.name}`)}
</td>
{params.segment ? (
barGraphData.xAxisKeys.map((key, index) => (
<td
key={`segment-value-${index}`}
className="whitespace-nowrap py-2 px-2.5 sm:pr-0"
>
{item[key] ?? 0}
</td>
))
) : (
<td className="whitespace-nowrap py-2 px-2.5 sm:pr-0">{item[yAxisKey]}</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,4 @@
export * from "./custom-analytics";
export * from "./scope-and-demand";
export * from "./select";
export * from "./project-modal";

View File

@ -0,0 +1,174 @@
import React, { Fragment, useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Tab } from "@headlessui/react";
// services
import analyticsService from "services/analytics.service";
import projectService from "services/project.service";
import cyclesService from "services/cycles.service";
import modulesService from "services/modules.service";
// components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
// icons
import {
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
// types
import { IAnalyticsParams } from "types";
// fetch-keys
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
onClose: () => void;
};
const defaultValues: IAnalyticsParams = {
x_axis: "priority",
y_axis: "issue_count",
segment: null,
project: null,
};
const tabsList = ["Scope and Demand", "Custom Analytics"];
export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
const [fullScreen, setFullScreen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues });
const params: IAnalyticsParams = {
x_axis: watch("x_axis"),
y_axis: watch("y_axis"),
segment: watch("segment"),
project: projectId ? [projectId.toString()] : watch("project"),
cycle: cycleId ? cycleId.toString() : null,
module: moduleId ? moduleId.toString() : null,
};
const { data: analytics, error: analyticsError } = useSWR(
workspaceSlug ? ANALYTICS(workspaceSlug.toString(), params) : null,
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
);
const { data: projectDetails } = useSWR(
workspaceSlug && projectId && !(cycleId || moduleId)
? PROJECT_DETAILS(projectId.toString())
: null,
workspaceSlug && projectId && !(cycleId || moduleId)
? () => projectService.getProject(workspaceSlug.toString(), projectId.toString())
: null
);
const { data: cycleDetails } = useSWR(
workspaceSlug && projectId && cycleId ? CYCLE_DETAILS(cycleId.toString()) : null,
workspaceSlug && projectId && cycleId
? () =>
cyclesService.getCycleDetails(
workspaceSlug.toString(),
projectId.toString(),
cycleId.toString()
)
: null
);
const { data: moduleDetails } = useSWR(
workspaceSlug && projectId && moduleId ? MODULE_DETAILS(moduleId.toString()) : null,
workspaceSlug && projectId && moduleId
? () =>
modulesService.getModuleDetails(
workspaceSlug.toString(),
projectId.toString(),
moduleId.toString()
)
: null
);
const handleClose = () => {
onClose();
};
return (
<div
className={`absolute top-0 z-30 h-full bg-brand-surface-1 ${
fullScreen ? "p-2 w-full" : "w-1/2"
} ${isOpen ? "right-0" : "-right-full"} duration-300 transition-all`}
>
<div
className={`flex h-full flex-col overflow-hidden border-brand-base bg-brand-base text-left ${
fullScreen ? "rounded-lg border" : "border-l"
}`}
>
<div className="flex items-center justify-between gap-4 bg-brand-base px-5 py-4 text-sm">
<h3 className="break-all">
Analytics for{" "}
{cycleId ? cycleDetails?.name : moduleId ? moduleDetails?.name : projectDetails?.name}
</h3>
<div className="flex items-center gap-2">
<button
type="button"
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
onClick={() => setFullScreen((prevData) => !prevData)}
>
{fullScreen ? (
<ArrowsPointingInIcon className="h-4 w-4" />
) : (
<ArrowsPointingOutIcon className="h-3 w-3" />
)}
</button>
<button
type="button"
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
onClick={handleClose}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
<Tab.Group as={Fragment}>
<Tab.List as="div" className="space-x-2 border-b border-brand-base p-5 pt-0">
{tabsList.map((tab) => (
<Tab
key={tab}
className={({ selected }) =>
`rounded-3xl border border-brand-base px-4 py-2 text-xs hover:bg-brand-surface-2 ${
selected ? "bg-brand-surface-2" : ""
}`
}
>
{tab}
</Tab>
))}
</Tab.List>
{/* <h4 className="p-5 pb-0">Analytics for</h4> */}
<Tab.Panels as={Fragment}>
<Tab.Panel as={Fragment}>
<ScopeAndDemand fullScreen={fullScreen} />
</Tab.Panel>
<Tab.Panel as={Fragment}>
<CustomAnalytics
analytics={analytics}
analyticsError={analyticsError}
params={params}
control={control}
setValue={setValue}
fullScreen={fullScreen}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</div>
);
};

View File

@ -0,0 +1,63 @@
// icons
import { PlayIcon } from "@heroicons/react/24/outline";
// types
import { IDefaultAnalyticsResponse } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
type Props = {
defaultAnalytics: IDefaultAnalyticsResponse;
};
export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
<div className="space-y-3 rounded-[10px] border border-brand-base p-3">
<h5 className="text-xs text-red-500">DEMAND</h5>
<div>
<h4 className="text-brand-bas text-base font-medium">Total open tasks</h4>
<h3 className="mt-1 text-xl font-semibold">{defaultAnalytics.open_issues}</h3>
</div>
<div className="space-y-6">
{defaultAnalytics?.open_issues_classified.map((group) => {
const percentage = ((group.state_count / defaultAnalytics.total_issues) * 100).toFixed(0);
return (
<div key={group.state_group} className="space-y-2">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-1">
<span
className="h-2 w-2 rounded-full"
style={{
backgroundColor: STATE_GROUP_COLORS[group.state_group],
}}
/>
<h6 className="capitalize">{group.state_group}</h6>
<span className="ml-1 rounded-3xl bg-brand-surface-2 px-2 py-0.5 text-[0.65rem] text-brand-secondary">
{group.state_count}
</span>
</div>
<p className="text-brand-secondary">{percentage}%</p>
</div>
<div className="bar relative h-1 w-full rounded bg-brand-surface-2">
<div
className="absolute top-0 left-0 h-1 rounded duration-300"
style={{
width: `${percentage}%`,
backgroundColor: STATE_GROUP_COLORS[group.state_group],
}}
/>
</div>
</div>
);
})}
</div>
<div className="!mt-6 flex w-min items-center gap-2 whitespace-nowrap rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
<p className="flex items-center gap-1 text-brand-secondary">
<PlayIcon className="h-4 w-4 -rotate-90" aria-hidden="true" />
<span>Estimate Demand:</span>
</p>
<p className="font-medium">
{defaultAnalytics.open_estimate_sum}/{defaultAnalytics.total_estimate_sum}
</p>
</div>
</div>
);

View File

@ -0,0 +1,5 @@
export * from "./demand";
export * from "./leaderboard";
export * from "./scope-and-demand";
export * from "./scope";
export * from "./year-wise-issues";

View File

@ -0,0 +1,52 @@
import Image from "next/image";
type Props = {
users: {
avatar: string | null;
email: string | null;
firstName: string;
lastName: string;
count: number;
}[];
title: string;
};
export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
<div className="p-3 border border-brand-base rounded-[10px]">
<h6 className="text-base font-medium">{title}</h6>
{users.length > 0 ? (
<div className="mt-3 space-y-3">
{users.map((user) => (
<div
key={user.email ?? "None"}
className="flex items-start justify-between gap-4 text-xs"
>
<div className="flex items-center gap-2">
{user && user.avatar && user.avatar !== "" ? (
<div className="rounded-full h-4 w-4 flex-shrink-0">
<Image
src={user.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={user.email ?? "None"}
/>
</div>
) : (
<div className="grid place-items-center flex-shrink-0 rounded-full bg-gray-700 text-[11px] capitalize text-white h-4 w-4">
{user.firstName !== "" ? user.firstName[0] : "?"}
</div>
)}
<span className="break-all text-brand-secondary">
{user.firstName !== "" ? `${user.firstName} ${user.lastName}` : "No assignee"}
</span>
</div>
<span className="flex-shrink-0">{user.count}</span>
</div>
))}
</div>
) : (
<div className="text-brand-secondary text-center text-sm py-8">No matching data found.</div>
)}
</div>
);

View File

@ -0,0 +1,101 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import analyticsService from "services/analytics.service";
// components
import {
AnalyticsDemand,
AnalyticsLeaderboard,
AnalyticsScope,
AnalyticsYearWiseIssues,
} from "components/analytics";
// ui
import { Loader, PrimaryButton } from "components/ui";
// fetch-keys
import { DEFAULT_ANALYTICS } from "constants/fetch-keys";
type Props = {
fullScreen?: boolean;
};
export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const isProjectLevel = projectId ? true : false;
const params = isProjectLevel
? {
project: projectId ? [projectId.toString()] : null,
cycle: cycleId ? cycleId.toString() : null,
module: moduleId ? moduleId.toString() : null,
}
: undefined;
const {
data: defaultAnalytics,
error: defaultAnalyticsError,
mutate: mutateDefaultAnalytics,
} = useSWR(
workspaceSlug ? DEFAULT_ANALYTICS(workspaceSlug.toString(), params) : null,
workspaceSlug
? () => analyticsService.getDefaultAnalytics(workspaceSlug.toString(), params)
: null
);
return (
<>
{!defaultAnalyticsError ? (
defaultAnalytics ? (
<div className="h-full overflow-y-auto p-5 text-sm">
<div className={`grid grid-cols-1 gap-5 ${fullScreen ? "md:grid-cols-2" : ""}`}>
<AnalyticsDemand defaultAnalytics={defaultAnalytics} />
<AnalyticsScope defaultAnalytics={defaultAnalytics} />
<AnalyticsLeaderboard
users={defaultAnalytics.most_issue_created_user?.map((user) => ({
avatar: user?.created_by__avatar,
email: user?.created_by__email,
firstName: user?.created_by__first_name,
lastName: user?.created_by__last_name,
count: user?.count,
}))}
title="Most issues created"
/>
<AnalyticsLeaderboard
users={defaultAnalytics.most_issue_closed_user?.map((user) => ({
avatar: user?.assignees__avatar,
email: user?.assignees__email,
firstName: user?.assignees__first_name,
lastName: user?.assignees__last_name,
count: user?.count,
}))}
title="Most issues closed"
/>
<div className={fullScreen ? "md:col-span-2" : ""}>
<AnalyticsYearWiseIssues defaultAnalytics={defaultAnalytics} />
</div>
</div>
</div>
) : (
<Loader className="grid grid-cols-1 gap-5 p-5 lg:grid-cols-2">
<Loader.Item height="250px" />
<Loader.Item height="250px" />
<Loader.Item height="250px" />
<Loader.Item height="250px" />
</Loader>
)
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-brand-secondary">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<PrimaryButton onClick={() => mutateDefaultAnalytics()}>Refresh</PrimaryButton>
</div>
</div>
</div>
)}
</>
);
};

View File

@ -0,0 +1,84 @@
// ui
import { BarGraph } from "components/ui";
// types
import { IDefaultAnalyticsResponse } from "types";
type Props = {
defaultAnalytics: IDefaultAnalyticsResponse;
};
export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
<div className="rounded-[10px] border border-brand-base">
<h5 className="p-3 text-xs text-green-500">SCOPE</h5>
<div className="divide-y divide-brand-base">
<div>
<h6 className="px-3 text-base font-medium">Pending issues</h6>
{defaultAnalytics.pending_issue_user.length > 0 ? (
<BarGraph
data={defaultAnalytics.pending_issue_user}
indexBy="assignees__email"
keys={["count"]}
height="250px"
colors={() => `#f97316`}
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)}
tooltip={(datum) => {
const assignee = defaultAnalytics.pending_issue_user.find(
(a) => a.assignees__email === `${datum.indexValue}`
);
return (
<div className="rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
<span className="font-medium text-brand-secondary">
{assignee
? assignee.assignees__first_name + " " + assignee.assignees__last_name
: "No assignee"}
:{" "}
</span>
{datum.value}
</div>
);
}}
axisBottom={{
renderTick: (datum) => {
const avatar =
defaultAnalytics.pending_issue_user[datum.tickIndex]?.assignees__avatar ?? "";
if (avatar && avatar !== "")
return (
<g transform={`translate(${datum.x},${datum.y})`}>
<image
x={-8}
y={10}
width={16}
height={16}
xlinkHref={avatar}
style={{ clipPath: "circle(50%)" }}
/>
</g>
);
else
return (
<g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{datum.value ? `${datum.value}`.toUpperCase()[0] : "?"}
</text>
</g>
);
},
}}
margin={{ top: 20 }}
theme={{
background: "rgb(var(--color-bg-base))",
axis: {},
}}
/>
) : (
<div className="text-brand-secondary text-center text-sm py-8">
No matching data found.
</div>
)}
</div>
</div>
</div>
);

View File

@ -0,0 +1,54 @@
// ui
import { LineGraph } from "components/ui";
// types
import { IDefaultAnalyticsResponse } from "types";
// constants
import { MONTHS_LIST } from "constants/calendar";
type Props = {
defaultAnalytics: IDefaultAnalyticsResponse;
};
export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) => {
const currentMonth = new Date().getMonth();
const startMonth = Math.floor(currentMonth / 3) * 3 + 1;
const quarterMonthsList = [startMonth, startMonth + 1, startMonth + 2];
return (
<div className="py-3 border border-brand-base rounded-[10px]">
<h1 className="px-3 text-base font-medium">Issues closed in a year</h1>
{defaultAnalytics.issue_completed_month_wise.length > 0 ? (
<LineGraph
data={[
{
id: "issues_closed",
color: "rgb(var(--color-accent))",
data: MONTHS_LIST.map((month) => ({
x: month.label.substring(0, 3),
y:
defaultAnalytics.issue_completed_month_wise.find(
(data) => data.month === month.value
)?.count || 0,
})),
},
]}
customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map((data) => {
if (quarterMonthsList.includes(data.month)) return data.count;
return 0;
})}
height="300px"
colors={(datum) => datum.color}
curve="monotoneX"
margin={{ top: 20 }}
theme={{
background: "rgb(var(--color-bg-base))",
}}
enableArea
/>
) : (
<div className="text-brand-secondary text-center text-sm py-8">No matching data found.</div>
)}
</div>
);
};

View File

@ -0,0 +1,4 @@
export * from "./project";
export * from "./segment";
export * from "./x-axis";
export * from "./y-axis";

View File

@ -0,0 +1,41 @@
// ui
import { CustomSearchSelect } from "components/ui";
// types
import { IProject } from "types";
type Props = {
value: string[] | null | undefined;
onChange: (val: string[] | null) => void;
projects: IProject[];
};
export const SelectProject: React.FC<Props> = ({ value, onChange, projects }) => {
const options = projects?.map((project) => ({
value: project.id,
query: project.name + project.identifier,
content: (
<div className="flex items-center gap-2">
<span className="text-brand-secondary text-[0.65rem]">{project.identifier}</span>
{project.name}
</div>
),
}));
return (
<CustomSearchSelect
value={value ?? []}
onChange={(val: string[]) => onChange(val)}
options={options}
label={
value && value.length > 0
? projects
.filter((p) => value.includes(p.id))
.map((p) => p.identifier)
.join(", ")
: "All projects"
}
optionsClassName="min-w-full"
multiple
/>
);
};

View File

@ -0,0 +1,48 @@
import { useRouter } from "next/router";
// ui
import { CustomSelect } from "components/ui";
// types
import { IAnalyticsParams, TXAxisValues } from "types";
// constants
import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics";
type Props = {
value: TXAxisValues | null | undefined;
onChange: () => void;
params: IAnalyticsParams;
};
export const SelectSegment: React.FC<Props> = ({ value, onChange, params }) => {
const router = useRouter();
const { cycleId, moduleId } = router.query;
return (
<CustomSelect
value={value}
label={
<span>
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label ?? (
<span className="text-brand-secondary">No value</span>
)}
</span>
}
onChange={onChange}
width="w-full"
maxHeight="lg"
>
<CustomSelect.Option value={null}>No value</CustomSelect.Option>
{ANALYTICS_X_AXIS_VALUES.map((item) => {
if (params.x_axis === item.value) return null;
if (cycleId && item.value === "issue_cycle__cycle__name") return null;
if (moduleId && item.value === "issue_module__module__name") return null;
return (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
);
})}
</CustomSelect>
);
};

View File

@ -0,0 +1,39 @@
import { useRouter } from "next/router";
// ui
import { CustomSelect } from "components/ui";
// types
import { TXAxisValues } from "types";
// constants
import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics";
type Props = {
value: TXAxisValues;
onChange: (val: string) => void;
};
export const SelectXAxis: React.FC<Props> = ({ value, onChange }) => {
const router = useRouter();
const { cycleId, moduleId } = router.query;
return (
<CustomSelect
value={value}
label={<span>{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label}</span>}
onChange={onChange}
width="w-full"
maxHeight="lg"
>
{ANALYTICS_X_AXIS_VALUES.map((item) => {
if (cycleId && item.value === "issue_cycle__cycle__name") return null;
if (moduleId && item.value === "issue_module__module__name") return null;
return (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
);
})}
</CustomSelect>
);
};

View File

@ -0,0 +1,26 @@
// ui
import { CustomSelect } from "components/ui";
// types
import { TYAxisValues } from "types";
// constants
import { ANALYTICS_Y_AXIS_VALUES } from "constants/analytics";
type Props = {
value: TYAxisValues;
onChange: () => void;
};
export const SelectYAxis: React.FC<Props> = ({ value, onChange }) => (
<CustomSelect
value={value}
label={<span>{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}</span>}
onChange={onChange}
width="w-full"
>
{ANALYTICS_Y_AXIS_VALUES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
);

View File

@ -27,7 +27,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
description: "You are not authorized to view this page", description: "You are not authorized to view this page",
}} }}
> >
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center"> <div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-brand-surface-1 text-center">
<div className="h-44 w-72"> <div className="h-44 w-72">
<Image <Image
src={type === "project" ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg} src={type === "project" ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg}
@ -36,7 +36,9 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
alt="ProjectSettingImg" alt="ProjectSettingImg"
/> />
</div> </div>
<h1 className="text-xl font-medium">Oops! You are not authorized to view this page</h1> <h1 className="text-xl font-medium text-brand-base">
Oops! You are not authorized to view this page
</h1>
<div className="w-full max-w-md text-base text-brand-secondary"> <div className="w-full max-w-md text-base text-brand-secondary">
{user ? ( {user ? (

View File

@ -41,11 +41,11 @@ export const JoinProject: React.FC = () => {
}; };
return ( return (
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center"> <div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-brand-surface-1 text-center">
<div className="h-44 w-72"> <div className="h-44 w-72">
<Image src={JoinProjectImg} height="176" width="288" alt="JoinProject" /> <Image src={JoinProjectImg} height="176" width="288" alt="JoinProject" />
</div> </div>
<h1 className="text-xl font-medium">You are not a member of this project</h1> <h1 className="text-xl font-medium text-brand-base">You are not a member of this project</h1>
<div className="w-full max-w-md text-base text-brand-secondary"> <div className="w-full max-w-md text-base text-brand-secondary">
<p className="mx-auto w-full text-sm md:w-3/4"> <p className="mx-auto w-full text-sm md:w-3/4">

View File

@ -821,7 +821,7 @@ export const CommandPalette: React.FC = () => {
> >
<div className="flex items-center gap-2 text-brand-secondary"> <div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" /> <SettingIcon className="h-4 w-4 text-brand-secondary" />
Billings and Plans Billing and Plans
</div> </div>
</Command.Item> </Command.Item>
<Command.Item <Command.Item
@ -839,7 +839,7 @@ export const CommandPalette: React.FC = () => {
> >
<div className="flex items-center gap-2 text-brand-secondary"> <div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" /> <SettingIcon className="h-4 w-4 text-brand-secondary" />
Import/Export Import/ Export
</div> </div>
</Command.Item> </Command.Item>
</> </>

View File

@ -44,7 +44,7 @@ export const AllBoards: React.FC<Props> = ({
return ( return (
<> <>
{groupedByIssues ? ( {groupedByIssues ? (
<div className="horizontal-scroll-enable flex h-[calc(100vh-140px)] gap-x-4"> <div className="horizontal-scroll-enable flex h-full gap-x-4 p-8">
{Object.keys(groupedByIssues).map((singleGroup, index) => { {Object.keys(groupedByIssues).map((singleGroup, index) => {
const currentState = const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null; selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -60,51 +60,46 @@ export const SingleBoard: React.FC<Props> = ({
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
useEffect(() => {
if (currentState?.group === "completed" || currentState?.group === "cancelled")
setIsCollapsed(false);
}, [currentState]);
return ( return (
<div className={`h-full flex-shrink-0 ${!isCollapsed ? "" : "w-96"}`}> <div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}>
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3"}`}> <BoardHeader
<BoardHeader addIssueToState={addIssueToState}
addIssueToState={addIssueToState} currentState={currentState}
currentState={currentState} groupTitle={groupTitle}
groupTitle={groupTitle} isCollapsed={isCollapsed}
isCollapsed={isCollapsed} setIsCollapsed={setIsCollapsed}
setIsCollapsed={setIsCollapsed} isCompleted={isCompleted}
isCompleted={isCompleted} />
/> {isCollapsed && (
{isCollapsed && ( <StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}> {(provided, snapshot) => (
{(provided, snapshot) => ( <div
<div className={`relative h-full ${
className={`relative h-full overflow-y-auto p-1 ${ orderBy !== "sort_order" && snapshot.isDraggingOver ? "bg-brand-base/20" : ""
snapshot.isDraggingOver ? "bg-brand-base/20" : "" } ${!isCollapsed ? "hidden" : "flex flex-col"}`}
} ${!isCollapsed ? "hidden" : "block"}`} ref={provided.innerRef}
ref={provided.innerRef} {...provided.droppableProps}
{...provided.droppableProps} >
> {orderBy !== "sort_order" && (
{orderBy !== "sort_order" && ( <>
<> <div
<div className={`absolute ${
className={`absolute ${ snapshot.isDraggingOver ? "block" : "hidden"
snapshot.isDraggingOver ? "block" : "hidden" } pointer-events-none top-0 left-0 z-[99] h-full w-full bg-brand-surface-1 opacity-50`}
} pointer-events-none top-0 left-0 z-[99] h-full w-full bg-brand-surface-1 opacity-50`} />
/> <div
<div className={`absolute ${
className={`absolute ${ snapshot.isDraggingOver ? "block" : "hidden"
snapshot.isDraggingOver ? "block" : "hidden" } pointer-events-none top-1/2 left-1/2 z-[99] -translate-y-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-brand-base p-2 text-xs`}
} pointer-events-none top-1/2 left-1/2 z-[99] -translate-y-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-brand-base p-2 text-xs`} >
> This board is ordered by{" "}
This board is ordered by{" "} {replaceUnderscoreIfSnakeCase(
{replaceUnderscoreIfSnakeCase( orderBy ? (orderBy[0] === "-" ? orderBy.slice(1) : orderBy) : "created_at"
orderBy ? (orderBy[0] === "-" ? orderBy.slice(1) : orderBy) : "created_at" )}
)} </div>
</div> </>
</> )}
)} <div className="pt-3 overflow-y-auto">
{groupedByIssues?.[groupTitle].map((issue, index) => ( {groupedByIssues?.[groupTitle].map((issue, index) => (
<Draggable <Draggable
key={issue.id} key={issue.id}
@ -146,10 +141,12 @@ export const SingleBoard: React.FC<Props> = ({
> >
{provided.placeholder} {provided.placeholder}
</span> </span>
</div>
<div>
{type === "issue" ? ( {type === "issue" ? (
<button <button
type="button" type="button"
className="flex items-center gap-2 font-medium text-brand-accent outline-none" className="flex items-center gap-2 font-medium text-brand-accent outline-none p-1"
onClick={addIssueToState} onClick={addIssueToState}
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
@ -167,7 +164,7 @@ export const SingleBoard: React.FC<Props> = ({
Add Issue Add Issue
</button> </button>
} }
optionsPosition="left" position="left"
noBorder noBorder
> >
<CustomMenu.MenuItem onClick={addIssueToState}> <CustomMenu.MenuItem onClick={addIssueToState}>
@ -182,10 +179,10 @@ export const SingleBoard: React.FC<Props> = ({
) )
)} )}
</div> </div>
)} </div>
</StrictModeDroppable> )}
)} </StrictModeDroppable>
</div> )}
</div> </div>
); );
}; };

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -17,6 +17,7 @@ import issuesService from "services/issues.service";
// hooks // hooks
import useIssuesView from "hooks/use-issues-view"; import useIssuesView from "hooks/use-issues-view";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components // components
import { import {
ViewAssigneeSelect, ViewAssigneeSelect,
@ -36,7 +37,9 @@ import {
XMarkIcon, XMarkIcon,
ArrowTopRightOnSquareIcon, ArrowTopRightOnSquareIcon,
PaperClipIcon, PaperClipIcon,
EllipsisHorizontalIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// helpers // helpers
import { handleIssuesMutation } from "constants/issue"; import { handleIssuesMutation } from "constants/issue";
import { copyTextToClipboard, truncateText } from "helpers/string.helper"; import { copyTextToClipboard, truncateText } from "helpers/string.helper";
@ -89,6 +92,9 @@ export const SingleBoardIssue: React.FC<Props> = ({
// context menu // context menu
const [contextMenu, setContextMenu] = useState(false); const [contextMenu, setContextMenu] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
const [isMenuActive, setIsMenuActive] = useState(false);
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const { orderBy, params } = useIssuesView(); const { orderBy, params } = useIssuesView();
@ -98,7 +104,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback( const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => { (formData: Partial<IIssue>, issueId: string) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
if (cycleId) if (cycleId)
@ -164,7 +170,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
} }
issuesService issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData) .patchIssue(workspaceSlug as string, projectId as string, issueId, formData)
.then(() => { .then(() => {
if (cycleId) { if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)); mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
@ -178,18 +184,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
console.log(error); console.log(error);
}); });
}, },
[ [workspaceSlug, projectId, cycleId, moduleId, groupTitle, index, selectedGroup, orderBy, params]
workspaceSlug,
projectId,
cycleId,
moduleId,
issue,
groupTitle,
index,
selectedGroup,
orderBy,
params,
]
); );
const getStyle = ( const getStyle = (
@ -217,6 +212,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
title: "Link Copied!", title: "Link Copied!",
message: "Issue link copied to clipboard.", message: "Issue link copied to clipboard.",
}); });
setIsMenuActive(false);
}); });
}; };
@ -224,6 +220,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging); if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
}, [snapshot, handleTrashBox]); }, [snapshot, handleTrashBox]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
return ( return (
@ -276,9 +274,23 @@ export const SingleBoardIssue: React.FC<Props> = ({
> >
<div className="group/card relative select-none p-3.5"> <div className="group/card relative select-none p-3.5">
{!isNotAllowed && ( {!isNotAllowed && (
<div className="z-1 absolute top-1.5 right-1.5 opacity-0 group-hover/card:opacity-100"> <div
ref={actionSectionRef}
className={`z-1 absolute top-1.5 right-1.5 hidden group-hover/card:!flex ${
isMenuActive ? "!flex" : ""
}`}
>
{type && !isNotAllowed && ( {type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis> <CustomMenu
customButton={
<button
className="flex w-full cursor-pointer items-center justify-between gap-1 rounded p-1 text-left text-xs duration-300 hover:bg-brand-surface-2"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<EllipsisHorizontalIcon className="h-4 w-4" />
</button>
}
>
<CustomMenu.MenuItem onClick={editIssue}> <CustomMenu.MenuItem onClick={editIssue}>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" /> <PencilIcon className="h-4 w-4" />
@ -348,11 +360,6 @@ export const SingleBoardIssue: React.FC<Props> = ({
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
/> />
)} )}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded-md border border-brand-base px-2 py-1 text-xs text-brand-secondary shadow-sm">
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.labels && issue.label_details.length > 0 && ( {properties.labels && issue.label_details.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => ( {issue.label_details.map((label) => (
@ -388,11 +395,21 @@ export const SingleBoardIssue: React.FC<Props> = ({
selfPositioned selfPositioned
/> />
)} )}
{properties.sub_issue_count && (
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}>
<div className="flex items-center gap-1 text-brand-secondary">
<LayerDiagonalIcon className="h-3.5 w-3.5" />
{issue.sub_issues_count}
</div>
</Tooltip>
</div>
)}
{properties.link && ( {properties.link && (
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm"> <div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Link" tooltipContent={`${issue.link_count}`}> <Tooltip tooltipHeading="Link" tooltipContent={`${issue.link_count}`}>
<div className="flex items-center gap-1 text-brand-secondary"> <div className="flex items-center gap-1 text-brand-secondary">
<LinkIcon className="h-3.5 w-3.5 text-brand-secondary" /> <LinkIcon className="h-3.5 w-3.5" />
{issue.link_count} {issue.link_count}
</div> </div>
</Tooltip> </Tooltip>
@ -402,7 +419,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm"> <div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Attachment" tooltipContent={`${issue.attachment_count}`}> <Tooltip tooltipHeading="Attachment" tooltipContent={`${issue.attachment_count}`}>
<div className="flex items-center gap-1 text-brand-secondary"> <div className="flex items-center gap-1 text-brand-secondary">
<PaperClipIcon className="h-3.5 w-3.5 -rotate-45 text-brand-secondary" /> <PaperClipIcon className="h-3.5 w-3.5 -rotate-45" />
{issue.attachment_count} {issue.attachment_count}
</div> </div>
</Tooltip> </Tooltip>

View File

@ -121,7 +121,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-brand-surface-2 shadow-2xl ring-1 ring-black ring-opacity-5 transition-all"> <Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 rounded-xl border border-brand-base bg-brand-base shadow-2xl transition-all">
<form> <form>
<Combobox <Combobox
onChange={(val: string) => { onChange={(val: string) => {
@ -149,7 +149,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
<Combobox.Options <Combobox.Options
static static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto" className="max-h-80 scroll-py-2 divide-y divide-brand-base overflow-y-auto"
> >
{filteredIssues.length > 0 ? ( {filteredIssues.length > 0 ? (
<li className="p-2"> <li className="p-2">
@ -158,15 +158,15 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
Select issues to delete Select issues to delete
</h2> </h2>
)} )}
<ul className="text-sm text-gray-700"> <ul className="text-sm text-brand-secondary">
{filteredIssues.map((issue) => ( {filteredIssues.map((issue) => (
<Combobox.Option <Combobox.Option
key={issue.id} key={issue.id}
as="div" as="div"
value={issue.id} value={issue.id}
className={({ active }) => className={({ active, selected }) =>
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${ `flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
active ? "bg-gray-900 bg-opacity-5 text-brand-base" : "" active ? "bg-brand-surface-2 text-brand-base" : ""
}` }`
} }
> >
@ -182,7 +182,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
backgroundColor: issue.state_detail.color, backgroundColor: issue.state_detail.color,
}} }}
/> />
<span className="flex-shrink-0 text-xs text-brand-secondary"> <span className="flex-shrink-0 text-xs">
{issue.project_detail.identifier}-{issue.sequence_id} {issue.project_detail.identifier}-{issue.sequence_id}
</span> </span>
<span>{issue.name}</span> <span>{issue.name}</span>

View File

@ -0,0 +1,220 @@
import React from "react";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// ui
import { CustomMenu, ToggleSwitch } from "components/ui";
// icons
import {
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "@heroicons/react/24/outline";
// helpers
import {
addMonths,
addSevenDaysToDate,
formatDate,
getCurrentWeekEndDate,
getCurrentWeekStartDate,
isSameMonth,
isSameYear,
lastDayOfWeek,
startOfWeek,
subtract7DaysToDate,
subtractMonths,
updateDateWithMonth,
updateDateWithYear,
} from "helpers/calendar.helper";
// constants
import { MONTHS_LIST, YEARS_LIST } from "constants/calendar";
type Props = {
isMonthlyView: boolean;
setIsMonthlyView: React.Dispatch<React.SetStateAction<boolean>>;
currentDate: Date;
setCurrentDate: React.Dispatch<React.SetStateAction<Date>>;
showWeekEnds: boolean;
setShowWeekEnds: React.Dispatch<React.SetStateAction<boolean>>;
changeDateRange: (startDate: Date, endDate: Date) => void;
};
export const CalendarHeader: React.FC<Props> = ({
setIsMonthlyView,
isMonthlyView,
currentDate,
setCurrentDate,
showWeekEnds,
setShowWeekEnds,
changeDateRange,
}) => {
const updateDate = (date: Date) => {
setCurrentDate(date);
changeDateRange(startOfWeek(date), lastDayOfWeek(date));
};
return (
<div className="mb-4 flex items-center justify-between">
<div className="relative flex h-full w-full items-center justify-start gap-2 text-sm ">
<Popover className="flex h-full items-center justify-start rounded-lg">
{({ open }) => (
<>
<Popover.Button>
<div className="flex items-center justify-center gap-2 text-2xl font-semibold text-brand-base">
<span>{formatDate(currentDate, "Month")}</span>{" "}
<span>{formatDate(currentDate, "yyyy")}</span>
</div>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-brand-surface-2 shadow-lg">
<div className="flex items-center justify-center gap-5 px-2 py-2 text-sm">
{YEARS_LIST.map((year) => (
<button
onClick={() => updateDate(updateDateWithYear(year.label, currentDate))}
className={` ${
isSameYear(year.value, currentDate)
? "text-sm font-medium text-brand-base"
: "text-xs text-brand-secondary "
} hover:text-sm hover:font-medium hover:text-brand-base`}
>
{year.label}
</button>
))}
</div>
<div className="grid grid-cols-4 border-t border-brand-base px-2">
{MONTHS_LIST.map((month) => (
<button
onClick={() =>
updateDate(updateDateWithMonth(`${month.value}`, currentDate))
}
className={`px-2 py-2 text-xs text-brand-secondary hover:font-medium hover:text-brand-base ${
isSameMonth(`${month.value}`, currentDate)
? "font-medium text-brand-base"
: ""
}`}
>
{month.label}
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<div className="flex items-center gap-2">
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(subtractMonths(currentDate, 1));
} else {
setCurrentDate(subtract7DaysToDate(currentDate));
changeDateRange(
getCurrentWeekStartDate(subtract7DaysToDate(currentDate)),
getCurrentWeekEndDate(subtract7DaysToDate(currentDate))
);
}
}}
>
<ChevronLeftIcon className="h-4 w-4" />
</button>
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(addMonths(currentDate, 1));
} else {
setCurrentDate(addSevenDaysToDate(currentDate));
changeDateRange(
getCurrentWeekStartDate(addSevenDaysToDate(currentDate)),
getCurrentWeekEndDate(addSevenDaysToDate(currentDate))
);
}
}}
>
<ChevronRightIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex w-full items-center justify-end gap-2">
<button
className="group flex cursor-pointer items-center gap-2 rounded-md border border-brand-base px-3 py-1 text-sm hover:bg-brand-surface-2 hover:text-brand-base focus:outline-none"
onClick={() => {
if (isMonthlyView) {
updateDate(new Date());
} else {
setCurrentDate(new Date());
changeDateRange(
getCurrentWeekStartDate(new Date()),
getCurrentWeekEndDate(new Date())
);
}
}}
>
Today
</button>
<CustomMenu
customButton={
<div className="group flex cursor-pointer items-center gap-2 rounded-md border border-brand-base px-3 py-1 text-sm hover:bg-brand-surface-2 hover:text-brand-base focus:outline-none ">
{isMonthlyView ? "Monthly" : "Weekly"}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
>
<CustomMenu.MenuItem
onClick={() => {
setIsMonthlyView(true);
changeDateRange(startOfWeek(currentDate), lastDayOfWeek(currentDate));
}}
className="w-52 text-sm text-brand-secondary"
>
<div className="flex w-full max-w-[260px] items-center justify-between gap-2">
<span className="flex items-center gap-2">Monthly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${isMonthlyView ? "opacity-100" : "opacity-0"}`}
/>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setIsMonthlyView(false);
changeDateRange(
getCurrentWeekStartDate(currentDate),
getCurrentWeekEndDate(currentDate)
);
}}
className="w-52 text-sm text-brand-secondary"
>
<div className="flex w-full items-center justify-between gap-2">
<span className="flex items-center gap-2">Weekly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${isMonthlyView ? "opacity-0" : "opacity-100"}`}
/>
</div>
</CustomMenu.MenuItem>
<div className="mt-1 flex w-52 items-center justify-between border-t border-brand-base py-2 px-1 text-sm text-brand-secondary">
<h4>Show weekends</h4>
<ToggleSwitch value={showWeekEnds} onChange={() => setShowWeekEnds(!showWeekEnds)} />
</div>
</CustomMenu>
</div>
</div>
);
};
export default CalendarHeader;

View File

@ -1,9 +1,20 @@
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import useSWR, { mutate } from "swr";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// helper import { mutate } from "swr";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
// hooks
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
// components
import { SingleCalendarDate, CalendarHeader } from "components/core";
// ui
import { Spinner } from "components/ui";
// helpers
import { renderDateFormat } from "helpers/date-time.helper"; import { renderDateFormat } from "helpers/date-time.helper";
import { import {
startOfWeek, startOfWeek,
@ -11,156 +22,61 @@ import {
eachDayOfInterval, eachDayOfInterval,
weekDayInterval, weekDayInterval,
formatDate, formatDate,
getCurrentWeekStartDate,
getCurrentWeekEndDate,
subtractMonths,
addMonths,
updateDateWithYear,
updateDateWithMonth,
isSameMonth,
isSameYear,
subtract7DaysToDate,
addSevenDaysToDate,
} from "helpers/calendar.helper"; } from "helpers/calendar.helper";
// ui // types
import { Popover, Transition } from "@headlessui/react"; import { ICalendarRange, IIssue, UserAuth } from "types";
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd"; // fetch-keys
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { CustomMenu, Spinner, ToggleSwitch } from "components/ui";
// icon
import { import {
CheckIcon, CYCLE_ISSUES_WITH_PARAMS,
ChevronDownIcon, MODULE_ISSUES_WITH_PARAMS,
ChevronLeftIcon, PROJECT_ISSUES_LIST_WITH_PARAMS,
ChevronRightIcon, VIEW_ISSUES,
PlusIcon,
} from "@heroicons/react/24/outline";
// hooks
import useIssuesView from "hooks/use-issues-view";
// services
import issuesService from "services/issues.service";
import cyclesService from "services/cycles.service";
// fetch key
import {
CYCLE_CALENDAR_ISSUES,
MODULE_CALENDAR_ISSUES,
PROJECT_CALENDAR_ISSUES,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
// type
import { IIssue } from "types";
// constant
import { monthOptions, yearOptions } from "constants/calendar";
import modulesService from "services/modules.service";
import { getStateGroupIcon } from "components/icons";
type Props = { type Props = {
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
addIssueToDate: (date: string) => void; addIssueToDate: (date: string) => void;
isCompleted: boolean;
userAuth: UserAuth;
}; };
interface ICalendarRange { export const CalendarView: React.FC<Props> = ({
startDate: Date; handleEditIssue,
endDate: Date; handleDeleteIssue,
} addIssueToDate,
isCompleted = false,
export const CalendarView: React.FC<Props> = ({ addIssueToDate }) => { userAuth,
}) => {
const [showWeekEnds, setShowWeekEnds] = useState(false); const [showWeekEnds, setShowWeekEnds] = useState(false);
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const [isMonthlyView, setIsMonthlyView] = useState(true); const [isMonthlyView, setIsMonthlyView] = useState(true);
const [showAllIssues, setShowAllIssues] = useState(false);
const router = useRouter(); const [calendarDates, setCalendarDates] = useState<ICalendarRange>({
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { params } = useIssuesView();
const [calendarDateRange, setCalendarDateRange] = useState<ICalendarRange>({
startDate: startOfWeek(currentDate), startDate: startOfWeek(currentDate),
endDate: lastDayOfWeek(currentDate), endDate: lastDayOfWeek(currentDate),
}); });
const targetDateFilter = { const router = useRouter();
target_date: `${renderDateFormat(calendarDateRange.startDate)};after,${renderDateFormat( const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
calendarDateRange.endDate
)};before`,
};
const { data: projectCalendarIssues } = useSWR( const { calendarIssues, params, setCalendarDateRange } = useCalendarIssuesView();
workspaceSlug && projectId ? PROJECT_CALENDAR_ISSUES(projectId as string) : null,
workspaceSlug && projectId
? () =>
issuesService.getIssuesWithParams(workspaceSlug as string, projectId as string, {
...params,
target_date: `${renderDateFormat(calendarDateRange.startDate)};after,${renderDateFormat(
calendarDateRange.endDate
)};before`,
group_by: null,
})
: null
);
const { data: cycleCalendarIssues } = useSWR(
workspaceSlug && projectId && cycleId
? CYCLE_CALENDAR_ISSUES(projectId as string, cycleId as string)
: null,
workspaceSlug && projectId && cycleId
? () =>
cyclesService.getCycleIssuesWithParams(
workspaceSlug as string,
projectId as string,
cycleId as string,
{
...params,
target_date: `${renderDateFormat(
calendarDateRange.startDate
)};after,${renderDateFormat(calendarDateRange.endDate)};before`,
group_by: null,
}
)
: null
);
const { data: moduleCalendarIssues } = useSWR(
workspaceSlug && projectId && moduleId
? MODULE_CALENDAR_ISSUES(projectId as string, moduleId as string)
: null,
workspaceSlug && projectId && moduleId
? () =>
modulesService.getModuleIssuesWithParams(
workspaceSlug as string,
projectId as string,
moduleId as string,
{
...params,
target_date: `${renderDateFormat(
calendarDateRange.startDate
)};after,${renderDateFormat(calendarDateRange.endDate)};before`,
group_by: null,
}
)
: null
);
const totalDate = eachDayOfInterval({ const totalDate = eachDayOfInterval({
start: calendarDateRange.startDate, start: calendarDates.startDate,
end: calendarDateRange.endDate, end: calendarDates.endDate,
}); });
const onlyWeekDays = weekDayInterval({ const onlyWeekDays = weekDayInterval({
start: calendarDateRange.startDate, start: calendarDates.startDate,
end: calendarDateRange.endDate, end: calendarDates.endDate,
}); });
const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays; const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays;
const calendarIssues = cycleId
? (cycleCalendarIssues as IIssue[])
: moduleId
? (moduleCalendarIssues as IIssue[])
: (projectCalendarIssues as IIssue[]);
const currentViewDaysData = currentViewDays.map((date: Date) => { const currentViewDaysData = currentViewDays.map((date: Date) => {
const filterIssue = const filterIssue =
calendarIssues && calendarIssues.length > 0 calendarIssues.length > 0
? calendarIssues.filter( ? calendarIssues.filter(
(issue) => (issue) =>
issue.target_date && renderDateFormat(issue.target_date) === renderDateFormat(date) issue.target_date && renderDateFormat(issue.target_date) === renderDateFormat(date)
@ -191,13 +107,16 @@ export const CalendarView: React.FC<Props> = ({ addIssueToDate }) => {
const { source, destination, draggableId } = result; const { source, destination, draggableId } = result;
if (!destination || !workspaceSlug || !projectId) return; if (!destination || !workspaceSlug || !projectId) return;
if (source.droppableId === destination.droppableId) return; if (source.droppableId === destination.droppableId) return;
const fetchKey = cycleId const fetchKey = cycleId
? CYCLE_CALENDAR_ISSUES(projectId as string, cycleId as string) ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
: moduleId : moduleId
? MODULE_CALENDAR_ISSUES(projectId as string, moduleId as string) ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: PROJECT_CALENDAR_ISSUES(projectId as string); : viewId
? VIEW_ISSUES(viewId.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
mutate<IIssue[]>( mutate<IIssue[]>(
fetchKey, fetchKey,
@ -208,314 +127,105 @@ export const CalendarView: React.FC<Props> = ({ addIssueToDate }) => {
...p, ...p,
target_date: destination.droppableId, target_date: destination.droppableId,
}; };
return p; return p;
}), }),
false false
); );
issuesService.patchIssue(workspaceSlug as string, projectId as string, draggableId, { issuesService
target_date: destination?.droppableId, .patchIssue(workspaceSlug as string, projectId as string, draggableId, {
}); target_date: destination?.droppableId,
})
.then(() => mutate(fetchKey));
}; };
const updateDate = (date: Date) => { const changeDateRange = (startDate: Date, endDate: Date) => {
setCurrentDate(date); setCalendarDates({
startDate,
setCalendarDateRange({ endDate,
startDate: startOfWeek(date),
endDate: lastDayOfWeek(date),
}); });
setCalendarDateRange(
`${renderDateFormat(startDate)};after,${renderDateFormat(endDate)};before`
);
}; };
useEffect(() => {
setCalendarDateRange(
`${renderDateFormat(startOfWeek(currentDate))};after,${renderDateFormat(
lastDayOfWeek(currentDate)
)};before`
);
}, [currentDate]);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
return calendarIssues ? ( return calendarIssues ? (
<DragDropContext onDragEnd={onDragEnd}> <div className="h-full">
<div className="-m-2 h-full overflow-y-auto rounded-lg text-brand-secondary"> <DragDropContext onDragEnd={onDragEnd}>
<div className="mb-4 flex items-center justify-between"> <div className="h-full rounded-lg p-8 text-brand-secondary">
<div className="relative flex h-full w-full items-center justify-start gap-2 text-sm "> <CalendarHeader
<Popover className="flex h-full items-center justify-start rounded-lg"> isMonthlyView={isMonthlyView}
{({ open }) => ( setIsMonthlyView={setIsMonthlyView}
<> showWeekEnds={showWeekEnds}
<Popover.Button className={`group flex h-full items-start gap-1 text-brand-base`}> setShowWeekEnds={setShowWeekEnds}
<div className="flex items-center justify-center gap-2 text-2xl font-semibold"> currentDate={currentDate}
<span>{formatDate(currentDate, "Month")}</span>{" "} setCurrentDate={setCurrentDate}
<span>{formatDate(currentDate, "yyyy")}</span> changeDateRange={changeDateRange}
</div> />
</Popover.Button>
<Transition <div
as={React.Fragment} className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
enter="transition ease-out duration-200" showWeekEnds ? "grid-cols-7" : "grid-cols-5"
enterFrom="opacity-0 translate-y-1" }`}
enterTo="opacity-100 translate-y-0" >
leave="transition ease-in duration-150" {weeks.map((date, index) => (
leaveFrom="opacity-100 translate-y-0" <div
leaveTo="opacity-0 translate-y-1" key={index}
> className={`flex items-center justify-start gap-2 border-brand-base bg-brand-surface-1 p-1.5 text-base font-medium text-brand-secondary ${
<Popover.Panel className="absolute top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-brand-surface-2 shadow-lg"> !isMonthlyView
<div className="flex items-center justify-center gap-5 px-2 py-2 text-sm"> ? showWeekEnds
{yearOptions.map((year) => ( ? (index + 1) % 7 === 0
<button
onClick={() => updateDate(updateDateWithYear(year.label, currentDate))}
className={` ${
isSameYear(year.value, currentDate)
? "text-sm font-medium text-brand-base"
: "text-xs text-brand-secondary "
} hover:text-sm hover:font-medium hover:text-brand-base`}
>
{year.label}
</button>
))}
</div>
<div className="grid grid-cols-4 border-t border-brand-base px-2">
{monthOptions.map((month) => (
<button
onClick={() =>
updateDate(updateDateWithMonth(month.value, currentDate))
}
className={`px-2 py-2 text-xs text-brand-secondary hover:font-medium hover:text-brand-base ${
isSameMonth(month.value, currentDate)
? "font-medium text-brand-base"
: ""
}`}
>
{month.label}
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<div className="flex items-center gap-2">
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(subtractMonths(currentDate, 1));
} else {
setCurrentDate(subtract7DaysToDate(currentDate));
setCalendarDateRange({
startDate: getCurrentWeekStartDate(subtract7DaysToDate(currentDate)),
endDate: getCurrentWeekEndDate(subtract7DaysToDate(currentDate)),
});
}
}}
>
<ChevronLeftIcon className="h-4 w-4" />
</button>
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(addMonths(currentDate, 1));
} else {
setCurrentDate(addSevenDaysToDate(currentDate));
setCalendarDateRange({
startDate: getCurrentWeekStartDate(addSevenDaysToDate(currentDate)),
endDate: getCurrentWeekEndDate(addSevenDaysToDate(currentDate)),
});
}
}}
>
<ChevronRightIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex w-full items-center justify-end gap-2">
<button
className="group flex cursor-pointer items-center gap-2 rounded-md border border-brand-base px-3 py-1 text-sm hover:bg-brand-surface-2 hover:text-brand-base focus:outline-none"
onClick={() => {
if (isMonthlyView) {
updateDate(new Date());
} else {
setCurrentDate(new Date());
setCalendarDateRange({
startDate: getCurrentWeekStartDate(new Date()),
endDate: getCurrentWeekEndDate(new Date()),
});
}
}}
>
Today
</button>
<CustomMenu
customButton={
<div className="group flex cursor-pointer items-center gap-2 rounded-md border border-brand-base px-3 py-1 text-sm hover:bg-brand-surface-2 hover:text-brand-base focus:outline-none ">
{isMonthlyView ? "Monthly" : "Weekly"}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
>
<CustomMenu.MenuItem
onClick={() => {
setIsMonthlyView(true);
setCalendarDateRange({
startDate: startOfWeek(currentDate),
endDate: lastDayOfWeek(currentDate),
});
}}
className="w-52 text-sm text-brand-secondary"
>
<div className="flex w-full max-w-[260px] items-center justify-between gap-2">
<span className="flex items-center gap-2">Monthly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${
isMonthlyView ? "opacity-100" : "opacity-0"
}`}
/>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setIsMonthlyView(false);
setCalendarDateRange({
startDate: getCurrentWeekStartDate(currentDate),
endDate: getCurrentWeekEndDate(currentDate),
});
}}
className="w-52 text-sm text-brand-secondary"
>
<div className="flex w-full items-center justify-between gap-2">
<span className="flex items-center gap-2">Weekly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${
isMonthlyView ? "opacity-0" : "opacity-100"
}`}
/>
</div>
</CustomMenu.MenuItem>
<div className="mt-1 flex w-52 items-center justify-between border-t border-brand-base py-2 px-1 text-sm text-brand-secondary">
<h4>Show weekends</h4>
<ToggleSwitch
value={showWeekEnds}
onChange={() => setShowWeekEnds(!showWeekEnds)}
/>
</div>
</CustomMenu>
</div>
</div>
<div
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
}`}
>
{weeks.map((date, index) => (
<div
key={index}
className={`flex items-center justify-start gap-2 border-brand-base bg-brand-surface-1 p-1.5 text-base font-medium text-brand-secondary ${
!isMonthlyView
? showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
: ""
}`}
>
<span>
{isMonthlyView ? formatDate(date, "eee").substring(0, 3) : formatDate(date, "eee")}
</span>
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
</div>
))}
</div>
<div
className={`grid h-full auto-rows-[minmax(150px,1fr)] ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
} `}
>
{currentViewDaysData.map((date, index) => {
const totalIssues = date.issues.length;
return (
<StrictModeDroppable droppableId={date.date}>
{(provided) => (
<div
key={index}
ref={provided.innerRef}
{...provided.droppableProps}
className={`group relative flex flex-col gap-1.5 border-t border-brand-base p-2.5 text-left text-sm font-medium hover:bg-brand-surface-1 ${
showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? "" ? ""
: "border-r" : "border-r"
}`} : (index + 1) % 5 === 0
> ? ""
{isMonthlyView && <span>{formatDate(new Date(date.date), "d")}</span>} : "border-r"
{totalIssues > 0 && : ""
date.issues }`}
.slice(0, showAllIssues ? totalIssues : 4) >
.map((issue: IIssue, index) => ( <span>
<Draggable draggableId={issue.id} index={index}> {isMonthlyView
{(provided, snapshot) => ( ? formatDate(date, "eee").substring(0, 3)
<div : formatDate(date, "eee")}
key={index} </span>
ref={provided.innerRef} {!isMonthlyView && <span>{formatDate(date, "d")}</span>}
{...provided.draggableProps} </div>
{...provided.dragHandleProps} ))}
className={`w-full cursor-pointer truncate rounded border border-brand-base px-1.5 py-1 text-xs duration-300 hover:cursor-move hover:bg-brand-surface-2 ${ </div>
snapshot.isDragging ? "bg-brand-surface-2 shadow-lg" : ""
}`} <div
> className={`grid h-full ${isMonthlyView ? "auto-rows-min" : ""} ${
<Link showWeekEnds ? "grid-cols-7" : "grid-cols-5"
href={`/${workspaceSlug}/projects/${issue?.project_detail.id}/issues/${issue.id}`} } `}
> >
<a className="flex w-full items-center gap-2"> {currentViewDaysData.map((date, index) => (
{getStateGroupIcon( <SingleCalendarDate
issue.state_detail.group, index={index}
"12", date={date}
"12", handleEditIssue={handleEditIssue}
issue.state_detail.color handleDeleteIssue={handleDeleteIssue}
)} addIssueToDate={addIssueToDate}
{issue.name} isMonthlyView={isMonthlyView}
</a> showWeekEnds={showWeekEnds}
</Link> isNotAllowed={isNotAllowed}
</div> />
)} ))}
</Draggable> </div>
))}
{totalIssues > 4 && (
<button
type="button"
className="w-min whitespace-nowrap rounded-md border border-brand-base bg-brand-surface-2 px-1.5 py-1 text-xs"
onClick={() => setShowAllIssues((prevData) => !prevData)}
>
{showAllIssues ? "Hide" : totalIssues - 4 + " more"}
</button>
)}
<div
className={`absolute ${
isMonthlyView ? "bottom-2" : "top-2"
} right-2 flex items-center justify-center rounded-md bg-brand-surface-2 p-1 text-xs text-brand-secondary opacity-0 group-hover:opacity-100`}
>
<button
className="flex items-center justify-center gap-1 text-center"
onClick={() => addIssueToDate(date.date)}
>
<PlusIcon className="h-3 w-3 text-brand-secondary" />
Add issue
</button>
</div>
{provided.placeholder}
</div>
)}
</StrictModeDroppable>
);
})}
</div> </div>
</div> </DragDropContext>
</DragDropContext> </div>
) : ( ) : (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<Spinner /> <Spinner />

View File

@ -1 +1,4 @@
export * from "./calendar" export * from "./calendar-header";
export * from "./calendar";
export * from "./single-date";
export * from "./single-issue";

View File

@ -0,0 +1,107 @@
import React, { useState } from "react";
// react-beautiful-dnd
import { Draggable } from "react-beautiful-dnd";
// component
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { SingleCalendarIssue } from "./single-issue";
// icons
import { PlusSmallIcon } from "@heroicons/react/24/outline";
// helper
import { formatDate } from "helpers/calendar.helper";
// types
import { IIssue } from "types";
type Props = {
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
index: number;
date: {
date: string;
issues: IIssue[];
};
addIssueToDate: (date: string) => void;
isMonthlyView: boolean;
showWeekEnds: boolean;
isNotAllowed: boolean;
};
export const SingleCalendarDate: React.FC<Props> = ({
handleEditIssue,
handleDeleteIssue,
date,
index,
addIssueToDate,
isMonthlyView,
showWeekEnds,
isNotAllowed,
}) => {
const [showAllIssues, setShowAllIssues] = useState(false);
const totalIssues = date.issues.length;
return (
<StrictModeDroppable droppableId={date.date}>
{(provided) => (
<div
key={index}
ref={provided.innerRef}
{...provided.droppableProps}
className={`group relative flex min-h-[150px] flex-col gap-1.5 border-t border-brand-base p-2.5 text-left text-sm font-medium hover:bg-brand-surface-1 ${
isMonthlyView ? "" : "pt-9"
} ${
showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
}`}
>
{isMonthlyView && <span>{formatDate(new Date(date.date), "d")}</span>}
{totalIssues > 0 &&
date.issues.slice(0, showAllIssues ? totalIssues : 4).map((issue: IIssue, index) => (
<Draggable key={issue.id} draggableId={issue.id} index={index}>
{(provided, snapshot) => (
<SingleCalendarIssue
key={index}
index={index}
provided={provided}
snapshot={snapshot}
issue={issue}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
isNotAllowed={isNotAllowed}
/>
)}
</Draggable>
))}
{totalIssues > 4 && (
<button
type="button"
className="w-min whitespace-nowrap rounded-md border border-brand-base bg-brand-surface-2 px-1.5 py-1 text-xs"
onClick={() => setShowAllIssues((prevData) => !prevData)}
>
{showAllIssues ? "Hide" : totalIssues - 4 + " more"}
</button>
)}
<div
className={`absolute top-2 right-2 flex items-center justify-center rounded-md bg-brand-surface-2 p-1 text-xs text-brand-secondary opacity-0 group-hover:opacity-100`}
>
<button
className="flex items-center justify-center gap-1 text-center"
onClick={() => addIssueToDate(date.date)}
>
<PlusSmallIcon className="h-4 w-4 text-brand-secondary" />
Add issue
</button>
</div>
{provided.placeholder}
</div>
)}
</StrictModeDroppable>
);
};

View File

@ -0,0 +1,276 @@
import React, { useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-beautiful-dnd
import { DraggableProvided, DraggableStateSnapshot } from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
// hooks
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
import useIssuesProperties from "hooks/use-issue-properties";
import useToast from "hooks/use-toast";
// components
import { CustomMenu, Tooltip } from "components/ui";
import {
ViewAssigneeSelect,
ViewDueDateSelect,
ViewEstimateSelect,
ViewPrioritySelect,
ViewStateSelect,
} from "components/issues";
// icons
import { LinkIcon, PaperClipIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// helper
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// type
import { IIssue } from "types";
// fetch-keys
import {
CYCLE_ISSUES_WITH_PARAMS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
VIEW_ISSUES,
} from "constants/fetch-keys";
type Props = {
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
index: number;
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
issue: IIssue;
isNotAllowed: boolean;
};
export const SingleCalendarIssue: React.FC<Props> = ({
handleEditIssue,
handleDeleteIssue,
index,
provided,
snapshot,
issue,
isNotAllowed,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { setToastAlert } = useToast();
const { params } = useCalendarIssuesView();
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, issueId: string) => {
if (!workspaceSlug || !projectId) return;
const fetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: viewId
? VIEW_ISSUES(viewId.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
mutate<IIssue[]>(
fetchKey,
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === issueId) {
return {
...p,
...formData,
assignees: formData?.assignees_list ?? p.assignees,
};
}
return p;
}),
false
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, formData)
.then(() => {
mutate(fetchKey);
})
.catch((error) => {
console.log(error);
});
},
[workspaceSlug, projectId, cycleId, moduleId, params]
);
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
const displayProperties = properties
? Object.values(properties).some((value) => value === true)
: false;
return (
<div
key={index}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={`w-full relative cursor-pointer rounded border border-brand-base px-1.5 py-1.5 text-xs duration-300 hover:cursor-move hover:bg-brand-surface-2 ${
snapshot.isDragging ? "bg-brand-surface-2 shadow-lg" : ""
}`}
>
<div className="group/card flex w-full flex-col items-start justify-center gap-1.5 text-xs sm:w-auto ">
{!isNotAllowed && (
<div className="z-1 absolute top-1.5 right-1.5 opacity-0 group-hover/card:opacity-100">
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={() => handleEditIssue(issue)}>
<div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
<span>Edit issue</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
<div className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete issue</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy issue Link</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
)}
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail.id}/issues/${issue.id}`}>
<a className="flex w-full cursor-pointer flex-col items-start justify-center gap-1.5">
{properties.key && (
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-brand-secondary">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
)}
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-xs text-brand-base">{truncateText(issue.name, 25)}</span>
</Tooltip>
</a>
</Link>
{displayProperties && (
<div className="relative mt-1.5 flex flex-wrap items-center gap-2 text-xs">
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
isNotAllowed={isNotAllowed}
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.labels && issue.label_details.length > 0 ? (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
key={label.id}
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary"
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</span>
))}
</div>
) : (
""
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
isNotAllowed={isNotAllowed}
/>
)}
{properties.estimate && (
<ViewEstimateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
isNotAllowed={isNotAllowed}
/>
)}
{properties.sub_issue_count && (
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}>
<div className="flex items-center gap-1 text-brand-secondary">
<LayerDiagonalIcon className="h-3.5 w-3.5" />
{issue.sub_issues_count}
</div>
</Tooltip>
</div>
)}
{properties.link && (
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
<div className="flex items-center gap-1 text-brand-secondary">
<LinkIcon className="h-3.5 w-3.5" />
{issue.link_count}
</div>
</Tooltip>
</div>
)}
{properties.attachment_count && (
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
<div className="flex items-center gap-1 text-brand-secondary">
<PaperClipIcon className="h-3.5 w-3.5 -rotate-45" />
{issue.attachment_count}
</div>
</Tooltip>
</div>
)}
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,121 @@
import React from "react";
// react-form
import {
FieldError,
FieldErrorsImpl,
Merge,
UseFormRegister,
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
// react-color
import { ColorResult, SketchPicker } from "react-color";
// component
import { Popover, Transition } from "@headlessui/react";
import { Input } from "components/ui";
// icons
import { ColorPickerIcon } from "components/icons";
type Props = {
name: string;
watch: UseFormWatch<any>;
setValue: UseFormSetValue<any>;
error: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined;
register: UseFormRegister<any>;
};
export const ColorPickerInput: React.FC<Props> = ({ name, watch, setValue, error, register }) => {
const handleColorChange = (newColor: ColorResult) => {
const { hex } = newColor;
setValue(name, hex);
};
const getColorText = (colorName: string) => {
switch (colorName) {
case "accent":
return "Accent";
case "bgBase":
return "Background";
case "bgSurface1":
return "Background surface 1";
case "bgSurface2":
return "Background surface 2";
case "border":
return "Border";
case "sidebar":
return "Sidebar";
case "textBase":
return "Text primary";
case "textSecondary":
return "Text secondary";
default:
return "Color";
}
};
return (
<div className="relative ">
<Input
id={name}
name={name}
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={error}
value={watch(name)}
register={register}
validations={{
required: `${getColorText(name)} color is required`,
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: `${getColorText(name)} color should be hex format`,
},
}}
/>
<div className="absolute right-4 top-2.5">
<Popover className="relative grid place-items-center">
{({ open }) => (
<>
<Popover.Button
type="button"
className={`group inline-flex items-center outline-none ${
open ? "text-brand-base" : "text-brand-secondary"
}`}
>
{watch(name) && watch(name) !== "" ? (
<span
className="h-4 w-4 rounded border border-brand-base"
style={{
backgroundColor: `${watch(name)}`,
}}
/>
) : (
<ColorPickerIcon
height={14}
width={14}
className="fill-current text-brand-base"
/>
)}
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute bottom-8 right-0 z-20 mt-1 max-w-xs px-2 sm:px-0">
<SketchPicker color={watch(name)} onChange={handleColorChange} />
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
</div>
);
};

View File

@ -1,267 +0,0 @@
import { useEffect, useState } from "react";
// react-hook-form
import { useForm } from "react-hook-form";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
const defaultValues = {
palette: "",
};
export const ThemeForm: React.FC<any> = ({ handleFormSubmit, handleClose, status, data }) => {
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm<any>({
defaultValues,
});
const [darkPalette, setDarkPalette] = useState(false);
const handleUpdateTheme = async (formData: any) => {
await handleFormSubmit({ ...formData, darkPalette });
reset({
...defaultValues,
});
};
useEffect(() => {
reset({
...defaultValues,
...data,
});
}, [data, reset]);
// --color-bg-base: 25, 27, 27;
// --color-bg-surface-1: 31, 32, 35;
// --color-bg-surface-2: 39, 42, 45;
// --color-border: 46, 50, 52;
// --color-bg-sidebar: 19, 20, 22;
// --color-accent: 60, 133, 217;
// --color-text-base: 255, 255, 255;
// --color-text-secondary: 142, 148, 146;
return (
<form onSubmit={handleSubmit(handleUpdateTheme)}>
<div className="space-y-5">
<h3 className="text-lg font-medium leading-6 text-brand-base">Customize your theme</h3>
<div className="space-y-4">
<div className="mt-6 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
<div className="sm:col-span-2">
<Input
id="bgBase"
label="Background"
name="bgBase"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.bgBase}
register={register}
validations={{
required: "Background color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Background color should be hex format",
},
}}
/>
</div>
<div className="sm:col-span-2">
<Input
id="bgSurface1"
label="Background surface 1"
name="bgSurface1"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.bgSurface1}
register={register}
validations={{
required: "Background surface 1 color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Background surface 1 color should be hex format",
},
}}
/>
</div>
<div className="sm:col-span-2">
<Input
id="bgSurface2"
label="Background surface 2"
name="bgSurface1"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.bgSurface1}
register={register}
validations={{
required: "Background surface 2 color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Background surface 2 color should be hex format",
},
}}
/>
</div>
<div className="sm:col-span-2">
<Input
id="border"
label="Border"
name="border"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.border}
register={register}
validations={{
required: "Border color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Border color should be hex format",
},
}}
/>
</div>
<div className="sm:col-span-2">
<Input
id="sidebar"
label="Sidebar"
name="sidebar"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.sidebar}
register={register}
validations={{
required: "Sidebar color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Sidebar color should be hex format",
},
}}
/>
</div>
<div className="sm:col-span-2">
<Input
id="accent"
label="Accent"
name="accent"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.accent}
register={register}
validations={{
required: "Accent color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Accent color should be hex format",
},
}}
/>
</div>
<div className="sm:col-span-3">
<Input
id="textBase"
label="Text primary"
name="textBase"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.textBase}
register={register}
validations={{
required: "Text primary color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Text primary color should be hex format",
},
}}
/>
</div>
<div className="sm:col-span-3">
<Input
id="textSecondary"
label="Text secondary"
name="textSecondary"
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={errors.textSecondary}
register={register}
validations={{
required: "Text secondary color is required",
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Text secondary color should be hex format",
},
}}
/>
</div>
</div>
<div>
<Input
id="palette"
label="All colors"
name="palette"
type="name"
placeholder="Enter comma separated hex colors"
autoComplete="off"
error={errors.palette}
register={register}
validations={{
required: "Color values is required",
pattern: {
value: /^(#(?:[0-9a-fA-F]{3}){1,2},){7}#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: "Color values should be hex format, separated by commas",
},
}}
/>
</div>
<div
className="flex cursor-pointer items-center gap-1"
onClick={() => setDarkPalette((prevData) => !prevData)}
>
<span className="text-xs">Dark palette</span>
<button
type="button"
className={`pointer-events-none relative inline-flex h-4 w-7 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent ${
darkPalette ? "bg-brand-accent" : "bg-gray-300"
} transition-colors duration-300 ease-in-out focus:outline-none`}
role="switch"
aria-checked="false"
>
<span className="sr-only">Dark palette</span>
<span
aria-hidden="true"
className={`pointer-events-none inline-block h-3 w-3 ${
darkPalette ? "translate-x-3" : "translate-x-0"
} transform rounded-full bg-brand-surface-2 shadow ring-0 transition duration-300 ease-in-out`}
/>
</button>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{status
? isSubmitting
? "Updating Theme..."
: "Update Theme"
: isSubmitting
? "Creating Theme..."
: "Set Theme"}
</PrimaryButton>
</div>
</form>
);
};

View File

@ -1,65 +0,0 @@
import React from "react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// components
import { ThemeForm } from "./custom-theme-form";
// helpers
import { applyTheme } from "helpers/theme.helper";
// fetch-keys
type Props = {
isOpen: boolean;
handleClose: () => void;
};
export const CustomThemeModal: React.FC<Props> = ({ isOpen, handleClose }) => {
const onClose = () => {
handleClose();
};
const handleFormSubmit = async (formData: any) => {
applyTheme(formData.palette, formData.darkPalette);
onClose();
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.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-[#131313] bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-brand-surface-1 px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<ThemeForm
handleClose={handleClose}
handleFormSubmit={handleFormSubmit}
status={false}
/>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,198 @@
import React, { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { useForm } from "react-hook-form";
// hooks
import useUser from "hooks/use-user";
// ui
import { PrimaryButton } from "components/ui";
import { ColorPickerInput } from "components/core";
// services
import userService from "services/user.service";
// helper
import { applyTheme } from "helpers/theme.helper";
// types
import { ICustomTheme } from "types";
type Props = {
preLoadedData?: Partial<ICustomTheme> | null;
};
export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
const [darkPalette, setDarkPalette] = useState(false);
const defaultValues = {
accent: preLoadedData?.accent ?? "#FE5050",
bgBase: preLoadedData?.bgBase ?? "#FFF7F7",
bgSurface1: preLoadedData?.bgSurface1 ?? "#FFE0E0",
bgSurface2: preLoadedData?.bgSurface2 ?? "#FFF7F7",
border: preLoadedData?.border ?? "#FFC9C9",
darkPalette: preLoadedData?.darkPalette ?? false,
palette: preLoadedData?.palette ?? "",
sidebar: preLoadedData?.sidebar ?? "#FFFFFF",
textBase: preLoadedData?.textBase ?? "#430000",
textSecondary: preLoadedData?.textSecondary ?? "#323232",
};
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
watch,
setValue,
reset,
} = useForm<any>({
defaultValues,
});
const { setTheme } = useTheme();
const { mutateUser } = useUser();
const handleFormSubmit = async (formData: any) => {
await userService
.updateUser({
theme: {
accent: formData.accent,
bgBase: formData.bgBase,
bgSurface1: formData.bgSurface1,
bgSurface2: formData.bgSurface2,
border: formData.border,
darkPalette: darkPalette,
palette: `${formData.bgBase},${formData.bgSurface1},${formData.bgSurface2},${formData.border},${formData.sidebar},${formData.accent},${formData.textBase},${formData.textSecondary}`,
sidebar: formData.sidebar,
textBase: formData.textBase,
textSecondary: formData.textSecondary,
},
})
.then((res) => {
mutateUser((prevData) => {
if (!prevData) return prevData;
return { ...prevData, user: res };
}, false);
applyTheme(formData.palette, darkPalette);
setTheme("custom");
})
.catch((err) => console.log(err));
};
const handleUpdateTheme = async (formData: any) => {
await handleFormSubmit({ ...formData, darkPalette });
reset({
...defaultValues,
});
};
useEffect(() => {
reset({
...defaultValues,
...preLoadedData,
});
}, [preLoadedData, reset]);
return (
<form onSubmit={handleSubmit(handleUpdateTheme)}>
<div className="space-y-5">
<h3 className="text-lg font-semibold text-brand-base">Customize your theme</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-base text-brand-secondary">Background</h3>
<ColorPickerInput
name="bgBase"
error={errors.bgBase}
watch={watch}
setValue={setValue}
register={register}
/>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-base text-brand-secondary">Background surface 1</h3>
<ColorPickerInput
name="bgSurface1"
error={errors.bgSurface1}
watch={watch}
setValue={setValue}
register={register}
/>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-base text-brand-secondary">Background surface 2</h3>
<ColorPickerInput
name="bgSurface2"
error={errors.bgSurface2}
watch={watch}
setValue={setValue}
register={register}
/>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-base text-brand-secondary">Border</h3>
<ColorPickerInput
name="border"
error={errors.border}
watch={watch}
setValue={setValue}
register={register}
/>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-base text-brand-secondary">Sidebar</h3>
<ColorPickerInput
name="sidebar"
error={errors.sidebar}
watch={watch}
setValue={setValue}
register={register}
/>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-base text-brand-secondary">Accent</h3>
<ColorPickerInput
name="accent"
error={errors.accent}
watch={watch}
setValue={setValue}
register={register}
/>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-base text-brand-secondary">Text primary</h3>
<ColorPickerInput
name="textBase"
error={errors.textBase}
watch={watch}
setValue={setValue}
register={register}
/>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-base text-brand-secondary">Text secondary</h3>
<ColorPickerInput
name="textSecondary"
error={errors.textSecondary}
watch={watch}
setValue={setValue}
register={register}
/>
</div>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Creating Theme..." : "Set Theme"}
</PrimaryButton>
</div>
</form>
);
};

View File

@ -79,7 +79,7 @@ const activityDetails: {
}, },
estimate_point: { estimate_point: {
message: "set the estimate point to", message: "set the estimate point to",
icon: <PlayIcon className="h-3 w-3 -rotate-90 text-gray-500" aria-hidden="true" />, icon: <PlayIcon className="h-3 w-3 -rotate-90 text-brand-secondary" aria-hidden="true" />,
}, },
target_date: { target_date: {
message: "set the due date to", message: "set the due date to",

View File

@ -0,0 +1,26 @@
import { useRouter } from "next/router";
// components
import { CycleIssuesGanttChartView } from "components/cycles";
import { IssueGanttChartView } from "components/issues/gantt-chart";
import { ModuleIssuesGanttChartView } from "components/modules";
import { ViewIssuesGanttChartView } from "components/views";
export const GanttChartView = () => {
const router = useRouter();
const { cycleId, moduleId, viewId } = router.query;
return (
<>
{cycleId ? (
<CycleIssuesGanttChartView />
) : moduleId ? (
<ModuleIssuesGanttChartView />
) : viewId ? (
<ViewIssuesGanttChartView />
) : (
<IssueGanttChartView />
)}
</>
);
};

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useState, forwardRef, useRef } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@ -35,6 +35,14 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor
ssr: false, ssr: false,
}); });
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
const WrappedRemirrorRichTextEditor = forwardRef<IRemirrorRichTextEditor, IRemirrorRichTextEditor>(
(props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />
);
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
export const GptAssistantModal: React.FC<Props> = ({ export const GptAssistantModal: React.FC<Props> = ({
isOpen, isOpen,
handleClose, handleClose,
@ -52,6 +60,8 @@ export const GptAssistantModal: React.FC<Props> = ({
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const editorRef = useRef<any>(null);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { const {
@ -119,26 +129,31 @@ export const GptAssistantModal: React.FC<Props> = ({
if (isOpen) setFocus("task"); if (isOpen) setFocus("task");
}, [isOpen, setFocus]); }, [isOpen, setFocus]);
useEffect(() => {
editorRef.current?.setEditorValue(htmlContent ?? `<p>${content}</p>`);
}, [htmlContent, editorRef, content]);
return ( return (
<div <div
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-brand-base bg-brand-surface-2 p-4 shadow ${ className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-brand-base bg-brand-base p-4 shadow ${
isOpen ? "block" : "hidden" isOpen ? "block" : "hidden"
}`} }`}
> >
{((content && content !== "") || htmlContent !== "<p></p>") && ( {((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
<div className="remirror-section text-sm"> <div className="remirror-section text-sm">
Content: Content:
<RemirrorRichTextEditor <WrappedRemirrorRichTextEditor
value={htmlContent ?? <p>{content}</p>} value={htmlContent ?? <p>{content}</p>}
customClassName="-m-3" customClassName="-m-3"
noBorder noBorder
borderOnFocus={false} borderOnFocus={false}
editable={false} editable={false}
ref={editorRef}
/> />
</div> </div>
)} )}
{response !== "" && ( {response !== "" && (
<div className="text-sm page-block-section"> <div className="page-block-section text-sm">
Response: Response:
<RemirrorRichTextEditor <RemirrorRichTextEditor
value={`<p>${response}</p>`} value={`<p>${response}</p>`}

View File

@ -110,7 +110,7 @@ export const ImageUploadModal: React.FC<Props> = ({
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" /> <div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto"> <div className="fixed inset-0 z-30 overflow-y-auto">
@ -124,7 +124,7 @@ export const ImageUploadModal: React.FC<Props> = ({
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-brand-surface-2 px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-xl sm:p-6"> <Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-brand-base bg-brand-base px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-xl sm:p-6">
<div className="space-y-5"> <div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base"> <Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base">
Upload Image Upload Image
@ -133,9 +133,9 @@ export const ImageUploadModal: React.FC<Props> = ({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
{...getRootProps()} {...getRootProps()}
className={`relative block h-80 w-full rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${ className={`relative grid h-80 w-full cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-brand-accent focus:ring-offset-2 ${
(image === null && isDragActive) || !value (image === null && isDragActive) || !value
? "border-2 border-dashed border-brand-base hover:border-gray-400" ? "border-2 border-dashed border-brand-base hover:bg-brand-surface-1"
: "" : ""
}`} }`}
> >
@ -143,7 +143,7 @@ export const ImageUploadModal: React.FC<Props> = ({
<> <>
<button <button
type="button" type="button"
className="absolute top-0 right-0 z-40 translate-x-1/2 -translate-y-1/2 rounded bg-brand-surface-1 px-2 py-0.5 text-xs font-medium text-gray-600" className="absolute top-0 right-0 z-40 translate-x-1/2 -translate-y-1/2 rounded bg-brand-surface-1 px-2 py-0.5 text-xs font-medium text-brand-secondary"
> >
Edit Edit
</button> </button>
@ -152,17 +152,18 @@ export const ImageUploadModal: React.FC<Props> = ({
objectFit="cover" objectFit="cover"
src={image ? URL.createObjectURL(image) : value ? value : ""} src={image ? URL.createObjectURL(image) : value ? value : ""}
alt="image" alt="image"
className="rounded-lg"
/> />
</> </>
) : ( ) : (
<> <div>
<UserCircleIcon className="mx-auto h-16 w-16 text-gray-400" /> <UserCircleIcon className="mx-auto h-16 w-16 text-brand-secondary" />
<span className="mt-2 block text-sm font-medium text-brand-base"> <span className="mt-2 block text-sm font-medium text-brand-secondary">
{isDragActive {isDragActive
? "Drop image here to upload" ? "Drop image here to upload"
: "Drag & drop image here"} : "Drag & drop image here"}
</span> </span>
</> </div>
)} )}
<input {...getInputProps()} type="text" /> <input {...getInputProps()} type="text" />

View File

@ -1,14 +1,18 @@
export * from "./board-view"; export * from "./board-view";
export * from "./calendar-view";
export * from "./gantt-chart-view";
export * from "./list-view"; export * from "./list-view";
export * from "./sidebar"; export * from "./sidebar";
export * from "./bulk-delete-issues-modal"; export * from "./bulk-delete-issues-modal";
export * from "./existing-issues-list-modal"; export * from "./existing-issues-list-modal";
export * from "./filters-list";
export * from "./gpt-assistant-modal"; export * from "./gpt-assistant-modal";
export * from "./image-upload-modal"; export * from "./image-upload-modal";
export * from "./issues-view-filter"; export * from "./issues-view-filter";
export * from "./issues-view"; export * from "./issues-view";
export * from "./link-modal"; export * from "./link-modal";
export * from "./image-picker-popover"; export * from "./image-picker-popover";
export * from "./filter-list";
export * from "./feeds"; export * from "./feeds";
export * from "./theme-switch"; export * from "./theme-switch";
export * from "./custom-theme-selector";
export * from "./color-picker-input";

View File

@ -17,6 +17,7 @@ import {
ListBulletIcon, ListBulletIcon,
Squares2X2Icon, Squares2X2Icon,
CalendarDaysIcon, CalendarDaysIcon,
ChartBarIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// helpers // helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
@ -82,6 +83,17 @@ export const IssuesFilterView: React.FC = () => {
> >
<CalendarDaysIcon className="h-4 w-4 text-brand-secondary" /> <CalendarDaysIcon className="h-4 w-4 text-brand-secondary" />
</button> </button>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded outline-none duration-300 hover:bg-brand-surface-2 ${
issueView === "gantt_chart" ? "bg-brand-surface-2" : ""
}`}
onClick={() => setIssueView("gantt_chart")}
>
<span className="material-symbols-rounded text-brand-secondary text-[18px] rotate-90">
waterfall_chart
</span>
</button>
</div> </div>
<SelectFilters <SelectFilters
filters={filters} filters={filters}
@ -134,7 +146,7 @@ export const IssuesFilterView: React.FC = () => {
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform overflow-hidden rounded-lg border border-brand-base bg-brand-surface-1 p-3 shadow-lg"> <Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform rounded-lg border border-brand-base bg-brand-surface-1 p-3 shadow-lg">
<div className="relative divide-y-2 divide-brand-base"> <div className="relative divide-y-2 divide-brand-base">
<div className="space-y-4 pb-3 text-xs"> <div className="space-y-4 pb-3 text-xs">
{issueView !== "calendar" && ( {issueView !== "calendar" && (
@ -233,31 +245,30 @@ export const IssuesFilterView: React.FC = () => {
</> </>
)} )}
</div> </div>
{issueView !== "calendar" && (
<div className="space-y-2 py-3">
<h4 className="text-sm text-brand-secondary">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => {
if (key === "estimate" && !isEstimateActive) return null;
return ( <div className="space-y-2 py-3">
<button <h4 className="text-sm text-brand-secondary">Display Properties</h4>
key={key} <div className="flex flex-wrap items-center gap-2">
type="button" {Object.keys(properties).map((key) => {
className={`rounded border px-2 py-1 text-xs capitalize ${ if (key === "estimate" && !isEstimateActive) return null;
properties[key as keyof Properties]
? "border-brand-accent bg-brand-accent text-white" return (
: "border-brand-base" <button
}`} key={key}
onClick={() => setProperties(key as keyof Properties)} type="button"
> className={`rounded border px-2 py-1 text-xs capitalize ${
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)} properties[key as keyof Properties]
</button> ? "border-brand-accent bg-brand-accent text-white"
); : "border-brand-base"
})} }`}
</div> onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div> </div>
)} </div>
</div> </div>
</Popover.Panel> </Popover.Panel>
</Transition> </Transition>

View File

@ -6,6 +6,7 @@ import useSWR, { mutate } from "swr";
// react-beautiful-dnd // react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd"; import { DragDropContext, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
import stateService from "services/state.service"; import stateService from "services/state.service";
@ -17,14 +18,13 @@ import { useProjectMyMembership } from "contexts/project-member.context";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view"; import useIssuesView from "hooks/use-issues-view";
// components // components
import { AllLists, AllBoards, FilterList } from "components/core"; import { AllLists, AllBoards, FilterList, CalendarView, GanttChartView } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { CreateUpdateViewModal } from "components/views"; import { CreateUpdateViewModal } from "components/views";
import { TransferIssues, TransferIssuesModal } from "components/cycles"; import { CycleIssuesGanttChartView, TransferIssues, TransferIssuesModal } from "components/cycles";
import { IssueGanttChartView } from "components/issues/gantt-chart";
// ui // ui
import { EmptySpace, EmptySpaceItem, EmptyState, PrimaryButton, Spinner } from "components/ui"; import { EmptySpace, EmptySpaceItem, EmptyState, PrimaryButton, Spinner } from "components/ui";
import { CalendarView } from "./calendar-view";
// icons // icons
import { import {
ListBulletIcon, ListBulletIcon,
@ -48,7 +48,7 @@ import {
PROJECT_ISSUES_LIST_WITH_PARAMS, PROJECT_ISSUES_LIST_WITH_PARAMS,
STATES_LIST, STATES_LIST,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
// image import { ModuleIssuesGanttChartView } from "components/modules";
type Props = { type Props = {
type?: "issue" | "cycle" | "module"; type?: "issue" | "cycle" | "module";
@ -353,7 +353,7 @@ export const IssuesView: React.FC<Props> = ({
console.log(e); console.log(e);
}); });
}, },
[workspaceSlug, projectId, cycleId, params] [workspaceSlug, projectId, cycleId, params, selectedGroup, setToastAlert]
); );
const removeIssueFromModule = useCallback( const removeIssueFromModule = useCallback(
@ -396,7 +396,7 @@ export const IssuesView: React.FC<Props> = ({
console.log(e); console.log(e);
}); });
}, },
[workspaceSlug, projectId, moduleId, params] [workspaceSlug, projectId, moduleId, params, selectedGroup, setToastAlert]
); );
const handleTrashBox = useCallback( const handleTrashBox = useCallback(
@ -442,14 +442,10 @@ export const IssuesView: React.FC<Props> = ({
handleClose={() => setTransferIssuesModal(false)} handleClose={() => setTransferIssuesModal(false)}
isOpen={transferIssuesModal} isOpen={transferIssuesModal}
/> />
<> {areFiltersApplied && (
<div <>
className={`flex items-center justify-between gap-2 ${ <div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
issueView === "list" ? (areFiltersApplied ? "mt-6 px-8" : "") : "-mt-2" <FilterList filters={filters} setFilters={setFilters} />
}`}
>
<FilterList filters={filters} setFilters={setFilters} />
{areFiltersApplied && (
<PrimaryButton <PrimaryButton
onClick={() => { onClick={() => {
if (viewId) { if (viewId) {
@ -469,12 +465,10 @@ export const IssuesView: React.FC<Props> = ({
{!viewId && <PlusIcon className="h-4 w-4" />} {!viewId && <PlusIcon className="h-4 w-4" />}
{viewId ? "Update" : "Save"} view {viewId ? "Update" : "Save"} view
</PrimaryButton> </PrimaryButton>
)} </div>
</div> {<div className="mt-3 border-t border-brand-base" />}
{areFiltersApplied && ( </>
<div className={`${issueView === "list" ? "mt-4" : "my-4"} border-t border-brand-base`} /> )}
)}
</>
<DragDropContext onDragEnd={handleOnDragEnd}> <DragDropContext onDragEnd={handleOnDragEnd}>
<StrictModeDroppable droppableId="trashBox"> <StrictModeDroppable droppableId="trashBox">
@ -482,14 +476,14 @@ export const IssuesView: React.FC<Props> = ({
<div <div
className={`${ className={`${
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0" trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
} fixed top-9 right-9 z-30 flex h-28 w-96 flex-col items-center justify-center gap-2 rounded border-2 border-red-500/20 bg-red-500/20 p-3 text-xs font-medium italic text-red-500 ${ } fixed top-4 left-1/2 -translate-x-1/2 z-40 w-72 flex items-center justify-center gap-2 rounded border-2 border-red-500/20 bg-brand-base px-3 py-5 text-xs font-medium italic text-red-500 ${
snapshot.isDraggingOver ? "bg-red-500/100 text-white" : "" snapshot.isDraggingOver ? "bg-red-500 blur-2xl opacity-70" : ""
} duration-200`} } transition duration-300`}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.droppableProps} {...provided.droppableProps}
> >
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
Drop issue here to delete Drop here to delete the issue.
</div> </div>
)} )}
</StrictModeDroppable> </StrictModeDroppable>
@ -536,8 +530,16 @@ export const IssuesView: React.FC<Props> = ({
isCompleted={isCompleted} isCompleted={isCompleted}
userAuth={memberRole} userAuth={memberRole}
/> />
) : issueView === "calendar" ? (
<CalendarView
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
addIssueToDate={addIssueToDate}
isCompleted={isCompleted}
userAuth={memberRole}
/>
) : ( ) : (
<CalendarView addIssueToDate={addIssueToDate} /> issueView === "gantt_chart" && <GanttChartView />
)} )}
</> </>
) : type === "issue" ? ( ) : type === "issue" ? (

View File

@ -31,6 +31,7 @@ import {
ArrowTopRightOnSquareIcon, ArrowTopRightOnSquareIcon,
PaperClipIcon, PaperClipIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// helpers // helpers
import { copyTextToClipboard, truncateText } from "helpers/string.helper"; import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import { handleIssuesMutation } from "constants/issue"; import { handleIssuesMutation } from "constants/issue";
@ -84,7 +85,7 @@ export const SingleListIssue: React.FC<Props> = ({
const { groupByProperty: selectedGroup, orderBy, params } = useIssueView(); const { groupByProperty: selectedGroup, orderBy, params } = useIssueView();
const partialUpdateIssue = useCallback( const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => { (formData: Partial<IIssue>, issueId: string) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
if (cycleId) if (cycleId)
@ -140,7 +141,7 @@ export const SingleListIssue: React.FC<Props> = ({
); );
issuesService issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData) .patchIssue(workspaceSlug as string, projectId as string, issueId, formData)
.then(() => { .then(() => {
if (cycleId) { if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)); mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
@ -151,18 +152,7 @@ export const SingleListIssue: React.FC<Props> = ({
} else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params)); } else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
}); });
}, },
[ [workspaceSlug, projectId, cycleId, moduleId, groupTitle, index, selectedGroup, orderBy, params]
workspaceSlug,
projectId,
cycleId,
moduleId,
issue,
groupTitle,
index,
selectedGroup,
orderBy,
params,
]
); );
const handleCopyText = () => { const handleCopyText = () => {
@ -216,7 +206,7 @@ export const SingleListIssue: React.FC<Props> = ({
</a> </a>
</ContextMenu> </ContextMenu>
<div <div
className="flex flex-wrap items-center justify-between gap-2 border-b border-brand-base bg-brand-base last:border-b-0" className="flex flex-wrap items-center justify-between px-4 py-2.5 gap-2 border-b border-brand-base bg-brand-base last:border-b-0"
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
setContextMenu(true); setContextMenu(true);
@ -224,7 +214,7 @@ export const SingleListIssue: React.FC<Props> = ({
}} }}
> >
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}> <Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<div className="flex-grow cursor-pointer px-4 pt-2.5 md:py-2.5"> <div className="flex-grow cursor-pointer">
<a className="group relative flex items-center gap-2"> <a className="group relative flex items-center gap-2">
{properties.key && ( {properties.key && (
<Tooltip <Tooltip
@ -245,7 +235,7 @@ export const SingleListIssue: React.FC<Props> = ({
</div> </div>
</Link> </Link>
<div className="flex w-full flex-shrink flex-wrap items-center gap-2 px-4 pb-2.5 text-xs sm:w-auto md:px-0 md:py-2.5 md:pr-4"> <div className="flex w-full flex-shrink flex-wrap items-center gap-2 text-xs sm:w-auto">
{properties.priority && ( {properties.priority && (
<ViewPrioritySelect <ViewPrioritySelect
issue={issue} issue={issue}
@ -269,11 +259,6 @@ export const SingleListIssue: React.FC<Props> = ({
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
/> />
)} )}
{properties.sub_issue_count && (
<div className="flex items-center gap-1 rounded-md border border-brand-base px-2 py-1 text-xs text-brand-secondary shadow-sm">
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.labels && issue.label_details.length > 0 ? ( {properties.labels && issue.label_details.length > 0 ? (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => ( {issue.label_details.map((label) => (
@ -310,11 +295,21 @@ export const SingleListIssue: React.FC<Props> = ({
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
/> />
)} )}
{properties.sub_issue_count && (
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}>
<div className="flex items-center gap-1 text-brand-secondary">
<LayerDiagonalIcon className="h-3.5 w-3.5" />
{issue.sub_issues_count}
</div>
</Tooltip>
</div>
)}
{properties.link && ( {properties.link && (
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm"> <div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}> <Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
<div className="flex items-center gap-1 text-brand-secondary"> <div className="flex items-center gap-1 text-brand-secondary">
<LinkIcon className="h-3.5 w-3.5 text-brand-secondary" /> <LinkIcon className="h-3.5 w-3.5" />
{issue.link_count} {issue.link_count}
</div> </div>
</Tooltip> </Tooltip>
@ -324,7 +319,7 @@ export const SingleListIssue: React.FC<Props> = ({
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm"> <div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}> <Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
<div className="flex items-center gap-1 text-brand-secondary"> <div className="flex items-center gap-1 text-brand-secondary">
<PaperClipIcon className="h-3.5 w-3.5 -rotate-45 text-brand-secondary" /> <PaperClipIcon className="h-3.5 w-3.5 -rotate-45" />
{issue.attachment_count} {issue.attachment_count}
</div> </div>
</Tooltip> </Tooltip>

View File

@ -168,7 +168,7 @@ export const SingleList: React.FC<Props> = ({
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</div> </div>
} }
optionsPosition="right" position="right"
noBorder noBorder
> >
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem> <CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
@ -204,7 +204,8 @@ export const SingleList: React.FC<Props> = ({
makeIssueCopy={() => makeIssueCopy(issue)} makeIssueCopy={() => makeIssueCopy(issue)}
handleDeleteIssue={handleDeleteIssue} handleDeleteIssue={handleDeleteIssue}
removeIssue={() => { removeIssue={() => {
if (removeIssue !== null && issue.bridge_id) removeIssue(issue.bridge_id, issue.id); if (removeIssue !== null && issue.bridge_id)
removeIssue(issue.bridge_id, issue.id);
}} }}
isCompleted={isCompleted} isCompleted={isCompleted}
userAuth={userAuth} userAuth={userAuth}

View File

@ -12,9 +12,11 @@ type Props = {
issues: IIssue[]; issues: IIssue[];
start: string; start: string;
end: string; end: string;
width?: number;
height?: number;
}; };
const ProgressChart: React.FC<Props> = ({ issues, start, end }) => { const ProgressChart: React.FC<Props> = ({ issues, start, end, width = 360, height = 160 }) => {
const startDate = new Date(start); const startDate = new Date(start);
const endDate = new Date(end); const endDate = new Date(end);
const getChartData = () => { const getChartData = () => {
@ -51,8 +53,8 @@ const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
return ( return (
<div className="absolute -left-4 flex h-full w-full items-center justify-center text-xs"> <div className="absolute -left-4 flex h-full w-full items-center justify-center text-xs">
<AreaChart <AreaChart
width={360} width={width}
height={160} height={height}
data={ChartData} data={ChartData}
margin={{ margin={{
top: 12, top: 12,

View File

@ -29,6 +29,8 @@ type Props = {
issues: IIssue[]; issues: IIssue[];
module?: IModule; module?: IModule;
userAuth?: UserAuth; userAuth?: UserAuth;
roundedTab?: boolean;
noBackground?: boolean;
}; };
const stateGroupColours: { const stateGroupColours: {
@ -46,6 +48,8 @@ export const SidebarProgressStats: React.FC<Props> = ({
issues, issues,
module, module,
userAuth, userAuth,
roundedTab,
noBackground,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -100,12 +104,16 @@ export const SidebarProgressStats: React.FC<Props> = ({
> >
<Tab.List <Tab.List
as="div" as="div"
className={`flex w-full items-center justify-between rounded-md bg-brand-surface-1 px-1 py-1.5 className={`flex w-full items-center gap-2 justify-between rounded-md ${
noBackground ? "" : "bg-brand-surface-1"
} px-1 py-1.5
${module ? "text-xs" : "text-sm"} `} ${module ? "text-xs" : "text-sm"} `}
> >
<Tab <Tab
className={({ selected }) => className={({ selected }) =>
`w-full rounded px-3 py-1 text-brand-base ${ `w-full ${
roundedTab ? "rounded-3xl border border-brand-base" : "rounded"
} px-3 py-1 text-brand-base ${
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2" selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
}` }`
} }
@ -114,7 +122,9 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab> </Tab>
<Tab <Tab
className={({ selected }) => className={({ selected }) =>
`w-full rounded px-3 py-1 text-brand-base ${ `w-full ${
roundedTab ? "rounded-3xl border border-brand-base" : "rounded"
} px-3 py-1 text-brand-base ${
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2" selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
}` }`
} }
@ -123,7 +133,9 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab> </Tab>
<Tab <Tab
className={({ selected }) => className={({ selected }) =>
`w-full rounded px-3 py-1 text-brand-base ${ `w-full ${
roundedTab ? "rounded-3xl border border-brand-base" : "rounded"
} px-3 py-1 text-brand-base ${
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2" selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
}` }`
} }
@ -131,10 +143,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
States States
</Tab> </Tab>
</Tab.List> </Tab.List>
<Tab.Panels className="flex w-full items-center justify-between pt-1"> <Tab.Panels className="flex w-full items-center justify-between pt-1 text-brand-secondary">
<Tab.Panel as="div" className="flex w-full flex-col text-xs"> <Tab.Panel as="div" className="flex w-full flex-col text-xs">
{members?.map((member, index) => { {members?.map((member, index) => {
const totalArray = issues?.filter((i) => i.assignees?.includes(member.member.id)); const totalArray = issues?.filter((i) => i?.assignees?.includes(member.member.id));
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
if (totalArray.length > 0) { if (totalArray.length > 0) {
@ -150,19 +162,19 @@ export const SidebarProgressStats: React.FC<Props> = ({
completed={completeArray.length} completed={completeArray.length}
total={totalArray.length} total={totalArray.length}
onClick={() => { onClick={() => {
if (filters.assignees?.includes(member.member.id)) if (filters?.assignees?.includes(member.member.id))
setFilters({ setFilters({
assignees: filters.assignees?.filter((a) => a !== member.member.id), assignees: filters?.assignees?.filter((a) => a !== member.member.id),
}); });
else else
setFilters({ assignees: [...(filters?.assignees ?? []), member.member.id] }); setFilters({ assignees: [...(filters?.assignees ?? []), member.member.id] });
}} }}
selected={filters.assignees?.includes(member.member.id)} selected={filters?.assignees?.includes(member.member.id)}
/> />
); );
} }
})} })}
{issues?.filter((i) => i.assignees?.length === 0).length > 0 ? ( {issues?.filter((i) => i?.assignees?.length === 0).length > 0 ? (
<SingleProgressStats <SingleProgressStats
title={ title={
<> <>
@ -180,10 +192,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
} }
completed={ completed={
issues?.filter( issues?.filter(
(i) => i.state_detail.group === "completed" && i.assignees?.length === 0 (i) => i?.state_detail.group === "completed" && i.assignees?.length === 0
).length ).length
} }
total={issues?.filter((i) => i.assignees?.length === 0).length} total={issues?.filter((i) => i?.assignees?.length === 0).length}
/> />
) : ( ) : (
"" ""
@ -191,8 +203,8 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="w-full space-y-1"> <Tab.Panel as="div" className="w-full space-y-1">
{issueLabels?.map((label, index) => { {issueLabels?.map((label, index) => {
const totalArray = issues?.filter((i) => i.labels?.includes(label.id)); const totalArray = issues?.filter((i) => i?.labels?.includes(label.id));
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); const completeArray = totalArray?.filter((i) => i?.state_detail.group === "completed");
if (totalArray.length > 0) { if (totalArray.length > 0) {
return ( return (
@ -207,7 +219,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
label.color && label.color !== "" ? label.color : "#000000", label.color && label.color !== "" ? label.color : "#000000",
}} }}
/> />
<span className="text-xs capitalize">{label.name}</span> <span className="text-xs capitalize">{label?.name}</span>
</div> </div>
} }
completed={completeArray.length} completed={completeArray.length}
@ -215,11 +227,11 @@ export const SidebarProgressStats: React.FC<Props> = ({
onClick={() => { onClick={() => {
if (filters.labels?.includes(label.id)) if (filters.labels?.includes(label.id))
setFilters({ setFilters({
labels: filters.labels?.filter((l) => l !== label.id), labels: filters?.labels?.filter((l) => l !== label.id),
}); });
else setFilters({ labels: [...(filters?.labels ?? []), label.id] }); else setFilters({ labels: [...(filters?.labels ?? []), label.id] });
}} }}
selected={filters.labels?.includes(label.id)} selected={filters?.labels?.includes(label.id)}
/> />
); );
} }

View File

@ -18,7 +18,7 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
selected = false, selected = false,
}) => ( }) => (
<div <div
className={`flex w-full items-center justify-between rounded p-2 text-xs ${ className={`flex w-full items-center gap-4 justify-between rounded-sm p-1 text-xs ${
onClick ? "cursor-pointer hover:bg-brand-surface-1" : "" onClick ? "cursor-pointer hover:bg-brand-surface-1" : ""
} ${selected ? "bg-brand-surface-1" : ""}`} } ${selected ? "bg-brand-surface-1" : ""}`}
onClick={onClick} onClick={onClick}
@ -29,7 +29,12 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
<span className="h-4 w-4 "> <span className="h-4 w-4 ">
<ProgressBar value={completed} maxValue={total} /> <ProgressBar value={completed} maxValue={total} />
</span> </span>
<span className="w-8 text-right">{Math.floor((completed / total) * 100)}%</span> <span className="w-8 text-right">
{isNaN(Math.floor((completed / total) * 100))
? "0"
: Math.floor((completed / total) * 100)}
%
</span>
</div> </div>
<span>of</span> <span>of</span>
<span>{total}</span> <span>{total}</span>

View File

@ -1,12 +1,30 @@
import { useState, useEffect, ChangeEvent } from "react"; import { useState, useEffect, Dispatch, SetStateAction } from "react";
import { useTheme } from "next-themes";
import { THEMES_OBJ } from "constants/themes";
import { CustomSelect } from "components/ui";
import { CustomThemeModal } from "./custom-theme-modal";
export const ThemeSwitch = () => { import { useTheme } from "next-themes";
// constants
import { THEMES_OBJ } from "constants/themes";
// ui
import { CustomSelect } from "components/ui";
// helper
import { applyTheme } from "helpers/theme.helper";
// types
import { ICustomTheme, IUser } from "types";
type Props = {
user: IUser | undefined;
setPreLoadedData: Dispatch<SetStateAction<ICustomTheme | null>>;
customThemeSelectorOptions: boolean;
setCustomThemeSelectorOptions: Dispatch<SetStateAction<boolean>>;
};
export const ThemeSwitch: React.FC<Props> = ({
user,
setPreLoadedData,
customThemeSelectorOptions,
setCustomThemeSelectorOptions,
}) => {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [customThemeModal, setCustomThemeModal] = useState(false);
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
// useEffect only runs on the client, so now we can safely show the UI // useEffect only runs on the client, so now we can safely show the UI
@ -18,15 +36,49 @@ export const ThemeSwitch = () => {
return null; return null;
} }
const currentThemeObj = THEMES_OBJ.find((t) => t.value === theme);
return ( return (
<> <>
<CustomSelect <CustomSelect
value={theme} value={theme}
label={theme ? THEMES_OBJ.find((t) => t.value === theme)?.label : "Select your theme"} label={
currentThemeObj ? (
<div className="flex items-center gap-2">
<div
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
style={{
borderColor: currentThemeObj.icon.border,
}}
>
<div
className="h-full w-1/2 rounded-l-full"
style={{
background: currentThemeObj.icon.color1,
}}
/>
<div
className="h-full w-1/2 rounded-r-full border-l"
style={{
borderLeftColor: currentThemeObj.icon.border,
background: currentThemeObj.icon.color2,
}}
/>
</div>
{currentThemeObj.label}
</div>
) : (
"Select your theme"
)
}
onChange={({ value, type }: { value: string; type: string }) => { onChange={({ value, type }: { value: string; type: string }) => {
if (value === "custom") { if (value === "custom") {
if (!customThemeModal) setCustomThemeModal(true); if (user?.theme.palette) {
setPreLoadedData(user.theme);
}
if (!customThemeSelectorOptions) setCustomThemeSelectorOptions(true);
} else { } else {
if (customThemeSelectorOptions) setCustomThemeSelectorOptions(false);
const cssVars = [ const cssVars = [
"--color-bg-base", "--color-bg-base",
"--color-bg-surface-1", "--color-bg-surface-1",
@ -41,20 +93,41 @@ export const ThemeSwitch = () => {
]; ];
cssVars.forEach((cssVar) => document.documentElement.style.removeProperty(cssVar)); cssVars.forEach((cssVar) => document.documentElement.style.removeProperty(cssVar));
} }
document.documentElement.style.setProperty("color-scheme", type);
setTheme(value); setTheme(value);
document.documentElement.style.setProperty("color-scheme", type);
}} }}
input input
width="w-full" width="w-full"
position="right" position="right"
> >
{THEMES_OBJ.map(({ value, label, type }) => ( {THEMES_OBJ.map(({ value, label, type, icon }) => (
<CustomSelect.Option key={value} value={{ value, type }}> <CustomSelect.Option key={value} value={{ value, type }}>
{label} <div className="flex items-center gap-2">
<div
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
style={{
borderColor: icon.border,
}}
>
<div
className="h-full w-1/2 rounded-l-full"
style={{
background: icon.color1,
}}
/>
<div
className="h-full w-1/2 rounded-r-full border-l"
style={{
borderLeftColor: icon.border,
background: icon.color2,
}}
/>
</div>
{label}
</div>
</CustomSelect.Option> </CustomSelect.Option>
))} ))}
</CustomSelect> </CustomSelect>
{/* <CustomThemeModal isOpen={customThemeModal} handleClose={() => setCustomThemeModal(false)} /> */}
</> </>
); );
}; };

View File

@ -0,0 +1,623 @@
import React from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// services
import cyclesService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { LinearProgressIndicator, Tooltip } from "components/ui";
import { AssigneesList } from "components/ui/avatar";
import { SingleProgressStats } from "components/core";
// components
import ProgressChart from "components/core/sidebar/progress-chart";
import { ActiveCycleProgressStats } from "components/cycles";
// icons
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
import { getPriorityIcon } from "components/icons/priority-icon";
import {
TargetIcon,
ContrastIcon,
PersonRunningIcon,
ArrowRightIcon,
TriangleExclamationIcon,
AlarmClockIcon,
LayerDiagonalIcon,
CompletedStateIcon,
} from "components/icons";
import { StarIcon } from "@heroicons/react/24/outline";
// helpers
import {
getDateRangeStatus,
renderShortDateWithYearFormat,
findHowManyDaysLeft,
} from "helpers/date-time.helper";
import { truncateText } from "helpers/string.helper";
// types
import {
CompletedCyclesResponse,
CurrentAndUpcomingCyclesResponse,
DraftCyclesResponse,
ICycle,
IIssue,
} from "types";
// fetch-keys
import {
CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DETAILS,
CYCLE_DRAFT_LIST,
CYCLE_ISSUES,
} from "constants/fetch-keys";
type TSingleStatProps = {
cycle: ICycle;
isCompleted?: boolean;
};
const stateGroups = [
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
export const ActiveCycleDetails: React.FC<TSingleStatProps> = ({ cycle, isCompleted = false }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
const groupedIssues: any = {
backlog: cycle.backlog_issues,
unstarted: cycle.unstarted_issues,
started: cycle.started_issues,
completed: cycle.completed_issues,
cancelled: cycle.cancelled_issues,
};
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId || !cycle) return;
switch (cycleStatus) {
case "current":
case "upcoming":
mutate<CurrentAndUpcomingCyclesResponse>(
CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string),
(prevData) => ({
current_cycle: (prevData?.current_cycle ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
upcoming_cycle: (prevData?.upcoming_cycle ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
}),
false
);
break;
case "completed":
mutate<CompletedCyclesResponse>(
CYCLE_COMPLETE_LIST(projectId as string),
(prevData) => ({
completed_cycles: (prevData?.completed_cycles ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
}),
false
);
break;
case "draft":
mutate<DraftCyclesResponse>(
CYCLE_DRAFT_LIST(projectId as string),
(prevData) => ({
draft_cycles: (prevData?.draft_cycles ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
}),
false
);
break;
}
mutate(
CYCLE_DETAILS(projectId as string),
(prevData: any) =>
(prevData ?? []).map((c: any) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
false
);
cyclesService
.addCycleToFavorites(workspaceSlug as string, projectId as string, {
cycle: cycle.id,
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !projectId || !cycle) return;
switch (cycleStatus) {
case "current":
case "upcoming":
mutate<CurrentAndUpcomingCyclesResponse>(
CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string),
(prevData) => ({
current_cycle: (prevData?.current_cycle ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
upcoming_cycle: (prevData?.upcoming_cycle ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
}),
false
);
break;
case "completed":
mutate<CompletedCyclesResponse>(
CYCLE_COMPLETE_LIST(projectId as string),
(prevData) => ({
completed_cycles: (prevData?.completed_cycles ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
}),
false
);
break;
case "draft":
mutate<DraftCyclesResponse>(
CYCLE_DRAFT_LIST(projectId as string),
(prevData) => ({
draft_cycles: (prevData?.draft_cycles ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
}),
false
);
break;
}
mutate(
CYCLE_DETAILS(projectId as string),
(prevData: any) =>
(prevData ?? []).map((c: any) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
false
);
cyclesService
.removeCycleFromFavorites(workspaceSlug as string, projectId as string, cycle.id)
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the cycle from favorites. Please try again.",
});
});
};
const { data: issues } = useSWR<IIssue[]>(
workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null,
workspaceSlug && projectId && cycle.id
? () =>
cyclesService.getCycleIssues(
workspaceSlug as string,
projectId as string,
cycle.id as string
)
: null
);
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
name: group.title,
value:
cycle.total_issues > 0
? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100
: 0,
color: group.color,
}));
return (
<div className="grid-row-2 grid rounded-[10px] shadow divide-y bg-brand-base border border-brand-base">
<div className="grid grid-cols-1 divide-y border-brand-base lg:divide-y-0 lg:divide-x lg:grid-cols-3">
<div className="flex flex-col text-xs">
<a className="h-full w-full">
<div className="flex h-60 flex-col gap-5 justify-between rounded-b-[10px] p-4">
<div className="flex items-center justify-between gap-1">
<span className="flex items-center gap-1">
<span className="h-5 w-5">
<ContrastIcon
className="h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "#858E96"
: ""
}`}
/>
</span>
<Tooltip tooltipContent={cycle.name} position="top-left">
<h3 className="break-all text-lg font-semibold">
{truncateText(cycle.name, 70)}
</h3>
</Tooltip>
</span>
<span className="flex items-center gap-1 capitalize">
<span
className={`rounded-full px-1.5 py-0.5
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
<PersonRunningIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1 whitespace-nowrap">
<AlarmClockIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${
cycle.total_issues - cycle.completed_issues
} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<TriangleExclamationIcon className="h-3.5 w-3.5 fill-current" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span>
{cycle.is_favorite ? (
<button
onClick={(e) => {
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button
onClick={(e) => {
e.preventDefault();
handleAddToFavorites();
}}
>
<StarIcon className="h-4 w-4 " color="#858E96" />
</button>
)}
</span>
</div>
<div className="flex items-center justify-start gap-5 text-brand-secondary">
<div className="flex items-start gap-1">
<CalendarDaysIcon className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRightIcon className="h-4 w-4 text-brand-secondary" />
<div className="flex items-start gap-1">
<TargetIcon className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5 text-brand-secondary">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<Image
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.first_name}
/>
) : (
<span className="bg-brand-secondary flex h-5 w-5 items-center justify-center rounded-full bg-brand-base capitalize">
{cycle.owned_by.first_name.charAt(0)}
</span>
)}
<span className="text-brand-secondary">{cycle.owned_by.first_name}</span>
</div>
{cycle.assignees.length > 0 && (
<div className="flex items-center gap-1 text-brand-secondary">
<AssigneesList users={cycle.assignees} length={4} />
</div>
)}
</div>
<div className="flex items-center gap-4 text-brand-secondary">
<div className="flex gap-2">
<LayerDiagonalIcon className="h-4 w-4 flex-shrink-0" />
{cycle.total_issues} issues
</div>
<div className="flex gap-2">
<CompletedStateIcon width={16} height={16} color="#438AF3" />
{cycle.completed_issues} issues
</div>
</div>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="bg-brand-accent text-white px-4 rounded-md py-2 text-center text-sm font-medium w-full hover:bg-brand-accent/90">
View Cycle
</a>
</Link>
</div>
</a>
</div>
<div className="grid col-span-2 grid-cols-1 divide-y border-brand-base md:divide-y-0 md:divide-x md:grid-cols-2">
<div className="flex h-60 flex-col border-brand-base">
<div className="flex h-full w-full flex-col text-brand-secondary p-4">
<div className="flex w-full items-center gap-2 py-1">
<span>Progress</span>
<LinearProgressIndicator data={progressIndicatorData} />
</div>
<div className="flex flex-col mt-2 gap-1 items-center">
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full "
style={{
backgroundColor: stateGroups[index].color,
}}
/>
<span className="text-xs capitalize">{group}</span>
</div>
}
completed={groupedIssues[group]}
total={cycle.total_issues}
/>
))}
</div>
</div>
</div>
<div className="border-brand-base h-60 overflow-y-scroll">
<ActiveCycleProgressStats issues={issues ?? []} />
</div>
</div>
</div>
<div className="grid grid-cols-1 divide-y border-brand-base lg:divide-y-0 lg:divide-x lg:grid-cols-2">
<div className="flex flex-col justify-between p-4">
<div>
<div className="text-brand-primary mb-2">High Priority Issues</div>
<div className="mb-2 flex max-h-[240px] min-h-[240px] flex-col gap-2.5 overflow-y-scroll rounded-md">
{issues
?.filter((issue) => issue.priority === "urgent" || issue.priority === "high")
.map((issue) => (
<div
key={issue.id}
className="flex flex-wrap rounded-md items-center justify-between gap-2 border border-brand-base bg-brand-surface-1 px-3 py-1.5"
>
<div className="flex flex-col gap-1">
<div>
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-brand-secondary">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
</div>
<Tooltip
position="top-left"
tooltipHeading="Title"
tooltipContent={issue.name}
>
<span className="text-[0.825rem] text-brand-base">
{truncateText(issue.name, 30)}
</span>
</Tooltip>
</div>
<div className="flex items-center gap-1.5">
<div
className={`grid h-6 w-6 place-items-center items-center rounded border shadow-sm ${
issue.priority === "urgent"
? "border-red-500/20 bg-red-500/20 text-red-500"
: issue.priority === "high"
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
: issue.priority === "medium"
? "border-yellow-500/20 bg-yellow-500/20 text-yellow-500"
: issue.priority === "low"
? "border-green-500/20 bg-green-500/20 text-green-500"
: "border-brand-base"
}`}
>
{getPriorityIcon(issue.priority, "text-sm")}
</div>
{issue.label_details.length > 0 ? (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
key={label.id}
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary"
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor:
label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</span>
))}
</div>
) : (
""
)}
<div className={`flex items-center gap-2 text-brand-secondary`}>
{issue.assignees &&
issue.assignees.length > 0 &&
Array.isArray(issue.assignees) ? (
<div className="-my-0.5 flex items-center justify-center gap-2">
<AssigneesList
userIds={issue.assignees}
length={3}
showLength={false}
/>
</div>
) : (
""
)}
</div>
</div>
</div>
))}
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="h-1 w-full rounded-full bg-brand-surface-2">
<div
className="h-1 rounded-full bg-green-600"
style={{
width:
issues &&
`${
(issues?.filter(
(issue) =>
issue?.state_detail?.group === "completed" &&
(issue?.priority === "urgent" || issue?.priority === "high")
)?.length /
issues?.filter(
(issue) => issue?.priority === "urgent" || issue?.priority === "high"
)?.length) *
100 ?? 0
}%`,
}}
/>
</div>
<div className="w-16 text-end text-xs text-brand-secondary">
{
issues?.filter(
(issue) =>
issue?.state_detail?.group === "completed" &&
(issue?.priority === "urgent" || issue?.priority === "high")
)?.length
}{" "}
of{" "}
{
issues?.filter(
(issue) => issue?.priority === "urgent" || issue?.priority === "high"
)?.length
}
</div>
</div>
</div>
<div className="flex flex-col justify-between border-brand-base p-4">
<div className="flex items-start justify-between gap-4 py-1.5 text-xs">
<div className="flex items-center gap-3 text-brand-base">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
<div className="flex items-center gap-1">
<span>
<LayerDiagonalIcon className="h-5 w-5 flex-shrink-0 text-brand-secondary" />
</span>
<span>
Pending Issues -{" "}
{cycle.total_issues - (cycle.completed_issues + cycle.cancelled_issues)}
</span>
</div>
</div>
<div className="relative h-64">
<ProgressChart
issues={issues ?? []}
start={cycle?.start_date ?? ""}
end={cycle?.end_date ?? ""}
width={475}
height={256}
/>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,185 @@
import React from "react";
import Image from "next/image";
import { useRouter } from "next/router";
import useSWR from "swr";
// headless ui
import { Tab } from "@headlessui/react";
// services
import issuesServices from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useLocalStorage from "hooks/use-local-storage";
// components
import { SingleProgressStats } from "components/core";
// ui
import { Avatar } from "components/ui";
// icons
import User from "public/user.png";
// types
import { IIssue, IIssueLabels } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
// types
type Props = {
issues: IIssue[];
};
export const ActiveCycleProgressStats: React.FC<Props> = ({ issues }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees");
const { data: issueLabels } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId
? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId as string)
: null
);
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(workspaceSlug as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const currentValue = (tab: string | null) => {
switch (tab) {
case "Assignees":
return 0;
case "Labels":
return 1;
default:
return 0;
}
};
return (
<Tab.Group
defaultIndex={currentValue(tab)}
onChange={(i) => {
switch (i) {
case 0:
return setTab("Assignees");
case 1:
return setTab("Labels");
default:
return setTab("Assignees");
}
}}
>
<Tab.List
as="div"
className="flex sticky top-0 z-10 bg-brand-base w-full px-4 pt-4 pb-1 flex-wrap items-center justify-start gap-4 text-sm"
>
<Tab
className={({ selected }) =>
`px-3 py-1 text-brand-base rounded-3xl border border-brand-base ${
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
}`
}
>
Assignees
</Tab>
<Tab
className={({ selected }) =>
`px-3 py-1 text-brand-base rounded-3xl border border-brand-base ${
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
}`
}
>
Labels
</Tab>
</Tab.List>
<Tab.Panels className="flex w-full px-4 pb-4">
<Tab.Panel
as="div"
className="flex flex-col w-full mt-2 gap-1 overflow-y-scroll items-center text-brand-secondary"
>
{members?.map((member, index) => {
const totalArray = issues?.filter((i) => i?.assignees?.includes(member.member.id));
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
if (totalArray.length > 0) {
return (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<Avatar user={member.member} />
<span>{member.member.first_name}</span>
</div>
}
completed={completeArray.length}
total={totalArray.length}
/>
);
}
})}
{issues?.filter((i) => i?.assignees?.length === 0).length > 0 ? (
<SingleProgressStats
title={
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full border-2 border-white bg-brand-surface-2">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="User"
/>
</div>
<span>No assignee</span>
</div>
}
completed={
issues?.filter(
(i) => i?.state_detail.group === "completed" && i.assignees?.length === 0
).length
}
total={issues?.filter((i) => i?.assignees?.length === 0).length}
/>
) : (
""
)}
</Tab.Panel>
<Tab.Panel
as="div"
className="flex flex-col w-full mt-2 gap-1 overflow-y-scroll items-center text-brand-secondary"
>
{issueLabels?.map((label, index) => {
const totalArray = issues?.filter((i) => i?.labels?.includes(label.id));
const completeArray = totalArray?.filter((i) => i?.state_detail.group === "completed");
if (totalArray.length > 0) {
return (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor:
label.color && label.color !== "" ? label.color : "#000000",
}}
/>
<span className="text-xs capitalize">{label?.name}</span>
</div>
}
completed={completeArray.length}
total={totalArray.length}
/>
);
}
})}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);
};

View File

@ -17,7 +17,7 @@ type TCycleStatsViewProps = {
type: "current" | "upcoming" | "draft"; type: "current" | "upcoming" | "draft";
}; };
export const CyclesList: React.FC<TCycleStatsViewProps> = ({ export const AllCyclesBoard: React.FC<TCycleStatsViewProps> = ({
cycles, cycles,
setCreateUpdateCycleModal, setCreateUpdateCycleModal,
setSelectedCycle, setSelectedCycle,
@ -61,7 +61,7 @@ export const CyclesList: React.FC<TCycleStatsViewProps> = ({
</div> </div>
) : type === "current" ? ( ) : type === "current" ? (
<div className="flex w-full items-center justify-start rounded-[10px] bg-brand-surface-2 px-6 py-4"> <div className="flex w-full items-center justify-start rounded-[10px] bg-brand-surface-2 px-6 py-4">
<h3 className="text-base font-medium text-brand-base ">No current cycle is present.</h3> <h3 className="text-base font-medium text-brand-base ">No cycle is present.</h3>
</div> </div>
) : ( ) : (
<EmptyState <EmptyState

View File

@ -0,0 +1,86 @@
import { useState } from "react";
// components
import { DeleteCycleModal, SingleCycleList } from "components/cycles";
import { EmptyState, Loader } from "components/ui";
// image
import emptyCycle from "public/empty-state/empty-cycle.svg";
// icon
import { XMarkIcon } from "@heroicons/react/24/outline";
// types
import { ICycle, SelectCycleType } from "types";
type TCycleStatsViewProps = {
cycles: ICycle[] | undefined;
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
type: "current" | "upcoming" | "draft";
};
export const AllCyclesList: React.FC<TCycleStatsViewProps> = ({
cycles,
setCreateUpdateCycleModal,
setSelectedCycle,
type,
}) => {
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectCycleType>();
const handleDeleteCycle = (cycle: ICycle) => {
setSelectedCycleForDelete({ ...cycle, actionType: "delete" });
setCycleDeleteModal(true);
};
const handleEditCycle = (cycle: ICycle) => {
setSelectedCycle({ ...cycle, actionType: "edit" });
setCreateUpdateCycleModal(true);
};
return (
<>
<DeleteCycleModal
isOpen={
cycleDeleteModal &&
!!selectedCycleForDelete &&
selectedCycleForDelete.actionType === "delete"
}
setIsOpen={setCycleDeleteModal}
data={selectedCycleForDelete}
/>
{cycles ? (
cycles.length > 0 ? (
<div>
{cycles.map((cycle) => (
<div className="hover:bg-brand-surface-2">
<div className="flex flex-col border-brand-base">
<SingleCycleList
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
/>
</div>
</div>
))}
</div>
) : type === "current" ? (
<div className="flex w-full items-center justify-start rounded-[10px] bg-brand-surface-2 px-6 py-4">
<h3 className="text-base font-medium text-brand-base ">No cycle is present.</h3>
</div>
) : (
<EmptyState
type="cycle"
title="Create New Cycle"
description="Sprint more effectively with Cycles by confining your project
to a fixed amount of time. Create new cycle now."
imgURL={emptyCycle}
/>
)
) : (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" />
</Loader>
)}
</>
);
};

View File

@ -7,9 +7,9 @@ import useSWR from "swr";
// services // services
import cyclesService from "services/cycles.service"; import cyclesService from "services/cycles.service";
// components // components
import { DeleteCycleModal, SingleCycleCard } from "components/cycles"; import { DeleteCycleModal, SingleCycleCard, SingleCycleList } from "components/cycles";
// icons // icons
import { CompletedCycleIcon, ExclamationIcon } from "components/icons"; import { ExclamationIcon } from "components/icons";
// types // types
import { ICycle, SelectCycleType } from "types"; import { ICycle, SelectCycleType } from "types";
// fetch-keys // fetch-keys
@ -19,11 +19,13 @@ import { EmptyState, Loader } from "components/ui";
import emptyCycle from "public/empty-state/empty-cycle.svg"; import emptyCycle from "public/empty-state/empty-cycle.svg";
export interface CompletedCyclesListProps { export interface CompletedCyclesListProps {
cycleView: string;
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>; setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>; setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
} }
export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({ export const CompletedCycles: React.FC<CompletedCyclesListProps> = ({
cycleView,
setCreateUpdateCycleModal, setCreateUpdateCycleModal,
setSelectedCycle, setSelectedCycle,
}) => { }) => {
@ -65,20 +67,51 @@ export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({
completedCycles.completed_cycles.length > 0 ? ( completedCycles.completed_cycles.length > 0 ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm text-brand-secondary"> <div className="flex items-center gap-2 text-sm text-brand-secondary">
<ExclamationIcon height={14} width={14} /> <ExclamationIcon
height={14}
width={14}
className="fill-current text-brand-secondary"
/>
<span>Completed cycles are not editable.</span> <span>Completed cycles are not editable.</span>
</div> </div>
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3"> {cycleView === "list" && (
{completedCycles.completed_cycles.map((cycle) => ( <div>
<SingleCycleCard {completedCycles.completed_cycles.map((cycle) => (
key={cycle.id} <div className="hover:bg-brand-surface-2">
cycle={cycle} <div className="flex flex-col border-brand-base">
handleDeleteCycle={() => handleDeleteCycle(cycle)} <SingleCycleList
handleEditCycle={() => handleEditCycle(cycle)} key={cycle.id}
isCompleted cycle={cycle}
/> handleDeleteCycle={() => handleDeleteCycle(cycle)}
))} handleEditCycle={() => handleEditCycle(cycle)}
</div> isCompleted
/>
</div>
</div>
))}
</div>
)}
{cycleView === "board" && (
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
{completedCycles.completed_cycles.map((cycle) => (
<SingleCycleCard
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
isCompleted
/>
))}
</div>
)}
{cycleView === "gantt" && (
<EmptyState
type="cycle"
title="Create New Cycle"
description="Sprint more effectively with Cycles by confining your project to a fixed amount of time. Create new cycle now."
imgURL={emptyCycle}
/>
)}
</div> </div>
) : ( ) : (
<EmptyState <EmptyState

View File

@ -0,0 +1,67 @@
import { FC } from "react";
// components
import { GanttChartRoot } from "components/gantt-chart";
// types
import { ICycle } from "types";
type Props = {
cycles: ICycle[];
};
export const CyclesListGanttChartView: FC<Props> = ({ cycles }) => {
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: "#858e96" }}
/>
<div className="text-brand-base text-sm">{data?.name}</div>
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: { data: ICycle }) => (
<div className="relative flex w-full h-full overflow-hidden">
<div className="flex-shrink-0 w-[4px] h-auto" style={{ backgroundColor: "#858e96" }} />
<div className="inline-block text-brand-base text-sm whitespace-nowrap py-[4px] px-1.5">
{data?.name}
</div>
</div>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
if (_block?.start_date && _block.target_date) console.log("_block", _block);
return {
start_date: new Date(_block.created_at),
target_date: new Date(_block.updated_at),
data: _block,
};
})
: [];
return (
<div className="w-full h-full overflow-y-auto">
<GanttChartRoot
title={"Cycles"}
loaderTitle="Cycles"
blocks={cycles ? blockFormat(cycles) : null}
blockUpdateHandler={handleUpdateDates}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
/>
</div>
);
};

View File

@ -0,0 +1,243 @@
import React, { useEffect } from "react";
import dynamic from "next/dynamic";
// headless ui
import { Tab } from "@headlessui/react";
// hooks
import useLocalStorage from "hooks/use-local-storage";
// components
import {
ActiveCycleDetails,
CompletedCyclesListProps,
AllCyclesBoard,
AllCyclesList,
CyclesListGanttChartView,
} from "components/cycles";
// ui
import { EmptyState, Loader } from "components/ui";
// icons
import { ChartBarIcon, ListBulletIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
import emptyCycle from "public/empty-state/empty-cycle.svg";
// types
import {
SelectCycleType,
ICycle,
CurrentAndUpcomingCyclesResponse,
DraftCyclesResponse,
} from "types";
type Props = {
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
cyclesCompleteList: ICycle[] | undefined;
currentAndUpcomingCycles: CurrentAndUpcomingCyclesResponse | undefined;
draftCycles: DraftCyclesResponse | undefined;
};
export const CyclesView: React.FC<Props> = ({
setSelectedCycle,
setCreateUpdateCycleModal,
cyclesCompleteList,
currentAndUpcomingCycles,
draftCycles,
}) => {
const { storedValue: cycleTab, setValue: setCycleTab } = useLocalStorage("cycleTab", "All");
const { storedValue: cyclesView, setValue: setCyclesView } = useLocalStorage("cycleView", "list");
const currentTabValue = (tab: string | null) => {
switch (tab) {
case "All":
return 0;
case "Active":
return 1;
case "Upcoming":
return 2;
case "Completed":
return 3;
case "Drafts":
return 4;
default:
return 0;
}
};
const CompletedCycles = dynamic<CompletedCyclesListProps>(
() => import("components/cycles").then((a) => a.CompletedCycles),
{
ssr: false,
loading: () => (
<Loader className="mb-5">
<Loader.Item height="12rem" width="100%" />
</Loader>
),
}
);
return (
<>
<div className="flex gap-4 justify-between">
<h3 className="text-2xl font-semibold text-brand-base">Cycles</h3>
<div className="flex items-center gap-x-1">
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-2 ${
cyclesView === "list" ? "bg-brand-surface-2" : ""
}`}
onClick={() => setCyclesView("list")}
>
<ListBulletIcon className="h-4 w-4 text-brand-secondary" />
</button>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-2 ${
cyclesView === "grid" ? "bg-brand-surface-2" : ""
}`}
onClick={() => setCyclesView("grid")}
>
<Squares2X2Icon className="h-4 w-4 text-brand-secondary" />
</button>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded outline-none duration-300 hover:bg-brand-surface-2 ${
cyclesView === "gantt_chart" ? "bg-brand-surface-2" : ""
}`}
onClick={() => setCyclesView("gantt_chart")}
>
<span className="material-symbols-rounded text-brand-secondary text-[18px] rotate-90">
waterfall_chart
</span>
</button>
</div>
</div>
<Tab.Group
as={React.Fragment}
defaultIndex={currentTabValue(cycleTab)}
onChange={(i) => {
switch (i) {
case 0:
return setCycleTab("All");
case 1:
return setCycleTab("Active");
case 2:
return setCycleTab("Upcoming");
case 3:
return setCycleTab("Completed");
case 4:
return setCycleTab("Drafts");
default:
return setCycleTab("All");
}
}}
>
<div className="flex justify-between">
<Tab.List as="div" className="flex flex-wrap items-center justify-start gap-4 text-base">
{["All", "Active", "Upcoming", "Completed", "Drafts"].map((tab, index) => {
if (cyclesView === "gantt_chart" && (tab === "Active" || tab === "Drafts"))
return null;
return (
<Tab
key={index}
className={({ selected }) =>
`rounded-3xl border px-6 py-1 outline-none ${
selected
? "border-brand-accent bg-brand-accent text-white font-medium"
: "border-brand-base bg-brand-base hover:bg-brand-surface-2"
}`
}
>
{tab}
</Tab>
);
})}
</Tab.List>
</div>
<Tab.Panels as={React.Fragment}>
<Tab.Panel as="div" className="mt-7 space-y-5 h-full overflow-y-auto">
{cyclesView === "list" && (
<AllCyclesList
cycles={cyclesCompleteList}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="current"
/>
)}
{cyclesView === "grid" && (
<AllCyclesBoard
cycles={cyclesCompleteList}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="current"
/>
)}
{cyclesView === "gantt_chart" && (
<CyclesListGanttChartView cycles={cyclesCompleteList ?? []} />
)}
</Tab.Panel>
{cyclesView !== "gantt_chart" && (
<Tab.Panel as="div" className="mt-7 space-y-5">
{currentAndUpcomingCycles?.current_cycle?.[0] ? (
<ActiveCycleDetails cycle={currentAndUpcomingCycles?.current_cycle?.[0]} />
) : (
<EmptyState
type="cycle"
title="Create New Cycle"
description="Sprint more effectively with Cycles by confining your project to a fixed amount of time. Create new cycle now."
imgURL={emptyCycle}
/>
)}
</Tab.Panel>
)}
<Tab.Panel as="div" className="mt-7 space-y-5 h-full overflow-y-auto">
{cyclesView === "list" && (
<AllCyclesList
cycles={currentAndUpcomingCycles?.upcoming_cycle}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="upcoming"
/>
)}
{cyclesView === "board" && (
<AllCyclesBoard
cycles={currentAndUpcomingCycles?.upcoming_cycle}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="upcoming"
/>
)}
{cyclesView === "gantt_chart" && (
<CyclesListGanttChartView cycles={currentAndUpcomingCycles?.upcoming_cycle ?? []} />
)}
</Tab.Panel>
<Tab.Panel as="div" className="mt-7 space-y-5">
<CompletedCycles
cycleView={cyclesView ?? "list"}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
/>
</Tab.Panel>
{cyclesView !== "gantt_chart" && (
<Tab.Panel as="div" className="mt-7 space-y-5">
{cyclesView === "list" && (
<AllCyclesList
cycles={draftCycles?.draft_cycles}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="draft"
/>
)}
{cyclesView === "board" && (
<AllCyclesBoard
cycles={draftCycles?.draft_cycles}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="draft"
/>
)}
</Tab.Panel>
)}
</Tab.Panels>
</Tab.Group>
</>
);
};

View File

@ -29,6 +29,7 @@ type TConfirmCycleDeletionProps = {
import { import {
CYCLE_COMPLETE_LIST, CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST, CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DETAILS,
CYCLE_DRAFT_LIST, CYCLE_DRAFT_LIST,
CYCLE_LIST, CYCLE_LIST,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
@ -114,6 +115,14 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
false false
); );
} }
mutate(
CYCLE_DETAILS(projectId as string),
(prevData: any) => {
if (!prevData) return;
return prevData.filter((cycle: any) => cycle.id !== data?.id);
},
false
);
handleClose(); handleClose();
setToastAlert({ setToastAlert({

Some files were not shown because too many files have changed in this diff Show More