Merge pull request #1252 from makeplane/develop

promote: develop to stage release
This commit is contained in:
guru_sainath 2023-06-08 00:19:08 +05:30 committed by GitHub
commit d09f410f21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
249 changed files with 5008 additions and 4516 deletions

View File

@ -1,20 +1,68 @@
# Replace with your instance Public IP
# Frontend
# Extra image domains that need to be added for Next Image
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID=""
NEXT_PUBLIC_GITHUB_APP_NAME=""
# Github ID for Github OAuth
NEXT_PUBLIC_GITHUB_ID=""
# Github App Name for GitHub Integration
NEXT_PUBLIC_GITHUB_APP_NAME=""
# Sentry DSN for error monitoring
NEXT_PUBLIC_SENTRY_DSN=""
# Enable/Disable OAUTH - default 0 for selfhosted instance
NEXT_PUBLIC_ENABLE_OAUTH=0
# Enable/Disable sentry
NEXT_PUBLIC_ENABLE_SENTRY=0
# Enable/Disable session recording
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
# Enable/Disable event tracking
NEXT_PUBLIC_TRACK_EVENTS=0
# Slack for Slack Integration
NEXT_PUBLIC_SLACK_CLIENT_ID=""
# Backend
# Database Settings
PGUSER="plane"
PGPASSWORD="plane"
PGHOST="plane-db"
PGDATABASE="plane"
# Email Settings
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS="1"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_S3_BUCKET_NAME=""
AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key"
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
# Changing this requires change in the nginx.conf for uploads if using minio setup
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_KEY=""
GPT_ENGINE=""
GPT_ENGINE=""
# Github
GITHUB_CLIENT_SECRET="" # For fetching release notes
# Settings related to Docker
DOCKERIZED=1
# set to 1 If using the pre-configured minio setup
USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# Default Creds
DEFAULT_EMAIL="captain@plane.so"
DEFAULT_PASSWORD="password123"
# Auto generated and Required that will be generated from setup.sh

View File

@ -1,6 +1,5 @@
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
@ -13,9 +12,7 @@ RUN turbo prune --scope=app --docker
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
# First install the dependencies (as they change less often)
@ -44,10 +41,12 @@ FROM python:3.11.1-alpine3.17 AS backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV DJANGO_SETTINGS_MODULE plane.settings.production
ENV DOCKERIZED 1
WORKDIR /code
RUN apk --update --no-cache add \
RUN apk --no-cache add \
"libpq~=15" \
"libxslt~=1.1" \
"nodejs-current~=19" \
@ -59,8 +58,8 @@ RUN apk --update --no-cache add \
COPY apiserver/requirements.txt ./
COPY apiserver/requirements ./requirements
RUN apk add libffi-dev
RUN apk --update --no-cache --virtual .build-deps add \
RUN apk add --no-cache libffi-dev
RUN apk add --no-cache --virtual .build-deps \
"bash~=5.2" \
"g++~=12.2" \
"gcc~=12.2" \
@ -81,18 +80,13 @@ COPY apiserver/plane plane/
COPY apiserver/templates templates/
COPY apiserver/gunicorn.config.py ./
RUN apk --update --no-cache add "bash~=5.2"
RUN apk --no-cache add "bash~=5.2"
COPY apiserver/bin ./bin/
RUN chmod +x ./bin/takeoff ./bin/worker
RUN chmod -R 777 /code
# Expose container port and run entry point script
EXPOSE 8000
EXPOSE 3000
EXPOSE 80
WORKDIR /app
@ -126,9 +120,6 @@ COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh
EXPOSE 80
CMD ["supervisord","-c","/code/supervisor.conf"]

View File

@ -15,11 +15,18 @@
</a>
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
</p>
<br />
<p>
<a href="https://app.plane.so/" target="_blank">
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680599798/Plane/plane_1_1_tnb32j.png"
src="https://ik.imagekit.io/killbluedog/Plane_Screen.png?updatedAt=1684942001069"
alt="Plane Screens"
width="100%"
/>
</a>
<a href="https://app.plane.so/#gh-dark-mode-only" target="_blank">
<img
src="https://ik.imagekit.io/killbluedog/Plane_Screens_Dark_Mode.png?updatedAt=1684942388044"
alt="Plane Screens"
width="100%"
/>
@ -38,22 +45,18 @@ The easiest way to get started with Plane is by creating a [Plane Cloud](https:/
### Docker Compose Setup
- Clone the Repository
- Clone the repository
```bash
git clone https://github.com/makeplane/plane
```
- Change Directory
```bash
cd plane
chmod +x setup.sh
```
- Run setup.sh
```bash
./setup.sh localhost
./setup.sh http://localhost
```
> If running in a cloud env replace localhost with public facing IP address of the VM
@ -69,7 +72,7 @@ set +a
- Run Docker compose up
```bash
docker-compose -f docker-compose-hub.yml up
docker compose up -d
```
<strong>You can use the default email and password for your first login `captain@plane.so` and `password123`.</strong>
@ -89,41 +92,62 @@ docker-compose -f docker-compose-hub.yml up
## 📸 Screenshots
<p>
<a href="https://app.plane.so/" target="_blank">
<a href="https://plane.so" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601719/Plane/plane_2_iqao52.png"
src="https://ik.imagekit.io/killbluedog/Plane_Views_Dark_Mode.png?updatedAt=1684943050275"
alt="Plane Views"
width="100%"
/>
</a>
</p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://ik.imagekit.io/killbluedog/Plane_Issue_Detail_Dark_Mode.png?updatedAt=1684943050202"
alt="Plane Issue Details"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
</p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680604273/Plane/plane_5_1_nwsl3a.png"
src="https://ik.imagekit.io/killbluedog/Plane_Cycles___Modules_Dark_Mode.png?updatedAt=1684943050281"
alt="Plane Cycles and Modules"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
</p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601713/Plane/plane_4_cqm0g8.png"
alt="Plane Quick Lists"
src="https://ik.imagekit.io/killbluedog/Plane_Analytics_Dark_Mode.png?updatedAt=1684944596824"
alt="Plane Analytics"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
</p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601712/Plane/plane_3_1_cu4fsc.png"
alt="Plane Command K"
src="https://ik.imagekit.io/killbluedog/Plane_Pages_Dark_Mode.png?updatedAt=1684943050202"
alt="Plane Pages"
width="100%"
/>
</a>
</p>
</p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://ik.imagekit.io/killbluedog/Plane_Commad_K_Dark_Mode.png?updatedAt=1684943050312"
alt="Plane Command Menu"
width="100%"
/>
</a>
</p>
</p>
## 📚Documentation

View File

@ -7,7 +7,7 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /code
RUN apk --update --no-cache add \
RUN apk --no-cache add \
"libpq~=15" \
"libxslt~=1.1" \
"nodejs-current~=19" \
@ -15,8 +15,8 @@ RUN apk --update --no-cache add \
COPY requirements.txt ./
COPY requirements ./requirements
RUN apk add libffi-dev
RUN apk --update --no-cache --virtual .build-deps add \
RUN apk add --no-cache libffi-dev
RUN apk add --no-cache --virtual .build-deps \
"bash~=5.2" \
"g++~=12.2" \
"gcc~=12.2" \
@ -46,7 +46,7 @@ COPY templates templates/
COPY gunicorn.config.py ./
USER root
RUN apk --update --no-cache add "bash~=5.2"
RUN apk --no-cache add "bash~=5.2"
COPY ./bin ./bin/
RUN chmod +x ./bin/takeoff ./bin/worker

View File

@ -1,3 +1,6 @@
# Django imports
from django.utils import timezone
# Third Party imports
from rest_framework import serializers
@ -251,6 +254,7 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10,
)
instance.updated_at = timezone.now()
return super().update(instance, validated_data)

View File

@ -25,6 +25,10 @@ class UserSerializer(BaseSerializer):
]
extra_kwargs = {"password": {"write_only": True}}
# If the user has already filled first name or last name then he is onboarded
def get_is_onboarded(self, obj):
return bool(obj.first_name) or bool(obj.last_name)
class UserLiteSerializer(BaseSerializer):
class Meta:

View File

@ -44,6 +44,8 @@ class WorkSpaceMemberSerializer(BaseSerializer):
class WorkSpaceMemberInviteSerializer(BaseSerializer):
workspace = WorkSpaceSerializer(read_only=True)
total_members = serializers.IntegerField(read_only=True)
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
class Meta:
model = WorkspaceMemberInvite

View File

@ -96,12 +96,8 @@ from plane.api.views import (
CycleViewSet,
CycleIssueViewSet,
CycleDateCheckEndpoint,
CurrentUpcomingCyclesEndpoint,
CompletedCyclesEndpoint,
CycleFavoriteViewSet,
DraftCyclesEndpoint,
TransferCycleIssueEndpoint,
InCompleteCyclesEndpoint,
## End Cycles
# Modules
ModuleViewSet,
@ -115,10 +111,6 @@ from plane.api.views import (
PageBlockViewSet,
PageFavoriteViewSet,
CreateIssueFromPageBlockEndpoint,
RecentPagesEndpoint,
FavoritePagesEndpoint,
MyPagesEndpoint,
CreatedbyOtherPagesEndpoint,
## End Pages
# Api Tokens
ApiTokenEndpoint,
@ -178,7 +170,7 @@ urlpatterns = [
),
# Password Manipulation
path(
"password-reset/<uidb64>/<token>/",
"reset-password/<uidb64>/<token>/",
ResetPasswordEndpoint.as_view(),
name="password-reset",
),
@ -664,21 +656,6 @@ urlpatterns = [
CycleDateCheckEndpoint.as_view(),
name="project-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/current-upcoming-cycles/",
CurrentUpcomingCyclesEndpoint.as_view(),
name="project-cycle-upcoming",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/completed-cycles/",
CompletedCyclesEndpoint.as_view(),
name="project-cycle-completed",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/draft-cycles/",
DraftCyclesEndpoint.as_view(),
name="project-cycle-draft",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-cycles/",
CycleFavoriteViewSet.as_view(
@ -703,11 +680,6 @@ urlpatterns = [
TransferCycleIssueEndpoint.as_view(),
name="transfer-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/incomplete-cycles/",
InCompleteCyclesEndpoint.as_view(),
name="transfer-issues",
),
## End Cycles
# Issue
path(
@ -1077,26 +1049,6 @@ urlpatterns = [
CreateIssueFromPageBlockEndpoint.as_view(),
name="page-block-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/recent-pages/",
RecentPagesEndpoint.as_view(),
name="recent-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/favorite-pages/",
FavoritePagesEndpoint.as_view(),
name="recent-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/my-pages/",
MyPagesEndpoint.as_view(),
name="user-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/created-by-other-pages/",
CreatedbyOtherPagesEndpoint.as_view(),
name="created-by-other-pages",
),
## End Pages
# API Tokens
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),

View File

@ -49,12 +49,8 @@ from .cycle import (
CycleViewSet,
CycleIssueViewSet,
CycleDateCheckEndpoint,
CurrentUpcomingCyclesEndpoint,
CompletedCyclesEndpoint,
CycleFavoriteViewSet,
DraftCyclesEndpoint,
TransferCycleIssueEndpoint,
InCompleteCyclesEndpoint,
)
from .asset import FileAssetEndpoint, UserAssetsEndpoint
from .issue import (
@ -122,10 +118,6 @@ from .page import (
PageBlockViewSet,
PageFavoriteViewSet,
CreateIssueFromPageBlockEndpoint,
RecentPagesEndpoint,
FavoritePagesEndpoint,
MyPagesEndpoint,
CreatedbyOtherPagesEndpoint,
)
from .search import GlobalSearchEndpoint, IssueSearchEndpoint

View File

@ -3,10 +3,10 @@ from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
from sentry_sdk import capture_exception
from django.conf import settings
# Module imports
from .base import BaseAPIView
from plane.db.models import FileAsset
from plane.db.models import FileAsset, Workspace
from plane.api.serializers import FileAssetSerializer
@ -27,15 +27,13 @@ class FileAssetEndpoint(BaseAPIView):
try:
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
if request.user.last_workspace_id is None:
return Response(
{"error": "Workspace id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer.save(workspace_id=request.user.last_workspace_id)
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
serializer.save(workspace_id=workspace.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Workspace.DoesNotExist:
return Response({"error": "Workspace does not exist"}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(

View File

@ -152,6 +152,75 @@ class CycleViewSet(BaseViewSet):
.distinct()
)
def list(self, request, slug, project_id):
try:
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", False)
if not cycle_view:
return Response(
{"error": "Cycle View parameter is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# All Cycles
if cycle_view == "all":
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(
start_date__lte=timezone.now(),
end_date__gte=timezone.now(),
)
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# Upcoming Cycles
if cycle_view == "upcoming":
queryset = queryset.filter(start_date__gt=timezone.now())
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# Completed Cycles
if cycle_view == "completed":
queryset = queryset.filter(end_date__lt=timezone.now())
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# Draft Cycles
if cycle_view == "draft":
queryset = queryset.filter(
end_date=None,
start_date=None,
)
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# Incomplete Cycles
if cycle_view == "incomplete":
queryset = queryset.filter(
Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True),
)
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
return Response(
{"error": "No matching view found"}, 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,
)
def create(self, request, slug, project_id):
try:
if (
@ -478,352 +547,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
)
class CurrentUpcomingCyclesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
current_cycle = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
start_date__lte=timezone.now(),
end_date__gte=timezone.now(),
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
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")
)
upcoming_cycle = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
start_date__gt=timezone.now(),
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
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")
)
return Response(
{
"current_cycle": CycleSerializer(current_cycle, many=True).data,
"upcoming_cycle": CycleSerializer(upcoming_cycle, many=True).data,
},
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 CompletedCyclesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
completed_cycles = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
end_date__lt=timezone.now(),
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
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")
)
return Response(
{
"completed_cycles": CycleSerializer(
completed_cycles, many=True
).data,
},
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 DraftCyclesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
draft_cycles = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
end_date=None,
start_date=None,
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
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")
)
return Response(
{"draft_cycles": CycleSerializer(draft_cycles, many=True).data},
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 CycleFavoriteViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
@ -948,22 +671,3 @@ class TransferCycleIssueEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class InCompleteCyclesEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
try:
cycles = Cycle.objects.filter(
Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True),
workspace__slug=slug,
project_id=project_id,
).select_related("owned_by")
serializer = CycleSerializer(cycles, many=True)
return Response(serializer.data, 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

@ -4,11 +4,23 @@ import random
from itertools import chain
# Django imports
from django.db.models import Prefetch, OuterRef, Func, F, Q, Count
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Q,
Count,
Case,
Value,
CharField,
When,
)
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db.models.functions import Coalesce
from django.conf import settings
# Third Party imports
from rest_framework.response import Response
@ -144,9 +156,13 @@ class IssueViewSet(BaseViewSet):
filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true")
# Custom ordering for priority
priority_order = ["urgent", "high", "medium", "low", None]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.order_by(request.GET.get("order_by", "created_at"))
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__id"))
.annotate(module_id=F("issue_module__id"))
@ -166,6 +182,19 @@ class IssueViewSet(BaseViewSet):
)
)
if order_by_param == "priority":
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issue_queryset = (
issue_queryset
if show_sub_issues == "true"

View File

@ -125,7 +125,57 @@ class PageViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def list(self, request, slug, project_id):
try:
queryset = self.get_queryset()
page_view = request.GET.get("page_view", False)
if not page_view:
return Response({"error": "Page View parameter is required"}, status=status.HTTP_400_BAD_REQUEST)
# All Pages
if page_view == "all":
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
# Recent pages
if page_view == "recent":
current_time = date.today()
day_before = current_time - timedelta(days=1)
todays_pages = queryset.filter(updated_at__date=date.today())
yesterdays_pages = queryset.filter(updated_at__date=day_before)
earlier_this_week = queryset.filter( updated_at__date__range=(
(timezone.now() - timedelta(days=7)),
(timezone.now() - timedelta(days=2)),
))
return Response(
{
"today": PageSerializer(todays_pages, many=True).data,
"yesterday": PageSerializer(yesterdays_pages, many=True).data,
"earlier_this_week": PageSerializer(earlier_this_week, many=True).data,
},
status=status.HTTP_200_OK,
)
# Favorite Pages
if page_view == "favorite":
queryset = queryset.filter(is_favorite=True)
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
# My pages
if page_view == "created_by_me":
queryset = queryset.filter(owned_by=request.user)
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
# Created by other Pages
if page_view == "created_by_other":
queryset = queryset.filter(~Q(owned_by=request.user), access=0)
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
return Response({"error": "No matching view found"}, 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 PageBlockViewSet(BaseViewSet):
serializer_class = PageBlockSerializer
@ -269,249 +319,3 @@ class CreateIssueFromPageBlockEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class RecentPagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = PageFavorite.objects.filter(
user=request.user,
page_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
current_time = date.today()
day_before = current_time - timedelta(days=1)
todays_pages = (
Page.objects.filter(
updated_at__date=date.today(),
workspace__slug=slug,
project_id=project_id,
)
.filter(project__project_projectmember__member=request.user)
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "-updated_at")
)
yesterdays_pages = (
Page.objects.filter(
updated_at__date=day_before,
workspace__slug=slug,
project_id=project_id,
)
.filter(project__project_projectmember__member=request.user)
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "-updated_at")
)
earlier_this_week = (
Page.objects.filter(
updated_at__date__range=(
(timezone.now() - timedelta(days=7)),
(timezone.now() - timedelta(days=2)),
),
workspace__slug=slug,
project_id=project_id,
)
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.filter(project__project_projectmember__member=request.user)
.annotate(is_favorite=Exists(subquery))
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "-updated_at")
)
todays_pages_serializer = PageSerializer(todays_pages, many=True)
yesterday_pages_serializer = PageSerializer(yesterdays_pages, many=True)
earlier_this_week_serializer = PageSerializer(earlier_this_week, many=True)
return Response(
{
"today": todays_pages_serializer.data,
"yesterday": yesterday_pages_serializer.data,
"earlier_this_week": earlier_this_week_serializer.data,
},
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 FavoritePagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = PageFavorite.objects.filter(
user=request.user,
page_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
pages = (
Page.objects.filter(
workspace__slug=slug,
project_id=project_id,
)
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.filter(project__project_projectmember__member=request.user)
.filter(is_favorite=True)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("name", "-is_favorite")
)
serializer = PageSerializer(pages, many=True)
return Response(serializer.data, 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 MyPagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = PageFavorite.objects.filter(
user=request.user,
page_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
pages = (
Page.objects.filter(
workspace__slug=slug, project_id=project_id, owned_by=request.user
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.filter(project__project_projectmember__member=request.user)
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "name")
)
serializer = PageSerializer(pages, many=True)
return Response(serializer.data, 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 CreatedbyOtherPagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = PageFavorite.objects.filter(
user=request.user,
page_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
pages = (
Page.objects.filter(
~Q(owned_by=request.user),
workspace__slug=slug,
project_id=project_id,
access=0,
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.annotate(is_favorite=Exists(subquery))
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "name")
)
serializer = PageSerializer(pages, many=True)
return Response(serializer.data, 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

@ -31,36 +31,61 @@ class UserEndpoint(BaseViewSet):
def retrieve(self, request):
try:
workspace = Workspace.objects.get(pk=request.user.last_workspace_id)
workspace = Workspace.objects.get(
pk=request.user.last_workspace_id, workspace_member__member=request.user
)
workspace_invites = WorkspaceMemberInvite.objects.filter(
email=request.user.email
).count()
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
serialized_data = UserSerializer(request.user).data
serialized_data["workspace"] = {
"last_workspace_id": request.user.last_workspace_id,
"last_workspace_slug": workspace.slug,
"fallback_workspace_id": request.user.last_workspace_id,
"fallback_workspace_slug": workspace.slug,
"invites": workspace_invites,
}
serialized_data.setdefault("issues", {})["assigned_issues"] = assigned_issues
return Response(
{
"user": UserSerializer(request.user).data,
"slug": workspace.slug,
"workspace_invites": workspace_invites,
"assigned_issues": assigned_issues,
},
serialized_data,
status=status.HTTP_200_OK,
)
except Workspace.DoesNotExist:
# This exception will be hit even when the `last_workspace_id` is None
workspace_invites = WorkspaceMemberInvite.objects.filter(
email=request.user.email
).count()
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
fallback_workspace = Workspace.objects.filter(
workspace_member__member=request.user
).order_by("created_at").first()
serialized_data = UserSerializer(request.user).data
serialized_data["workspace"] = {
"last_workspace_id": None,
"last_workspace_slug": None,
"fallback_workspace_id": fallback_workspace.id
if fallback_workspace is not None
else None,
"fallback_workspace_slug": fallback_workspace.slug
if fallback_workspace is not None
else None,
"invites": workspace_invites,
}
serialized_data.setdefault("issues", {})["assigned_issues"] = assigned_issues
return Response(
{
"user": UserSerializer(request.user).data,
"slug": None,
"workspace_invites": workspace_invites,
"assigned_issues": assigned_issues,
},
serialized_data,
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

@ -37,18 +37,19 @@ from plane.db.models import (
State,
TeamMember,
ProjectFavorite,
ProjectIdentifier,
Module,
Cycle,
CycleFavorite,
ModuleFavorite,
PageFavorite,
IssueViewFavorite,
Page,
IssueAssignee,
ModuleMember,
)
from plane.db.models import (
Project,
ProjectMember,
Workspace,
ProjectMemberInvite,
User,
ProjectIdentifier,
Cycle,
Module,
)
from plane.bgtasks.project_invitation_task import project_invitation
@ -133,12 +134,12 @@ class ProjectViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
## Add the user as Administrator to the project
# Add the user as Administrator to the project
ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20
)
## Default states
# Default states
states = [
{
"name": "Backlog",
@ -373,7 +374,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
]
)
## Delete joined project invites
# Delete joined project invites
project_invitations.delete()
return Response(status=status.HTTP_200_OK)
@ -411,14 +412,23 @@ class ProjectMemberViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, pk):
try:
project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
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:
# Check while updating user roles
requested_project_member = ProjectMember.objects.get(
project_id=project_id, workspace__slug=slug, member=request.user
)
if (
"role" in request.data
and int(request.data.get("role", project_member.role))
> requested_project_member.role
):
return Response(
{
"error": "You cannot update a role that is higher than your own role"
@ -441,8 +451,70 @@ class ProjectMemberViewSet(BaseViewSet):
)
except Exception as e:
capture_exception(e)
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, pk):
try:
project_member = ProjectMember.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
# check requesting user role
requesting_project_member = ProjectMember.objects.get(
workspace__slug=slug, member=request.user, project_id=project_id
)
if requesting_project_member.role < project_member.role:
return Response(
{"error": "You cannot remove a user having role higher than yourself"},
status=status.HTTP_400_BAD_REQUEST,
)
# Remove all favorites
ProjectFavorite.objects.filter(
workspace__slug=slug, project_id=project_id, user=project_member.member
).delete()
CycleFavorite.objects.filter(
workspace__slug=slug, project_id=project_id, user=project_member.member
).delete()
ModuleFavorite.objects.filter(
workspace__slug=slug, project_id=project_id, user=project_member.member
).delete()
PageFavorite.objects.filter(
workspace__slug=slug, project_id=project_id, user=project_member.member
).delete()
IssueViewFavorite.objects.filter(
workspace__slug=slug, project_id=project_id, user=project_member.member
).delete()
# Also remove issue from issue assigned
IssueAssignee.objects.filter(
workspace__slug=slug,
project_id=project_id,
assignee=project_member.member,
).delete()
# Remove if module member
ModuleMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=project_member.member,
).delete()
# Delete owned Pages
Page.objects.filter(
workspace__slug=slug,
project_id=project_id,
owned_by=project_member.member,
).delete()
project_member.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except ProjectMember.DoesNotExist:
return Response(
{"error": "Project Member does not exist"}, status=status.HTTP_400
)
except Exception as e:
capture_exception(e)
return Response({"error": "Something went wrong please try again later"})
class AddMemberToProjectEndpoint(BaseAPIView):

View File

@ -210,13 +210,15 @@ class IssueSearchEndpoint(BaseAPIView):
blocker_blocked_by = request.query_params.get("blocker_blocked_by", False)
issue_id = request.query_params.get("issue_id", False)
issues = search_issues(query)
issues = issues.filter(
issues = Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
project__project_projectmember__member=self.request.user,
)
if query:
issues = search_issues(query, issues)
if parent == "true" and issue_id:
issue = Issue.objects.get(pk=issue_id)
issues = issues.filter(
@ -227,7 +229,12 @@ class IssueSearchEndpoint(BaseAPIView):
)
)
if blocker_blocked_by == "true" and issue_id:
issues = issues.filter(blocker_issues=issue_id, blocked_issues=issue_id)
issue = Issue.objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id),
~Q(blocked_issues__block=issue),
~Q(blocker_issues__blocked_by=issue),
)
return Response(
issues.values(

View File

@ -50,6 +50,14 @@ from plane.db.models import (
IssueActivity,
Issue,
WorkspaceTheme,
IssueAssignee,
ProjectFavorite,
CycleFavorite,
ModuleMember,
ModuleFavorite,
PageFavorite,
Page,
IssueViewFavorite,
)
from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
from plane.bgtasks.workspace_invitation_task import workspace_invitation
@ -353,7 +361,7 @@ class WorkspaceInvitationsViewset(BaseViewSet):
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "workspace__owner")
.select_related("workspace", "workspace__owner", "created_by")
)
@ -366,7 +374,8 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
super()
.get_queryset()
.filter(email=self.request.user.email)
.select_related("workspace", "workspace__owner")
.select_related("workspace", "workspace__owner", "created_by")
.annotate(total_members=Count("workspace__workspace_member"))
)
def create(self, request):
@ -432,7 +441,17 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
if request.data.get("role", 10) > workspace_member.role:
# Get the requested user role
requested_workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user
)
# Check if role is being updated
# One cannot update role higher than his own role
if (
"role" in request.data
and int(request.data.get("role", workspace_member.role))
> requested_workspace_member.role
):
return Response(
{
"error": "You cannot update a role that is higher than your own role"
@ -460,6 +479,69 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, pk):
try:
# Check the user role who is deleting the user
workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, pk=pk)
# check requesting user role
requesting_workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user
)
if requesting_workspace_member.role < workspace_member.role:
return Response(
{"error": "You cannot remove a user having role higher than you"},
status=status.HTTP_400_BAD_REQUEST,
)
# Delete the user also from all the projects
ProjectMember.objects.filter(
workspace__slug=slug, member=workspace_member.member
).delete()
# Remove all favorites
ProjectFavorite.objects.filter(
workspace__slug=slug, user=workspace_member.member
).delete()
CycleFavorite.objects.filter(
workspace__slug=slug, user=workspace_member.member
).delete()
ModuleFavorite.objects.filter(
workspace__slug=slug, user=workspace_member.member
).delete()
PageFavorite.objects.filter(
workspace__slug=slug, user=workspace_member.member
).delete()
IssueViewFavorite.objects.filter(
workspace__slug=slug, user=workspace_member.member
).delete()
# Also remove issue from issue assigned
IssueAssignee.objects.filter(
workspace__slug=slug, assignee=workspace_member.member
).delete()
# Remove if module member
ModuleMember.objects.filter(
workspace__slug=slug, member=workspace_member.member
).delete()
# Delete owned Pages
Page.objects.filter(
workspace__slug=slug, owned_by=workspace_member.member
).delete()
workspace_member.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except WorkspaceMember.DoesNotExist:
return Response(
{"error": "Workspace Member does not exists"},
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):
serializer_class = TeamSerializer

View File

@ -19,7 +19,7 @@ def email_verification(first_name, email, token, current_site):
try:
realtivelink = "/request-email-verification/" + "?token=" + str(token)
abs_url = "http://" + current_site + realtivelink
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM

View File

@ -16,12 +16,12 @@ from plane.db.models import User
def forgot_password(first_name, email, uidb64, token, current_site):
try:
realtivelink = f"/email-verify/?uidb64={uidb64}&token={token}/"
abs_url = "http://" + current_site + realtivelink
realtivelink = f"/reset-password/?uidb64={uidb64}&token={token}"
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
subject = f"Verify your Email!"
subject = f"Reset Your Password - Plane"
context = {
"first_name": first_name,

View File

@ -13,7 +13,7 @@ from sentry_sdk import capture_exception
def magic_link(email, key, token, current_site):
try:
realtivelink = f"/magic-sign-in/?password={token}&key={key}"
abs_url = "http://" + current_site + realtivelink
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM

View File

@ -21,7 +21,7 @@ def project_invitation(email, project_id, token, current_site):
)
relativelink = f"/project-member-invitation/{project_member_invite.id}"
abs_url = "http://" + current_site + relativelink
abs_url = current_site + relativelink
from_email_string = settings.EMAIL_FROM

View File

@ -23,9 +23,9 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
)
realtivelink = (
f"/workspace-member-invitation/{workspace_member_invite.id}?email={email}"
f"/workspace-member-invitation/?invitation_id={workspace_member_invite.id}&email={email}"
)
abs_url = "http://" + current_site + realtivelink
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM

View File

@ -4,6 +4,7 @@ from uuid import uuid4
# Django import
from django.db import models
from django.core.exceptions import ValidationError
from django.conf import settings
# Module import
from . import BaseModel
@ -16,8 +17,7 @@ def get_upload_path(instance, filename):
def file_size(value):
limit = 5 * 1024 * 1024
if value.size > limit:
if value.size > settings.FILE_SIZE_LIMIT:
raise ValidationError("File too large. Size should not exceed 5 MB.")

View File

@ -210,8 +210,8 @@ def get_upload_path(instance, filename):
def file_size(value):
limit = 5 * 1024 * 1024
if value.size > limit:
# File limit check is only for cloud hosted
if value.size > settings.FILE_SIZE_LIMIT:
raise ValidationError("File too large. Size should not exceed 5 MB.")

View File

@ -25,7 +25,13 @@ DATABASES = {
}
}
DOCKERIZED = os.environ.get("DOCKERIZED", False)
DOCKERIZED = int(os.environ.get(
"DOCKERIZED", 0
)) == 1
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
if DOCKERIZED:
DATABASES["default"] = dj_database_url.config()
@ -68,7 +74,7 @@ MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
if DOCKERIZED:
REDIS_URL = os.environ.get("REDIS_URL")
WEB_URL = os.environ.get("WEB_URL", "localhost:3000")
WEB_URL = os.environ.get("WEB_URL", "http://localhost:3000")
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
@ -84,5 +90,4 @@ LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
CELERY_RESULT_BACKEND = os.environ.get("REDIS_URL")
CELERY_BROKER_URL = os.environ.get("REDIS_URL")
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)

View File

@ -29,9 +29,12 @@ DATABASES = {
DATABASES["default"] = dj_database_url.config()
SITE_ID = 1
DOCKERIZED = os.environ.get(
"DOCKERIZED", False
) # Set the variable true if running in docker-compose environment
# Set the variable true if running in docker environment
DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# Enable Connection Pooling (if desired)
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
@ -69,7 +72,7 @@ CORS_ALLOW_CREDENTIALS = True
# Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
if os.environ.get("SENTRY_DSN", False):
if bool(os.environ.get("SENTRY_DSN", False)):
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN", ""),
integrations=[DjangoIntegration(), RedisIntegration()],
@ -80,12 +83,27 @@ if os.environ.get("SENTRY_DSN", False):
environment="production",
)
if (
os.environ.get("AWS_REGION", False)
and os.environ.get("AWS_ACCESS_KEY_ID", False)
and os.environ.get("AWS_SECRET_ACCESS_KEY", False)
and os.environ.get("AWS_S3_BUCKET_NAME", False)
):
if DOCKERIZED and USE_MINIO:
INSTALLED_APPS += ("storages",)
DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
# The name of the bucket to store files in.
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "http://plane-minio:9000")
# Default permissions
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False
# Custom Domain settings
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
else:
# The AWS region to connect to.
AWS_REGION = os.environ.get("AWS_REGION", "")
@ -99,7 +117,7 @@ if (
# AWS_SESSION_TOKEN = ""
# The name of the bucket to store files in.
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "")
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
# How to construct S3 URLs ("auto", "path", "virtual").
AWS_S3_ADDRESSING_STYLE = "auto"
@ -166,14 +184,8 @@ if (
# extra characters appended.
AWS_S3_FILE_OVERWRITE = False
# AWS Settings End
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
else:
MEDIA_URL = "/uploads/"
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
# AWS Settings End
# Enable Connection Pooling (if desired)
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
@ -218,14 +230,8 @@ else:
}
}
RQ_QUEUES = {
"default": {
"USE_REDIS_CACHE": "default",
}
}
WEB_URL = os.environ.get("WEB_URL")
WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so")
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)

View File

@ -49,6 +49,12 @@ CORS_ALLOW_ALL_ORIGINS = True
# Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Make true if running in a docker environment
DOCKERIZED = int(os.environ.get(
"DOCKERIZED", 0
)) == 1
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN"),
@ -165,7 +171,6 @@ CSRF_COOKIE_SECURE = True
REDIS_URL = os.environ.get("REDIS_URL")
DOCKERIZED = os.environ.get("DOCKERIZED", False)
CACHES = {
"default": {

View File

@ -7,7 +7,7 @@ from django.urls import path
from django.views.generic import TemplateView
from django.conf import settings
from django.conf.urls import include, url
from django.conf.urls import include, url, static
# from django.conf.urls.static import static
@ -17,9 +17,8 @@ urlpatterns = [
path("api/", include("plane.api.urls")),
path("", include("plane.web.urls")),
]
# + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns = urlpatterns + static.static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.DEBUG:
import debug_toolbar

View File

@ -8,7 +8,7 @@ from django.db.models import Q
from plane.db.models import Issue
def search_issues(query):
def search_issues(query, queryset):
fields = ["name", "sequence_id"]
q = Q()
for field in fields:
@ -18,6 +18,6 @@ def search_issues(query):
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
return Issue.objects.filter(
return queryset.filter(
q,
).distinct()

View File

@ -4,7 +4,7 @@ dj-database-url==1.2.0
gunicorn==20.1.0
whitenoise==6.3.0
django-storages==1.13.2
boto==2.49.0
boto3==1.26.136
django-anymail==9.0
twilio==7.16.2
django-debug-toolbar==3.8.1

View File

@ -1,11 +1,21 @@
<!DOCTYPE html>
<html>
<p>
<body>
<p>
Dear {{first_name}},<br /><br />
Welcome! Your account has been created.
Verify your email by clicking on the link below <br />
{{forgot_password_url}}
successfully.<br /><br />
</p>
We received a request to reset your password for your Plane account.
<br /><br />
To proceed with resetting your password, please click on the link below:
<br />
<a href="{{forgot_password_url}}">{{forgot_password_url}}</a>
<br /><br />
If you didn't request to reset your password, please ignore this email. Your account will remain secure.
<br /><br />
If you have any questions or need further assistance, please contact our support team.
<br /><br />
Thank you for using Plane.
</p>
</body>
</html>

View File

@ -37,6 +37,14 @@
"description": "Email host to send emails from",
"value": ""
},
"EMAIL_FROM": {
"description": "Email Sender",
"value": ""
},
"EMAIL_PORT": {
"description": "The default Email PORT to use",
"value": "587"
},
"AWS_REGION": {
"description": "AWS Region to use for S3",
"value": "false"
@ -49,30 +57,22 @@
"description": "AWS Secret Access Key to use for S3",
"value": ""
},
"SENTRY_DSN": {
"description": "",
"value": ""
},
"AWS_S3_BUCKET_NAME": {
"description": "AWS Bucket Name to use for S3",
"value": ""
},
"SENTRY_DSN": {
"description": "",
"value": ""
},
"WEB_URL": {
"description": "Web URL for Plane",
"description": "Web URL for Plane this will be used for redirections in the emails",
"value": ""
},
"GITHUB_CLIENT_SECRET": {
"description": "Github Client Secret",
"value": ""
},
"NEXT_PUBLIC_GITHUB_ID": {
"description": "Next Public Github ID",
"value": ""
},
"NEXT_PUBLIC_GOOGLE_CLIENTID": {
"description": "Next Public Google Client ID",
"value": ""
},
"NEXT_PUBLIC_API_BASE_URL": {
"description": "Next Public API Base URL",
"value": ""

View File

@ -1,4 +1,7 @@
module.exports = {
root: true,
extends: ["custom"],
rules: {
"@next/next/no-img-element": "off",
},
};

View File

@ -1,6 +1,5 @@
FROM node:18-alpine
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app

View File

@ -1,6 +1,5 @@
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
@ -14,7 +13,6 @@ RUN turbo prune --scope=app --docker
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000

View File

@ -21,6 +21,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
const [codeResent, setCodeResent] = useState(false);
const [isCodeResending, setIsCodeResending] = useState(false);
const [errorResendingCode, setErrorResendingCode] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { setToastAlert } = useToast();
const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer();
@ -64,22 +65,19 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
};
const handleSignin = async (formData: EmailCodeFormValues) => {
await authenticationService
.magicSignIn(formData)
.then((response) => {
onSuccess(response);
})
.catch((error) => {
setToastAlert({
title: "Oops!",
type: "error",
message: error?.response?.data?.error ?? "Enter the correct code to sign in",
});
setError("token" as keyof EmailCodeFormValues, {
type: "manual",
message: error.error,
});
setIsLoading(true);
await authenticationService.magicSignIn(formData).catch((error) => {
setIsLoading(false);
setToastAlert({
title: "Oops!",
type: "error",
message: error?.response?.data?.error ?? "Enter the correct code to sign in",
});
setError("token" as keyof EmailCodeFormValues, {
type: "manual",
message: error.error,
});
});
};
const emailOld = getValues("email");
@ -88,6 +86,25 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
setErrorResendingCode(false);
}, [emailOld]);
useEffect(() => {
const submitForm = (e: KeyboardEvent) => {
if (!codeSent && e.key === "Enter") {
e.preventDefault();
handleSubmit(onSubmit)().then(() => {
setResendCodeTimer(30);
});
}
};
if (!codeSent) {
window.addEventListener("keydown", submitForm);
}
return () => {
window.removeEventListener("keydown", submitForm);
};
}, [handleSubmit, codeSent]);
return (
<>
<form className="space-y-5 py-5 px-5">
@ -177,9 +194,9 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
size="md"
onClick={handleSubmit(handleSignin)}
disabled={!isValid && isDirty}
loading={isSubmitting}
loading={isLoading}
>
{isSubmitting ? "Signing in..." : "Sign in"}
{isLoading ? "Signing in..." : "Sign in"}
</PrimaryButton>
) : (
<PrimaryButton

View File

@ -1,6 +1,4 @@
import React from "react";
import Link from "next/link";
import React, { useState } from "react";
// react hook form
import { useForm } from "react-hook-form";
@ -8,6 +6,8 @@ import { useForm } from "react-hook-form";
import authenticationService from "services/authentication.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { EmailResetPasswordForm } from "components/account";
// ui
import { Input, SecondaryButton } from "components/ui";
// types
@ -17,8 +17,11 @@ type EmailPasswordFormValues = {
medium?: string;
};
export const EmailPasswordForm = ({ onSuccess }: any) => {
export const EmailPasswordForm = ({ handleSignIn }: any) => {
const [isResettingPassword, setIsResettingPassword] = useState(false);
const { setToastAlert } = useToast();
const {
register,
handleSubmit,
@ -38,7 +41,7 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
authenticationService
.emailLogin(formData)
.then((response) => {
onSuccess(response);
if (handleSignIn) handleSignIn(response);
})
.catch((error) => {
console.log(error);
@ -58,59 +61,66 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
});
});
};
return (
<>
<form className="mt-5 py-5 px-5" onSubmit={handleSubmit(onSubmit)}>
<div>
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email ID is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email ID is not valid",
}}
error={errors.email}
placeholder="Enter your Email ID"
/>
</div>
<div className="mt-5">
<Input
id="password"
type="password"
name="password"
register={register}
validations={{
required: "Password is required",
}}
error={errors.password}
placeholder="Enter your password"
/>
</div>
<div className="mt-2 flex items-center justify-between">
<div className="ml-auto text-sm">
<Link href={"/forgot-password"}>
<a className="font-medium text-brand-accent hover:text-brand-accent">
Forgot your password?
</a>
</Link>
{isResettingPassword ? (
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} />
) : (
<form className="mt-5 py-5 px-5" onSubmit={handleSubmit(onSubmit)}>
<div>
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email ID is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email ID is not valid",
}}
error={errors.email}
placeholder="Enter your Email ID"
/>
</div>
</div>
<div className="mt-5">
<SecondaryButton
type="submit"
className="w-full text-center"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign In"}
</SecondaryButton>
</div>
</form>
<div className="mt-5">
<Input
id="password"
type="password"
name="password"
register={register}
validations={{
required: "Password is required",
}}
error={errors.password}
placeholder="Enter your password"
/>
</div>
<div className="mt-2 flex items-center justify-between">
<div className="ml-auto text-sm">
<button
type="button"
onClick={() => setIsResettingPassword(true)}
className="font-medium text-brand-accent hover:text-brand-accent"
>
Forgot your password?
</button>
</div>
</div>
<div className="mt-5">
<SecondaryButton
type="submit"
className="w-full text-center"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign In"}
</SecondaryButton>
</div>
</form>
)}
</>
);
};

View File

@ -0,0 +1,93 @@
import React from "react";
// react hook form
import { useForm } from "react-hook-form";
// services
import userService from "services/user.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// types
type Props = {
setIsResettingPassword: React.Dispatch<React.SetStateAction<boolean>>;
};
export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword }) => {
const { setToastAlert } = useToast();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
defaultValues: {
email: "",
},
mode: "onChange",
reValidateMode: "onChange",
});
const forgotPassword = async (formData: any) => {
const payload = {
email: formData.email,
};
await userService
.forgotPassword(payload)
.then(() =>
setToastAlert({
type: "success",
title: "Success!",
message: "Password reset link has been sent to your email address.",
})
)
.catch((err) => {
if (err.status === 400)
setToastAlert({
type: "error",
title: "Error!",
message: "Please check the Email ID entered.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again.",
});
});
};
return (
<form className="mt-5 py-5 px-5" onSubmit={handleSubmit(forgotPassword)}>
<div>
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email ID is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email ID is not valid",
}}
error={errors.email}
placeholder="Enter registered Email ID"
/>
</div>
<div className="mt-5 flex items-center gap-2">
<SecondaryButton
className="w-full text-center"
onClick={() => setIsResettingPassword(false)}
>
Go Back
</SecondaryButton>
<PrimaryButton type="submit" className="w-full text-center" loading={isSubmitting}>
{isSubmitting ? "Sending link..." : "Send reset link"}
</PrimaryButton>
</div>
</form>
);
};

View File

@ -1,24 +0,0 @@
import { useState, FC } from "react";
import { KeyIcon } from "@heroicons/react/24/outline";
// components
import { EmailCodeForm, EmailPasswordForm } from "components/account";
export interface EmailSignInFormProps {
handleSuccess: () => void;
}
export const EmailSignInForm: FC<EmailSignInFormProps> = (props) => {
const { handleSuccess } = props;
// states
const [useCode, setUseCode] = useState(true);
return (
<>
{useCode ? (
<EmailCodeForm onSuccess={handleSuccess} />
) : (
<EmailPasswordForm onSuccess={handleSuccess} />
)}
</>
);
};

View File

@ -29,7 +29,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
useEffect(() => {
const origin =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
setLoginCallBackURL(`${origin}/signin` as any);
setLoginCallBackURL(`${origin}/` as any);
}, []);
return (

View File

@ -1,5 +1,5 @@
export * from "./google-login";
export * from "./email-code-form";
export * from "./email-password-form";
export * from "./email-reset-password-form";
export * from "./github-login-button";
export * from "./email-signin-form";
export * from "./google-login";

View File

@ -18,7 +18,7 @@ import { Loader, PrimaryButton } from "components/ui";
// helpers
import { convertResponseToBarGraphData } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
import { IAnalyticsParams, IAnalyticsResponse, ICurrentUserResponse } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
@ -29,6 +29,7 @@ type Props = {
control: Control<IAnalyticsParams, any>;
setValue: UseFormSetValue<IAnalyticsParams>;
fullScreen: boolean;
user: ICurrentUserResponse | undefined;
};
export const CustomAnalytics: React.FC<Props> = ({
@ -38,6 +39,7 @@ export const CustomAnalytics: React.FC<Props> = ({
control,
setValue,
fullScreen,
user,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -124,6 +126,7 @@ export const CustomAnalytics: React.FC<Props> = ({
params={params}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
user={user}
/>
</div>
);

View File

@ -7,6 +7,7 @@ import analyticsService from "services/analytics.service";
import projectService from "services/project.service";
import cyclesService from "services/cycles.service";
import modulesService from "services/modules.service";
import trackEventServices from "services/track-event.service";
// hooks
import useProjects from "hooks/use-projects";
import useToast from "hooks/use-toast";
@ -23,7 +24,14 @@ import { ContrastIcon, LayerDiagonalIcon } from "components/icons";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IProject } from "types";
import {
IAnalyticsParams,
IAnalyticsResponse,
ICurrentUserResponse,
IExportAnalyticsFormData,
IProject,
IWorkspace,
} from "types";
// fetch-keys
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
// constants
@ -34,6 +42,7 @@ type Props = {
params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean;
user: ICurrentUserResponse | undefined;
};
export const AnalyticsSidebar: React.FC<Props> = ({
@ -41,6 +50,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
params,
fullScreen,
isProjectLevel = false,
user,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
@ -82,6 +92,60 @@ export const AnalyticsSidebar: React.FC<Props> = ({
: null
);
const trackExportAnalytics = () => {
const eventPayload: any = {
workspaceSlug: workspaceSlug?.toString(),
params: {
x_axis: params.x_axis,
y_axis: params.y_axis,
group: params.segment,
project: params.project,
},
};
if (projectDetails) {
const workspaceDetails = projectDetails.workspace as IWorkspace;
eventPayload.workspaceId = workspaceDetails.id;
eventPayload.workspaceName = workspaceDetails.name;
eventPayload.projectId = projectDetails.id;
eventPayload.projectIdentifier = projectDetails.identifier;
eventPayload.projectName = projectDetails.name;
}
if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id;
eventPayload.workspaceName = details?.workspace_detail?.name;
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier;
eventPayload.projectName = details?.project_detail.name;
}
if (cycleDetails) {
eventPayload.cycleId = cycleDetails.id;
eventPayload.cycleName = cycleDetails.name;
}
if (moduleDetails) {
eventPayload.moduleId = moduleDetails.id;
eventPayload.moduleName = moduleDetails.name;
}
trackEventServices.trackAnalyticsEvent(
eventPayload,
cycleId
? "CYCLE_ANALYTICS_EXPORT"
: moduleId
? "MODULE_ANALYTICS_EXPORT"
: projectId
? "PROJECT_ANALYTICS_EXPORT"
: "WORKSPACE_ANALYTICS_EXPORT",
user
);
};
const exportAnalytics = () => {
if (!workspaceSlug) return;
@ -95,13 +159,15 @@ export const AnalyticsSidebar: React.FC<Props> = ({
analyticsService
.exportAnalytics(workspaceSlug.toString(), data)
.then((res) =>
.then((res) => {
setToastAlert({
type: "success",
title: "Success!",
message: res.message,
})
)
});
trackExportAnalytics();
})
.catch(() =>
setToastAlert({
type: "error",

View File

@ -13,6 +13,7 @@ import analyticsService from "services/analytics.service";
import projectService from "services/project.service";
import cyclesService from "services/cycles.service";
import modulesService from "services/modules.service";
import trackEventServices from "services/track-event.service";
// components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
// icons
@ -22,9 +23,10 @@ import {
XMarkIcon,
} from "@heroicons/react/24/outline";
// types
import { IAnalyticsParams } from "types";
import { IAnalyticsParams, IWorkspace } from "types";
// fetch-keys
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
import useUserAuth from "hooks/use-user-auth";
type Props = {
isOpen: boolean;
@ -46,6 +48,8 @@ export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { user } = useUserAuth();
const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues });
const params: IAnalyticsParams = {
@ -95,6 +99,51 @@ export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
: null
);
const trackAnalyticsEvent = (tab: string) => {
const eventPayload: any = {
workspaceSlug: workspaceSlug?.toString(),
};
if (projectDetails) {
const workspaceDetails = projectDetails.workspace as IWorkspace;
eventPayload.workspaceId = workspaceDetails.id;
eventPayload.workspaceName = workspaceDetails.name;
eventPayload.projectId = projectDetails.id;
eventPayload.projectIdentifier = projectDetails.identifier;
eventPayload.projectName = projectDetails.name;
}
if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id;
eventPayload.workspaceName = details?.workspace_detail?.name;
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier;
eventPayload.projectName = details?.project_detail.name;
}
if (cycleDetails) {
eventPayload.cycleId = cycleDetails.id;
eventPayload.cycleName = cycleDetails.name;
}
if (moduleDetails) {
eventPayload.moduleId = moduleDetails.id;
eventPayload.moduleName = moduleDetails.name;
}
const eventType =
tab === "Scope and Demand" ? "SCOPE_AND_DEMAND_ANALYTICS" : "CUSTOM_ANALYTICS";
trackEventServices.trackAnalyticsEvent(
eventPayload,
cycleId ? `CYCLE_${eventType}` : moduleId ? `MODULE_${eventType}` : `PROJECT_${eventType}`,
user
);
};
const handleClose = () => {
onClose();
};
@ -146,6 +195,7 @@ export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
selected ? "bg-brand-surface-2" : ""
}`
}
onClick={() => trackAnalyticsEvent(tab)}
>
{tab}
</Tab>
@ -164,6 +214,7 @@ export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
control={control}
setValue={setValue}
fullScreen={fullScreen}
user={user}
/>
</Tab.Panel>
</Tab.Panels>

View File

@ -1,5 +1,3 @@
import Image from "next/image";
type Props = {
users: {
avatar: string | null;
@ -23,12 +21,10 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
>
<div className="flex items-center gap-2">
{user && user.avatar && user.avatar !== "" ? (
<div className="rounded-full h-4 w-4 flex-shrink-0">
<Image
<div className="relative rounded-full h-4 w-4 flex-shrink-0">
<img
src={user.avatar}
height="100%"
width="100%"
className="rounded-full"
className="absolute top-0 left-0 h-full w-full object-cover rounded-full"
alt={user.email ?? "None"}
/>
</div>

View File

@ -21,12 +21,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
const { asPath: currentPath } = useRouter();
return (
<DefaultLayout
meta={{
title: "Plane - Not Authorized",
description: "You are not authorized to view this page",
}}
>
<DefaultLayout>
<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">
<Image
@ -44,7 +39,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
{user ? (
<p>
You have signed in as {user.email}. <br />
<Link href={`/signin?next=${currentPath}`}>
<Link href={`/?next=${currentPath}`}>
<a className="font-medium text-brand-base">Sign in</a>
</Link>{" "}
with different account that has access to this page.
@ -52,7 +47,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
) : (
<p>
You need to{" "}
<Link href={`/signin?next=${currentPath}`}>
<Link href={`/?next=${currentPath}`}>
<a className="font-medium text-brand-base">Sign in</a>
</Link>{" "}
with an account that has access to this page.

View File

@ -1,44 +1,34 @@
import Link from "next/link";
import { useRouter } from "next/router";
// layouts
import DefaultLayout from "layouts/default-layout";
// ui
import { PrimaryButton, SecondaryButton } from "components/ui";
export const NotAWorkspaceMember = () => {
const router = useRouter();
return (
<DefaultLayout
meta={{
title: "Plane - Unauthorized User",
description: "Unauthorized user",
}}
>
<div className="grid h-full place-items-center p-4">
<div className="space-y-8 text-center">
<div className="space-y-2">
<h3 className="text-lg font-semibold">Not Authorized!</h3>
<p className="mx-auto w-1/2 text-sm text-brand-secondary">
You{"'"}re not a member of this workspace. Please contact the workspace admin to get
an invitation or check your pending invitations.
</p>
</div>
<div className="flex items-center justify-center gap-2">
<Link href="/invitations">
<a>
<SecondaryButton>Check pending invites</SecondaryButton>
</a>
</Link>
<Link href="/create-workspace">
<a>
<PrimaryButton>Create new workspace</PrimaryButton>
</a>
</Link>
</div>
export const NotAWorkspaceMember = () => (
<DefaultLayout>
<div className="grid h-full place-items-center p-4">
<div className="space-y-8 text-center">
<div className="space-y-2">
<h3 className="text-lg font-semibold">Not Authorized!</h3>
<p className="mx-auto w-1/2 text-sm text-brand-secondary">
You{"'"}re not a member of this workspace. Please contact the workspace admin to get an
invitation or check your pending invitations.
</p>
</div>
<div className="flex items-center justify-center gap-2">
<Link href="/invitations">
<a>
<SecondaryButton>Check pending invites</SecondaryButton>
</a>
</Link>
<Link href="/create-workspace">
<a>
<PrimaryButton>Create new workspace</PrimaryButton>
</a>
</Link>
</div>
</div>
</DefaultLayout>
);
};
</div>
</DefaultLayout>
);

View File

@ -3,6 +3,7 @@ import { useRouter } from "next/router";
import Link from "next/link";
// icons
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import { Icon } from "components/ui";
type BreadcrumbsProps = {
children: any;
@ -16,10 +17,13 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
<div className="flex items-center">
<button
type="button"
className="grid h-8 w-8 flex-shrink-0 cursor-pointer place-items-center rounded border border-brand-base text-center text-sm hover:bg-brand-surface-1"
className="group grid h-7 w-7 flex-shrink-0 cursor-pointer place-items-center rounded border border-brand-base text-center text-sm hover:bg-brand-surface-1"
onClick={() => router.back()}
>
<ArrowLeftIcon className="h-3 w-3" />
<Icon
iconName="keyboard_backspace"
className="text-base leading-4 text-brand-secondary group-hover:text-brand-base"
/>
</button>
{children}
</div>

View File

@ -7,7 +7,7 @@ import { Command } from "cmdk";
// services
import issuesService from "services/issues.service";
// types
import { IIssue } from "types";
import { ICurrentUserResponse, IIssue } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, PROJECT_MEMBERS } from "constants/fetch-keys";
// icons
@ -18,9 +18,10 @@ import { Avatar } from "components/ui";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: ICurrentUserResponse | undefined;
};
export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue, user }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
@ -57,18 +58,21 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
async (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
@ -80,7 +84,7 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }
);
const handleIssueAssignees = (assignee: string) => {
const updatedAssignees = issue.assignees ?? [];
const updatedAssignees = issue.assignees_list ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);

View File

@ -7,7 +7,7 @@ import { Command } from "cmdk";
// services
import issuesService from "services/issues.service";
// types
import { IIssue } from "types";
import { ICurrentUserResponse, IIssue } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import { PRIORITIES } from "constants/project";
@ -17,9 +17,10 @@ import { CheckIcon, getPriorityIcon } from "components/icons";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: ICurrentUserResponse;
};
export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue, user }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
@ -27,18 +28,22 @@ export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue }
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
async (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})

View File

@ -12,7 +12,7 @@ import { getStatesList } from "helpers/state.helper";
import issuesService from "services/issues.service";
import stateService from "services/state.service";
// types
import { IIssue } from "types";
import { ICurrentUserResponse, IIssue } from "types";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, STATES_LIST } from "constants/fetch-keys";
// icons
@ -21,9 +21,10 @@ import { CheckIcon, getStateGroupIcon } from "components/icons";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: ICurrentUserResponse | undefined;
};
export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue, user }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
@ -39,18 +40,21 @@ export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) =
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
async (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
.then(() => {
mutateIssueDetails();
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));

View File

@ -120,18 +120,23 @@ export const CommandPalette: React.FC = () => {
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
mutate(ISSUE_DETAILS(issueId as string));
@ -325,25 +330,33 @@ export const CommandPalette: React.FC = () => {
<>
<ShortcutsModal isOpen={isShortcutsModalOpen} setIsOpen={setIsShortcutsModalOpen} />
{workspaceSlug && (
<CreateProjectModal isOpen={isProjectModalOpen} setIsOpen={setIsProjectModalOpen} />
<CreateProjectModal
isOpen={isProjectModalOpen}
setIsOpen={setIsProjectModalOpen}
user={user}
/>
)}
{projectId && (
<>
<CreateUpdateCycleModal
isOpen={isCreateCycleModalOpen}
handleClose={() => setIsCreateCycleModalOpen(false)}
user={user}
/>
<CreateUpdateModuleModal
isOpen={isCreateModuleModalOpen}
setIsOpen={setIsCreateModuleModalOpen}
user={user}
/>
<CreateUpdateViewModal
handleClose={() => setIsCreateViewModalOpen(false)}
isOpen={isCreateViewModalOpen}
user={user}
/>
<CreateUpdatePageModal
isOpen={isCreateUpdatePageModalOpen}
handleClose={() => setIsCreateUpdatePageModalOpen(false)}
user={user}
/>
</>
)}
@ -352,6 +365,7 @@ export const CommandPalette: React.FC = () => {
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueDetails}
user={user}
/>
)}
@ -362,6 +376,7 @@ export const CommandPalette: React.FC = () => {
<BulkDeleteIssuesModal
isOpen={isBulkDeleteIssuesModalOpen}
setIsOpen={setIsBulkDeleteIssuesModalOpen}
user={user}
/>
<Transition.Root
show={isPaletteOpen}
@ -849,6 +864,7 @@ export const CommandPalette: React.FC = () => {
<ChangeIssueState
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
user={user}
/>
</>
)}
@ -856,12 +872,14 @@ export const CommandPalette: React.FC = () => {
<ChangeIssuePriority
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
user={user}
/>
)}
{page === "change-issue-assignee" && issueDetails && (
<ChangeIssueAssignee
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
user={user}
/>
)}
{page === "change-interface-theme" && (

View File

@ -5,7 +5,7 @@ import { SingleBoard } from "components/core/board-view/single-board";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IState, UserAuth } from "types";
import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types";
import { getStateGroupIcon } from "components/icons";
type Props = {
@ -19,6 +19,7 @@ type Props = {
handleTrashBox: (isDragging: boolean) => void;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
isCompleted?: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
};
@ -33,6 +34,7 @@ export const AllBoards: React.FC<Props> = ({
handleTrashBox,
removeIssue,
isCompleted = false,
user,
userAuth,
}) => {
const {
@ -65,6 +67,7 @@ export const AllBoards: React.FC<Props> = ({
handleTrashBox={handleTrashBox}
removeIssue={removeIssue}
isCompleted={isCompleted}
user={user}
userAuth={userAuth}
/>
);

View File

@ -17,7 +17,7 @@ import { PlusIcon } from "@heroicons/react/24/outline";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { IIssue, IState, UserAuth } from "types";
import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types";
type Props = {
type?: "issue" | "cycle" | "module";
@ -31,6 +31,7 @@ type Props = {
handleTrashBox: (isDragging: boolean) => void;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
isCompleted?: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
};
@ -46,6 +47,7 @@ export const SingleBoard: React.FC<Props> = ({
handleTrashBox,
removeIssue,
isCompleted = false,
user,
userAuth,
}) => {
// collapse/expand
@ -129,6 +131,7 @@ export const SingleBoard: React.FC<Props> = ({
removeIssue(issue.bridge_id, issue.id);
}}
isCompleted={isCompleted}
user={user}
userAuth={userAuth}
/>
)}

View File

@ -44,7 +44,7 @@ import { LayerDiagonalIcon } from "components/icons";
import { handleIssuesMutation } from "constants/issue";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import { IIssue, Properties, TIssueGroupByOptions, UserAuth } from "types";
import { ICurrentUserResponse, IIssue, Properties, TIssueGroupByOptions, UserAuth } from "types";
// fetch-keys
import {
CYCLE_DETAILS,
@ -69,6 +69,7 @@ type Props = {
handleDeleteIssue: (issue: IIssue) => void;
handleTrashBox: (isDragging: boolean) => void;
isCompleted?: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
};
@ -87,6 +88,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
handleDeleteIssue,
handleTrashBox,
isCompleted = false,
user,
userAuth,
}) => {
// context menu
@ -170,7 +172,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
}
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData)
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData, user)
.then(() => {
if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
@ -342,6 +344,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
user={user}
selfPositioned
/>
)}
@ -350,6 +353,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
user={user}
selfPositioned
/>
)}
@ -357,6 +361,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
@ -384,6 +389,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
tooltipPosition="left"
user={user}
selfPositioned
/>
)}
@ -392,6 +398,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
user={user}
selfPositioned
/>
)}

View File

@ -18,7 +18,7 @@ import { DangerButton, SecondaryButton } from "components/ui";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// types
import { IIssue } from "types";
import { ICurrentUserResponse, IIssue } from "types";
// fetch keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
@ -29,9 +29,10 @@ type FormInput = {
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
user: ICurrentUserResponse | undefined;
};
export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user }) => {
const [query, setQuery] = useState("");
const router = useRouter();
@ -91,9 +92,14 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
if (workspaceSlug && projectId) {
await issuesServices
.bulkDeleteIssues(workspaceSlug as string, projectId as string, {
issue_ids: data.delete_issue_ids,
})
.bulkDeleteIssues(
workspaceSlug as string,
projectId as string,
{
issue_ids: data.delete_issue_ids,
},
user
)
.then((res) => {
setToastAlert({
title: "Success",

View File

@ -24,7 +24,7 @@ import {
formatDate,
} from "helpers/calendar.helper";
// types
import { ICalendarRange, IIssue, UserAuth } from "types";
import { ICalendarRange, ICurrentUserResponse, IIssue, UserAuth } from "types";
// fetch-keys
import {
CYCLE_ISSUES_WITH_PARAMS,
@ -38,6 +38,7 @@ type Props = {
handleDeleteIssue: (issue: IIssue) => void;
addIssueToDate: (date: string) => void;
isCompleted: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
};
@ -46,6 +47,7 @@ export const CalendarView: React.FC<Props> = ({
handleDeleteIssue,
addIssueToDate,
isCompleted = false,
user,
userAuth,
}) => {
const [showWeekEnds, setShowWeekEnds] = useState(false);
@ -134,9 +136,15 @@ export const CalendarView: React.FC<Props> = ({
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, draggableId, {
target_date: destination?.droppableId,
})
.patchIssue(
workspaceSlug as string,
projectId as string,
draggableId,
{
target_date: destination?.droppableId,
},
user
)
.then(() => mutate(fetchKey));
};
@ -219,6 +227,7 @@ export const CalendarView: React.FC<Props> = ({
addIssueToDate={addIssueToDate}
isMonthlyView={isMonthlyView}
showWeekEnds={showWeekEnds}
user={user}
isNotAllowed={isNotAllowed}
/>
))}

View File

@ -10,7 +10,7 @@ import { PlusSmallIcon } from "@heroicons/react/24/outline";
// helper
import { formatDate } from "helpers/calendar.helper";
// types
import { IIssue } from "types";
import { ICurrentUserResponse, IIssue } from "types";
type Props = {
handleEditIssue: (issue: IIssue) => void;
@ -23,6 +23,7 @@ type Props = {
addIssueToDate: (date: string) => void;
isMonthlyView: boolean;
showWeekEnds: boolean;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
@ -34,6 +35,7 @@ export const SingleCalendarDate: React.FC<Props> = ({
addIssueToDate,
isMonthlyView,
showWeekEnds,
user,
isNotAllowed,
}) => {
const [showAllIssues, setShowAllIssues] = useState(false);
@ -72,6 +74,7 @@ export const SingleCalendarDate: React.FC<Props> = ({
issue={issue}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}

View File

@ -28,7 +28,7 @@ import { LayerDiagonalIcon } from "components/icons";
// helper
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// type
import { IIssue } from "types";
import { ICurrentUserResponse, IIssue } from "types";
// fetch-keys
import {
CYCLE_ISSUES_WITH_PARAMS,
@ -44,6 +44,7 @@ type Props = {
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
issue: IIssue;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
@ -54,6 +55,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
provided,
snapshot,
issue,
user,
isNotAllowed,
}) => {
const router = useRouter();
@ -95,7 +97,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, formData)
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, formData, user)
.then(() => {
mutate(fetchKey);
})
@ -183,6 +185,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
user={user}
isNotAllowed={isNotAllowed}
/>
)}
@ -192,6 +195,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
partialUpdateIssue={partialUpdateIssue}
position="left"
isNotAllowed={isNotAllowed}
user={user}
/>
)}
@ -199,6 +203,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
@ -227,6 +232,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
user={user}
isNotAllowed={isNotAllowed}
/>
)}
@ -235,6 +241,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
user={user}
isNotAllowed={isNotAllowed}
/>
)}

View File

@ -1,5 +1,6 @@
import React from "react";
import Image from "next/image";
import Link from "next/link";
// icons
import {
@ -22,7 +23,6 @@ import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper"
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import RemirrorRichTextEditor from "components/rich-text-editor";
import Link from "next/link";
const activityDetails: {
[key: string]: {
@ -206,7 +206,7 @@ export const Feeds: React.FC<any> = ({ activities }) => (
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
<Image
<img
src={activity.actor_detail.avatar}
alt={activity.actor_detail.first_name}
height={30}
@ -276,7 +276,7 @@ export const Feeds: React.FC<any> = ({ activities }) => (
activityDetails[activity.field as keyof typeof activityDetails]?.icon
) : activity.actor_detail.avatar &&
activity.actor_detail.avatar !== "" ? (
<Image
<img
src={activity.actor_detail.avatar}
alt={activity.actor_detail.first_name}
height={24}

View File

@ -10,6 +10,7 @@ import aiService from "services/ai.service";
import trackEventServices from "services/track-event.service";
// hooks
import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
@ -60,6 +61,8 @@ export const GptAssistantModal: React.FC<Props> = ({
const router = useRouter();
const { workspaceSlug } = router.query;
const { user } = useUserAuth();
const editorRef = useRef<any>(null);
const { setToastAlert } = useToast();
@ -97,10 +100,15 @@ export const GptAssistantModal: React.FC<Props> = ({
}
await aiService
.createGptTask(workspaceSlug as string, projectId as string, {
prompt: content && content !== "" ? content : htmlContent ?? "",
task: formData.task,
})
.createGptTask(
workspaceSlug as string,
projectId as string,
{
prompt: content && content !== "" ? content : htmlContent ?? "",
task: formData.task,
},
user
)
.then((res) => {
setResponse(res.response_html);
setFocus("task");
@ -190,10 +198,15 @@ export const GptAssistantModal: React.FC<Props> = ({
if (block)
trackEventServices.trackUseGPTResponseEvent(
block,
"USE_GPT_RESPONSE_IN_PAGE_BLOCK"
"USE_GPT_RESPONSE_IN_PAGE_BLOCK",
user
);
else if (issue)
trackEventServices.trackUseGPTResponseEvent(issue, "USE_GPT_RESPONSE_IN_ISSUE");
trackEventServices.trackUseGPTResponseEvent(
issue,
"USE_GPT_RESPONSE_IN_ISSUE",
user
);
}}
>
Use this response

View File

@ -1,8 +1,5 @@
import React, { useEffect, useState, useRef } from "react";
// next
import Image from "next/image";
// swr
import useSWR from "swr";
@ -107,12 +104,7 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
placeholder="Search for images"
/>
<PrimaryButton
type="button"
onClick={() => setSearchParams(formData.search)}
className="bg-indigo-600"
size="sm"
>
<PrimaryButton onClick={() => setSearchParams(formData.search)} size="sm">
Search
</PrimaryButton>
</div>
@ -123,12 +115,10 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
key={image.id}
className="relative col-span-2 aspect-video md:col-span-1"
>
<Image
<img
src={image.urls.small}
alt={image.alt_description}
layout="fill"
objectFit="cover"
className="cursor-pointer rounded"
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);

View File

@ -17,6 +17,7 @@ import { useProjectMyMembership } from "contexts/project-member.context";
// hooks
import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view";
import useUserAuth from "hooks/use-user-auth";
// components
import { AllLists, AllBoards, FilterList, CalendarView, GanttChartView } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
@ -89,6 +90,8 @@ export const IssuesView: React.FC<Props> = ({
const { memberRole } = useProjectMyMembership();
const { user } = useUserAuth();
const { setToastAlert } = useToast();
const {
@ -220,11 +223,17 @@ export const IssuesView: React.FC<Props> = ({
// patch request
issuesService
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
priority: draggedItem.priority,
state: draggedItem.state,
sort_order: draggedItem.sort_order,
})
.patchIssue(
workspaceSlug as string,
projectId as string,
draggedItem.id,
{
priority: draggedItem.priority,
state: draggedItem.state,
sort_order: draggedItem.sort_order,
},
user
)
.then((response) => {
const sourceStateBeforeDrag = states.find((state) => state.name === source.droppableId);
@ -232,14 +241,17 @@ export const IssuesView: React.FC<Props> = ({
sourceStateBeforeDrag?.group !== "completed" &&
response?.state_detail?.group === "completed"
)
trackEventServices.trackIssueMarkedAsDoneEvent({
workspaceSlug,
workspaceId: draggedItem.workspace,
projectName: draggedItem.project_detail.name,
projectIdentifier: draggedItem.project_detail.identifier,
projectId,
issueId: draggedItem.id,
});
trackEventServices.trackIssueMarkedAsDoneEvent(
{
workspaceSlug,
workspaceId: draggedItem.workspace,
projectName: draggedItem.project_detail.name,
projectIdentifier: draggedItem.project_detail.identifier,
projectId,
issueId: draggedItem.id,
},
user
);
if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
@ -419,6 +431,7 @@ export const IssuesView: React.FC<Props> = ({
isOpen={createViewModal !== null}
handleClose={() => setCreateViewModal(null)}
preLoadedData={createViewModal}
user={user}
/>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
@ -437,6 +450,7 @@ export const IssuesView: React.FC<Props> = ({
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueToDelete}
user={user}
/>
<TransferIssuesModal
handleClose={() => setTransferIssuesModal(false)}
@ -508,6 +522,7 @@ export const IssuesView: React.FC<Props> = ({
: null
}
isCompleted={isCompleted}
user={user}
userAuth={memberRole}
/>
) : issueView === "kanban" ? (
@ -528,6 +543,7 @@ export const IssuesView: React.FC<Props> = ({
: null
}
isCompleted={isCompleted}
user={user}
userAuth={memberRole}
/>
) : issueView === "calendar" ? (
@ -536,6 +552,7 @@ export const IssuesView: React.FC<Props> = ({
handleDeleteIssue={handleDeleteIssue}
addIssueToDate={addIssueToDate}
isCompleted={isCompleted}
user={user}
userAuth={memberRole}
/>
) : (

View File

@ -3,7 +3,7 @@ import useIssuesView from "hooks/use-issues-view";
// components
import { SingleList } from "components/core/list-view/single-list";
// types
import { IIssue, IState, UserAuth } from "types";
import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types";
// types
type Props = {
@ -16,6 +16,7 @@ type Props = {
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
isCompleted?: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
};
@ -29,6 +30,7 @@ export const AllLists: React.FC<Props> = ({
handleDeleteIssue,
removeIssue,
isCompleted = false,
user,
userAuth,
}) => {
const { groupedByIssues, groupByProperty: selectedGroup, showEmptyGroups } = useIssuesView();
@ -58,6 +60,7 @@ export const AllLists: React.FC<Props> = ({
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
removeIssue={removeIssue}
isCompleted={isCompleted}
user={user}
userAuth={userAuth}
/>
);

View File

@ -36,7 +36,7 @@ import { LayerDiagonalIcon } from "components/icons";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import { handleIssuesMutation } from "constants/issue";
// types
import { IIssue, Properties, UserAuth } from "types";
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types";
// fetch-keys
import {
CYCLE_DETAILS,
@ -57,6 +57,7 @@ type Props = {
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
isCompleted?: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
};
@ -71,6 +72,7 @@ export const SingleListIssue: React.FC<Props> = ({
groupTitle,
handleDeleteIssue,
isCompleted = false,
user,
userAuth,
}) => {
// context menu
@ -141,7 +143,7 @@ export const SingleListIssue: React.FC<Props> = ({
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData)
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData, user)
.then(() => {
if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
@ -241,6 +243,7 @@ export const SingleListIssue: React.FC<Props> = ({
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
user={user}
isNotAllowed={isNotAllowed}
/>
)}
@ -249,6 +252,7 @@ export const SingleListIssue: React.FC<Props> = ({
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
user={user}
isNotAllowed={isNotAllowed}
/>
)}
@ -256,6 +260,7 @@ export const SingleListIssue: React.FC<Props> = ({
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
@ -284,6 +289,7 @@ export const SingleListIssue: React.FC<Props> = ({
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
user={user}
isNotAllowed={isNotAllowed}
/>
)}
@ -292,6 +298,7 @@ export const SingleListIssue: React.FC<Props> = ({
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
user={user}
isNotAllowed={isNotAllowed}
/>
)}

View File

@ -19,7 +19,14 @@ import { getPriorityIcon, getStateGroupIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IIssueLabels, IState, TIssueGroupByOptions, UserAuth } from "types";
import {
ICurrentUserResponse,
IIssue,
IIssueLabels,
IState,
TIssueGroupByOptions,
UserAuth,
} from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
@ -39,6 +46,7 @@ type Props = {
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
isCompleted?: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
};
@ -56,6 +64,7 @@ export const SingleList: React.FC<Props> = ({
openIssuesListModal,
removeIssue,
isCompleted = false,
user,
userAuth,
}) => {
const router = useRouter();
@ -208,6 +217,7 @@ export const SingleList: React.FC<Props> = ({
removeIssue(issue.bridge_id, issue.id);
}}
isCompleted={isCompleted}
user={user}
userAuth={userAuth}
/>
))

View File

@ -1,7 +1,6 @@
import React from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
@ -40,25 +39,12 @@ import {
} from "helpers/date-time.helper";
import { truncateText } from "helpers/string.helper";
// types
import {
CompletedCyclesResponse,
CurrentAndUpcomingCyclesResponse,
DraftCyclesResponse,
ICycle,
IIssue,
} from "types";
import { 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";
import { CURRENT_CYCLE_LIST, CYCLES_LIST, CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
type TSingleStatProps = {
cycle: ICycle;
isCompleted?: boolean;
};
const stateGroups = [
@ -89,7 +75,7 @@ const stateGroups = [
},
];
export const ActiveCycleDetails: React.FC<TSingleStatProps> = ({ cycle, isCompleted = false }) => {
export const ActiveCycleDetails: React.FC<TSingleStatProps> = ({ cycle }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -111,51 +97,18 @@ export const ActiveCycleDetails: React.FC<TSingleStatProps> = ({ cycle, isComple
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<ICycle[]>(
CURRENT_CYCLE_LIST(projectId as string),
(prevData) =>
(prevData ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
false
);
mutate(
CYCLE_DETAILS(projectId as string),
CYCLES_LIST(projectId as string),
(prevData: any) =>
(prevData ?? []).map((c: any) => ({
...c,
@ -180,51 +133,18 @@ export const ActiveCycleDetails: React.FC<TSingleStatProps> = ({ cycle, isComple
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<ICycle[]>(
CURRENT_CYCLE_LIST(projectId as string),
(prevData) =>
(prevData ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
false
);
mutate(
CYCLE_DETAILS(projectId as string),
CYCLES_LIST(projectId as string),
(prevData: any) =>
(prevData ?? []).map((c: any) => ({
...c,
@ -244,17 +164,20 @@ export const ActiveCycleDetails: React.FC<TSingleStatProps> = ({ cycle, isComple
});
};
const { data: issues } = useSWR<IIssue[]>(
workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null,
const { data: issues } = useSWR(
workspaceSlug && projectId && cycle.id
? CYCLE_ISSUES_WITH_PARAMS(cycle.id, { priority: "high" })
: null,
workspaceSlug && projectId && cycle.id
? () =>
cyclesService.getCycleIssues(
cyclesService.getCycleIssuesWithParams(
workspaceSlug as string,
projectId as string,
cycle.id as string
cycle.id,
{ priority: "high" }
)
: null
);
) as { data: IIssue[] };
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
@ -379,7 +302,7 @@ export const ActiveCycleDetails: React.FC<TSingleStatProps> = ({ cycle, isComple
<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
<img
src={cycle.owned_by.avatar}
height={16}
width={16}

View File

@ -1,6 +1,5 @@
import React from "react";
import Image from "next/image";
import { useRouter } from "next/router";
import useSWR from "swr";
@ -16,8 +15,6 @@ import useLocalStorage from "hooks/use-local-storage";
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
@ -125,9 +122,9 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ issues }) => {
<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}
<div className="h-5 w-5 rounded-full border-2 border-brand-base bg-brand-surface-2">
<img
src="/user.png"
height="100%"
width="100%"
className="rounded-full"

View File

@ -1,82 +0,0 @@
import { useState } from "react";
// components
import { DeleteCycleModal, SingleCycleCard } 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 AllCyclesBoard: 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 className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
{cycles.map((cycle) => (
<SingleCycleCard
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
/>
))}
</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

@ -1,86 +0,0 @@
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

@ -1,126 +0,0 @@
import { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import cyclesService from "services/cycles.service";
// components
import { DeleteCycleModal, SingleCycleCard, SingleCycleList } from "components/cycles";
// icons
import { ExclamationIcon } from "components/icons";
// types
import { ICycle, SelectCycleType } from "types";
// fetch-keys
import { CYCLE_COMPLETE_LIST } from "constants/fetch-keys";
import { EmptyState, Loader } from "components/ui";
// image
import emptyCycle from "public/empty-state/empty-cycle.svg";
export interface CompletedCyclesListProps {
cycleView: string;
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
}
export const CompletedCycles: React.FC<CompletedCyclesListProps> = ({
cycleView,
setCreateUpdateCycleModal,
setSelectedCycle,
}) => {
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectCycleType>();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: completedCycles } = useSWR(
workspaceSlug && projectId ? CYCLE_COMPLETE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => cyclesService.getCompletedCycles(workspaceSlug as string, projectId as string)
: null
);
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}
/>
{completedCycles ? (
completedCycles.completed_cycles.length > 0 ? (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm text-brand-secondary">
<ExclamationIcon
height={14}
width={14}
className="fill-current text-brand-secondary"
/>
<span>Completed cycles are not editable.</span>
</div>
{cycleView === "list" && (
<div>
{completedCycles.completed_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)}
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>
)}
</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.Item height="200px" />
<Loader.Item height="200px" />
</Loader>
)}
</>
);
};

View File

@ -4,6 +4,8 @@ import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// types
import { ICycle } from "types";
@ -31,9 +33,11 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles }) => {
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div className="flex-shrink-0 w-[4px] h-full" style={{ backgroundColor: "#858e96" }} />
<div className="w-full text-brand-base text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden">
{data?.name}
</div>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-brand-base text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
</a>
</Link>
);

View File

@ -0,0 +1,29 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import cyclesService from "services/cycles.service";
// components
import { CyclesView } from "components/cycles";
// fetch-keys
import { CYCLES_LIST } from "constants/fetch-keys";
type Props = {
viewType: string | null;
};
export const AllCyclesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: allCyclesList } = useSWR(
workspaceSlug && projectId ? CYCLES_LIST(projectId.toString()) : null,
workspaceSlug && projectId
? () =>
cyclesService.getCyclesWithParams(workspaceSlug.toString(), projectId.toString(), "all")
: null
);
return <CyclesView cycles={allCyclesList} viewType={viewType} />;
};

View File

@ -0,0 +1,33 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import cyclesService from "services/cycles.service";
// components
import { CyclesView } from "components/cycles";
// fetch-keys
import { COMPLETED_CYCLES_LIST } from "constants/fetch-keys";
type Props = {
viewType: string | null;
};
export const CompletedCyclesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: completedCyclesList } = useSWR(
workspaceSlug && projectId ? COMPLETED_CYCLES_LIST(projectId.toString()) : null,
workspaceSlug && projectId
? () =>
cyclesService.getCyclesWithParams(
workspaceSlug.toString(),
projectId.toString(),
"completed"
)
: null
);
return <CyclesView cycles={completedCyclesList} viewType={viewType} />;
};

View File

@ -0,0 +1,29 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import cyclesService from "services/cycles.service";
// components
import { CyclesView } from "components/cycles";
// fetch-keys
import { DRAFT_CYCLES_LIST } from "constants/fetch-keys";
type Props = {
viewType: string | null;
};
export const DraftCyclesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: draftCyclesList } = useSWR(
workspaceSlug && projectId ? DRAFT_CYCLES_LIST(projectId.toString()) : null,
workspaceSlug && projectId
? () =>
cyclesService.getCyclesWithParams(workspaceSlug.toString(), projectId.toString(), "draft")
: null
);
return <CyclesView cycles={draftCyclesList} viewType={viewType} />;
};

View File

@ -0,0 +1,4 @@
export * from "./all-cycles-list";
export * from "./completed-cycles-list";
export * from "./draft-cycles-list";
export * from "./upcoming-cycles-list";

View File

@ -0,0 +1,33 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import cyclesService from "services/cycles.service";
// components
import { CyclesView } from "components/cycles";
// fetch-keys
import { UPCOMING_CYCLES_LIST } from "constants/fetch-keys";
type Props = {
viewType: string | null;
};
export const UpcomingCyclesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: upcomingCyclesList } = useSWR(
workspaceSlug && projectId ? UPCOMING_CYCLES_LIST(projectId.toString()) : null,
workspaceSlug && projectId
? () =>
cyclesService.getCyclesWithParams(
workspaceSlug.toString(),
projectId.toString(),
"upcoming"
)
: null
);
return <CyclesView cycles={upcomingCyclesList} viewType={viewType} />;
};

View File

@ -1,249 +1,233 @@
import React, { useEffect } from "react";
import dynamic from "next/dynamic";
// headless ui
import { Tab } from "@headlessui/react";
import React, { useState } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import cyclesService from "services/cycles.service";
// hooks
import useLocalStorage from "hooks/use-local-storage";
import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth";
// components
import {
ActiveCycleDetails,
CompletedCyclesListProps,
AllCyclesBoard,
AllCyclesList,
CreateUpdateCycleModal,
CyclesListGanttChartView,
DeleteCycleModal,
SingleCycleCard,
SingleCycleList,
} from "components/cycles";
// ui
import { EmptyState, Loader } from "components/ui";
// icons
import { ChartBarIcon, ListBulletIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
// images
import emptyCycle from "public/empty-state/empty-cycle.svg";
// helpers
import { getDateRangeStatus } from "helpers/date-time.helper";
// types
import { ICycle } from "types";
// fetch-keys
import {
SelectCycleType,
ICycle,
CurrentAndUpcomingCyclesResponse,
DraftCyclesResponse,
} from "types";
COMPLETED_CYCLES_LIST,
CURRENT_CYCLE_LIST,
CYCLES_LIST,
DRAFT_CYCLES_LIST,
UPCOMING_CYCLES_LIST,
} from "constants/fetch-keys";
type Props = {
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
cyclesCompleteList: ICycle[] | undefined;
currentAndUpcomingCycles: CurrentAndUpcomingCyclesResponse | undefined;
draftCycles: DraftCyclesResponse | undefined;
cycles: ICycle[] | undefined;
viewType: string | null;
};
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");
export const CyclesView: React.FC<Props> = ({ cycles, viewType }) => {
const [createUpdateCycleModal, setCreateUpdateCycleModal] = useState(false);
const [selectedCycleToUpdate, setSelectedCycleToUpdate] = useState<ICycle | null>(null);
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 [deleteCycleModal, setDeleteCycleModal] = useState(false);
const [selectedCycleToDelete, setSelectedCycleToDelete] = useState<ICycle | null>(null);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { user } = useUserAuth();
const { setToastAlert } = useToast();
const handleEditCycle = (cycle: ICycle) => {
setSelectedCycleToUpdate(cycle);
setCreateUpdateCycleModal(true);
};
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>
),
}
);
const handleDeleteCycle = (cycle: ICycle) => {
setSelectedCycleToDelete(cycle);
setDeleteCycleModal(true);
};
const handleAddToFavorites = (cycle: ICycle) => {
if (!workspaceSlug || !projectId) return;
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const fetchKey =
cycleStatus === "current"
? CURRENT_CYCLE_LIST(projectId as string)
: cycleStatus === "upcoming"
? UPCOMING_CYCLES_LIST(projectId as string)
: cycleStatus === "completed"
? COMPLETED_CYCLES_LIST(projectId as string)
: DRAFT_CYCLES_LIST(projectId as string);
mutate<ICycle[]>(
fetchKey,
(prevData) =>
(prevData ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
false
);
mutate(
CYCLES_LIST(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 = (cycle: ICycle) => {
if (!workspaceSlug || !projectId) return;
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const fetchKey =
cycleStatus === "current"
? CURRENT_CYCLE_LIST(projectId as string)
: cycleStatus === "upcoming"
? UPCOMING_CYCLES_LIST(projectId as string)
: cycleStatus === "completed"
? COMPLETED_CYCLES_LIST(projectId as string)
: DRAFT_CYCLES_LIST(projectId as string);
mutate<ICycle[]>(
fetchKey,
(prevData) =>
(prevData ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
false
);
mutate(
CYCLES_LIST(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.",
});
});
};
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 === "board" ? "bg-brand-surface-2" : ""
}`}
onClick={() => setCyclesView("board")}
>
<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");
setCycleTab("All");
}}
>
<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)}
selectedIndex={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" || tab === "Completed")
)
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 === "board" && (
<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}
<CreateUpdateCycleModal
isOpen={createUpdateCycleModal}
handleClose={() => setCreateUpdateCycleModal(false)}
data={selectedCycleToUpdate}
user={user}
/>
<DeleteCycleModal
isOpen={deleteCycleModal}
setIsOpen={setDeleteCycleModal}
data={selectedCycleToDelete}
user={user}
/>
{cycles ? (
cycles.length > 0 ? (
viewType === "list" ? (
<div className="divide-y divide-brand-base">
{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)}
handleAddToFavorites={() => handleAddToFavorites(cycle)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(cycle)}
/>
</div>
</div>
))}
</div>
) : viewType === "board" ? (
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
{cycles.map((cycle) => (
<SingleCycleCard
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
handleAddToFavorites={() => handleAddToFavorites(cycle)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(cycle)}
/>
)}
</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>
))}
</div>
) : (
<CyclesListGanttChartView cycles={cycles ?? []} />
)
) : (
<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}
/>
)
) : viewType === "list" ? (
<Loader className="space-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
) : viewType === "board" ? (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" />
<Loader.Item height="200px" />
<Loader.Item height="200px" />
</Loader>
) : (
<Loader>
<Loader.Item height="300px" />
</Loader>
)}
</>
);
};

View File

@ -14,24 +14,20 @@ import { DangerButton, SecondaryButton } from "components/ui";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// types
import type {
CompletedCyclesResponse,
CurrentAndUpcomingCyclesResponse,
DraftCyclesResponse,
ICycle,
} from "types";
import type { ICurrentUserResponse, ICycle } from "types";
type TConfirmCycleDeletionProps = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data?: ICycle;
data?: ICycle | null;
user: ICurrentUserResponse | undefined;
};
// fetch-keys
import {
CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DETAILS,
CYCLE_DRAFT_LIST,
CYCLE_LIST,
COMPLETED_CYCLES_LIST,
CURRENT_CYCLE_LIST,
CYCLES_LIST,
DRAFT_CYCLES_LIST,
UPCOMING_CYCLES_LIST,
} from "constants/fetch-keys";
import { getDateRangeStatus } from "helpers/date-time.helper";
@ -39,6 +35,7 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
isOpen,
setIsOpen,
data,
user,
}) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
@ -58,65 +55,30 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
setIsDeleteLoading(true);
await cycleService
.deleteCycle(workspaceSlug as string, data.project, data.id)
.deleteCycle(workspaceSlug as string, data.project, data.id, user)
.then(() => {
switch (getDateRangeStatus(data.start_date, data.end_date)) {
case "completed":
mutate<CompletedCyclesResponse>(
CYCLE_COMPLETE_LIST(projectId as string),
(prevData) => {
if (!prevData) return;
const cycleType = getDateRangeStatus(data.start_date, data.end_date);
const fetchKey =
cycleType === "current"
? CURRENT_CYCLE_LIST(projectId as string)
: cycleType === "upcoming"
? UPCOMING_CYCLES_LIST(projectId as string)
: cycleType === "completed"
? COMPLETED_CYCLES_LIST(projectId as string)
: DRAFT_CYCLES_LIST(projectId as string);
return {
completed_cycles: prevData.completed_cycles?.filter(
(cycle) => cycle.id !== data?.id
),
};
},
false
);
break;
case "current":
mutate<CurrentAndUpcomingCyclesResponse>(
CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string),
(prevData) => {
if (!prevData) return;
return {
current_cycle: prevData.current_cycle?.filter((c) => c.id !== data?.id),
upcoming_cycle: prevData.upcoming_cycle,
};
},
false
);
break;
case "upcoming":
mutate<CurrentAndUpcomingCyclesResponse>(
CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string),
(prevData) => {
if (!prevData) return;
mutate<ICycle[]>(
fetchKey,
(prevData) => {
if (!prevData) return;
return prevData.filter((cycle) => cycle.id !== data?.id);
},
false
);
return {
current_cycle: prevData.current_cycle,
upcoming_cycle: prevData.upcoming_cycle?.filter((c) => c.id !== data?.id),
};
},
false
);
break;
default:
mutate<DraftCyclesResponse>(
CYCLE_DRAFT_LIST(projectId as string),
(prevData) => {
if (!prevData) return;
return {
draft_cycles: prevData.draft_cycles?.filter((cycle) => cycle.id !== data?.id),
};
},
false
);
}
mutate(
CYCLE_DETAILS(projectId as string),
CYCLES_LIST(projectId as string),
(prevData: any) => {
if (!prevData) return;
return prevData.filter((cycle: any) => cycle.id !== data?.id);

View File

@ -1,78 +0,0 @@
import React from "react";
import { LinearProgressIndicator } from "components/ui";
export const EmptyCycle = () => {
const emptyCycleData = [
{
id: 1,
name: "backlog",
value: 20,
color: "#DEE2E6",
},
{
id: 2,
name: "unstarted",
value: 14,
color: "#26B5CE",
},
{
id: 3,
name: "started",
value: 27,
color: "#F7AE59",
},
{
id: 4,
name: "cancelled",
value: 15,
color: "#D687FF",
},
{
id: 5,
name: "completed",
value: 14,
color: "#09A953",
},
];
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-5 ">
<div className="relative h-32 w-72">
<div className="absolute right-0 top-0 flex w-64 flex-col rounded-[10px] bg-brand-surface-2 text-xs shadow">
<div className="flex flex-col items-start justify-center gap-2.5 p-3.5">
<span className="text-sm font-semibold text-brand-base">Cycle Name</span>
<div className="flex h-full w-full items-center gap-4">
<span className="h-2 w-20 rounded-full bg-brand-surface-2" />
<span className="h-2 w-20 rounded-full bg-brand-surface-2" />
</div>
</div>
<div className="border-t border-brand-base bg-brand-surface-1 px-4 py-3">
<LinearProgressIndicator data={emptyCycleData} />
</div>
</div>
<div className="absolute left-0 bottom-0 flex w-64 flex-col rounded-[10px] bg-brand-surface-2 text-xs shadow">
<div className="flex flex-col items-start justify-center gap-2.5 p-3.5">
<span className="text-sm font-semibold text-brand-base">Cycle Name</span>
<div className="flex h-full w-full items-center gap-4">
<span className="h-2 w-20 rounded-full bg-brand-surface-2" />
<span className="h-2 w-20 rounded-full bg-brand-surface-2" />
</div>
</div>
<div className="border-t border-brand-base bg-brand-surface-1 px-4 py-3">
<LinearProgressIndicator data={emptyCycleData} />
</div>
</div>
</div>
<div className="flex flex-col items-center justify-center gap-4 text-center ">
<h3 className="text-xl font-semibold">Create New Cycle</h3>
<p className="text-sm text-brand-secondary">
Sprint more effectively with Cycles by confining your project <br /> to a fixed amount of
time. Create new cycle now.
</p>
</div>
</div>
);
};

View File

@ -12,7 +12,7 @@ type Props = {
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
handleClose: () => void;
status: boolean;
data?: ICycle;
data?: ICycle | null;
};
const defaultValues: Partial<ICycle> = {
@ -28,7 +28,6 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
formState: { errors, isSubmitting },
handleSubmit,
control,
watch,
reset,
} = useForm<ICycle>({
defaultValues,

View File

@ -4,6 +4,8 @@ import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// hooks
import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view";
@ -38,9 +40,23 @@ export const CycleIssuesGanttChartView: FC<Props> = ({}) => {
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: data?.state_detail?.color || "#858e96" }}
/>
<div className="w-full text-brand-base text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden">
{data?.name}
</div>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-brand-base text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
{data.infoToggle && (
<Tooltip
tooltipContent={`No due-date set, rendered according to last updated date.`}
className={`z-[999999]`}
>
<div className="flex-shrink-0 mx-2 w-[18px] h-[18px] overflow-hidden flex justify-center items-center">
<span className="material-symbols-rounded text-brand-secondary text-[18px]">
info
</span>
</div>
</Tooltip>
)}
</a>
</Link>
);
@ -59,10 +75,20 @@ export const CycleIssuesGanttChartView: FC<Props> = ({}) => {
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
if (_block?.start_date && _block.target_date) console.log("_block", _block);
let startDate = new Date(_block.created_at);
let targetDate = new Date(_block.updated_at);
let infoToggle = true;
if (_block?.start_date && _block.target_date) {
startDate = _block?.start_date;
targetDate = _block.target_date;
infoToggle = false;
}
return {
start_date: new Date(_block.created_at),
target_date: new Date(_block.updated_at),
start_date: new Date(startDate),
target_date: new Date(targetDate),
infoToggle: infoToggle,
data: _block,
};
})

View File

@ -1,18 +1,15 @@
export * from "./cycles-list";
export * from "./active-cycle-details";
export * from "./cycles-view";
export * from "./completed-cycles";
export * from "./active-cycle-stats";
export * from "./cycles-list-gantt-chart";
export * from "./all-cycles-board";
export * from "./all-cycles-list";
export * from "./cycles-view";
export * from "./delete-cycle-modal";
export * from "./form";
export * from "./gantt-chart";
export * from "./modal";
export * from "./select";
export * from "./sidebar";
export * from "./single-cycle-list";
export * from "./single-cycle-card";
export * from "./empty-cycle";
export * from "./single-cycle-list";
export * from "./transfer-issues-modal";
export * from "./transfer-issues";
export * from "./active-cycle-stats";

View File

@ -15,26 +15,29 @@ import { CycleForm } from "components/cycles";
// helper
import { getDateRangeStatus, isDateGreaterThanToday } from "helpers/date-time.helper";
// types
import type { ICycle } from "types";
import type { ICurrentUserResponse, ICycle } from "types";
// fetch keys
import {
CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DETAILS,
CYCLE_DRAFT_LIST,
CYCLE_INCOMPLETE_LIST,
COMPLETED_CYCLES_LIST,
CURRENT_CYCLE_LIST,
CYCLES_LIST,
DRAFT_CYCLES_LIST,
INCOMPLETE_CYCLES_LIST,
UPCOMING_CYCLES_LIST,
} from "constants/fetch-keys";
type CycleModalProps = {
isOpen: boolean;
handleClose: () => void;
data?: ICycle;
data?: ICycle | null;
user: ICurrentUserResponse | undefined;
};
export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
isOpen,
handleClose,
data,
user,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -42,24 +45,26 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
const { setToastAlert } = useToast();
const createCycle = async (payload: Partial<ICycle>) => {
if (!workspaceSlug || !projectId) return;
await cycleService
.createCycle(workspaceSlug as string, projectId as string, payload)
.createCycle(workspaceSlug.toString(), projectId.toString(), payload, user)
.then((res) => {
switch (getDateRangeStatus(res.start_date, res.end_date)) {
case "completed":
mutate(CYCLE_COMPLETE_LIST(projectId as string));
mutate(COMPLETED_CYCLES_LIST(projectId.toString()));
break;
case "current":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
mutate(CURRENT_CYCLE_LIST(projectId.toString()));
break;
case "upcoming":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
mutate(UPCOMING_CYCLES_LIST(projectId.toString()));
break;
default:
mutate(CYCLE_DRAFT_LIST(projectId as string));
mutate(DRAFT_CYCLES_LIST(projectId.toString()));
}
mutate(CYCLE_INCOMPLETE_LIST(projectId as string));
mutate(CYCLE_DETAILS(projectId as string));
mutate(INCOMPLETE_CYCLES_LIST(projectId.toString()));
mutate(CYCLES_LIST(projectId.toString()));
handleClose();
setToastAlert({
@ -68,7 +73,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
message: "Cycle created successfully.",
});
})
.catch((err) => {
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
@ -78,39 +83,41 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
};
const updateCycle = async (cycleId: string, payload: Partial<ICycle>) => {
if (!workspaceSlug || !projectId) return;
await cycleService
.updateCycle(workspaceSlug as string, projectId as string, cycleId, payload)
.updateCycle(workspaceSlug.toString(), projectId.toString(), cycleId, payload, user)
.then((res) => {
switch (getDateRangeStatus(data?.start_date, data?.end_date)) {
case "completed":
mutate(CYCLE_COMPLETE_LIST(projectId as string));
mutate(COMPLETED_CYCLES_LIST(projectId.toString()));
break;
case "current":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
mutate(CURRENT_CYCLE_LIST(projectId.toString()));
break;
case "upcoming":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
mutate(UPCOMING_CYCLES_LIST(projectId.toString()));
break;
default:
mutate(CYCLE_DRAFT_LIST(projectId as string));
mutate(DRAFT_CYCLES_LIST(projectId.toString()));
}
mutate(CYCLE_DETAILS(projectId as string));
mutate(CYCLES_LIST(projectId.toString()));
if (
getDateRangeStatus(data?.start_date, data?.end_date) !=
getDateRangeStatus(res.start_date, res.end_date)
) {
switch (getDateRangeStatus(res.start_date, res.end_date)) {
case "completed":
mutate(CYCLE_COMPLETE_LIST(projectId as string));
mutate(COMPLETED_CYCLES_LIST(projectId.toString()));
break;
case "current":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
mutate(CURRENT_CYCLE_LIST(projectId.toString()));
break;
case "upcoming":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
mutate(UPCOMING_CYCLES_LIST(projectId.toString()));
break;
default:
mutate(CYCLE_DRAFT_LIST(projectId as string));
mutate(DRAFT_CYCLES_LIST(projectId.toString()));
}
}

View File

@ -4,6 +4,7 @@ import { useRouter } from "next/router";
import useSWR from "swr";
import useUserAuth from "hooks/use-user-auth";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// icons
@ -14,7 +15,7 @@ import cycleServices from "services/cycles.service";
// components
import { CreateUpdateCycleModal } from "components/cycles";
// fetch-keys
import { CYCLE_LIST } from "constants/fetch-keys";
import { CYCLES_LIST } from "constants/fetch-keys";
export type IssueCycleSelectProps = {
projectId: string;
@ -35,10 +36,12 @@ export const CycleSelect: React.FC<IssueCycleSelectProps> = ({
const router = useRouter();
const { workspaceSlug } = router.query;
const { user } = useUserAuth();
const { data: cycles } = useSWR(
workspaceSlug && projectId ? CYCLE_LIST(projectId) : null,
workspaceSlug && projectId ? CYCLES_LIST(projectId) : null,
workspaceSlug && projectId
? () => cycleServices.getCycles(workspaceSlug as string, projectId)
? () => cycleServices.getCyclesWithParams(workspaceSlug as string, projectId as string, "all")
: null
);
@ -54,7 +57,11 @@ export const CycleSelect: React.FC<IssueCycleSelectProps> = ({
return (
<>
<CreateUpdateCycleModal isOpen={isCycleModalActive} handleClose={closeCycleModal} />
<CreateUpdateCycleModal
isOpen={isCycleModalActive}
handleClose={closeCycleModal}
user={user}
/>
<Listbox as="div" className="relative" value={value} onChange={onChange} multiple={multiple}>
{({ open }) => (
<>

View File

@ -1,7 +1,6 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import Image from "next/image";
import useSWR, { mutate } from "swr";
@ -39,7 +38,7 @@ import {
renderShortDate,
} from "helpers/date-time.helper";
// types
import { ICycle, IIssue } from "types";
import { ICurrentUserResponse, ICycle, IIssue } from "types";
// fetch-keys
import { CYCLE_DETAILS, CYCLE_ISSUES } from "constants/fetch-keys";
@ -48,6 +47,7 @@ type Props = {
isOpen: boolean;
cycleStatus: string;
isCompleted: boolean;
user: ICurrentUserResponse | undefined;
};
export const CycleDetailsSidebar: React.FC<Props> = ({
@ -55,6 +55,7 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
isOpen,
cycleStatus,
isCompleted,
user,
}) => {
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
@ -94,7 +95,7 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
);
cyclesService
.patchCycle(workspaceSlug as string, projectId as string, cycleId as string, data)
.patchCycle(workspaceSlug as string, projectId as string, cycleId as string, data, user)
.then(() => mutate(CYCLE_DETAILS(cycleId as string)))
.catch((e) => console.log(e));
};
@ -294,7 +295,12 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
return (
<>
<DeleteCycleModal isOpen={cycleDeleteModal} setIsOpen={setCycleDeleteModal} data={cycle} />
<DeleteCycleModal
isOpen={cycleDeleteModal}
setIsOpen={setCycleDeleteModal}
data={cycle}
user={user}
/>
<div
className={`fixed top-[66px] ${
isOpen ? "right-0" : "-right-[24rem]"
@ -447,7 +453,7 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
<div className="flex items-center gap-2.5">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<Image
<img
src={cycle.owned_by.avatar}
height={12}
width={12}

View File

@ -1,23 +1,19 @@
import React from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import cyclesService from "services/cycles.service";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
// components
import { SingleProgressStats } from "components/core";
// ui
import { CustomMenu, LinearProgressIndicator, Tooltip } from "components/ui";
import { Disclosure, Transition } from "@headlessui/react";
import { AssigneesList, Avatar } from "components/ui/avatar";
import { SingleProgressStats } from "components/core";
import { AssigneesList } from "components/ui/avatar";
// icons
import { CalendarDaysIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid";
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
import {
TargetIcon,
ContrastIcon,
@ -41,24 +37,14 @@ import {
} from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import {
CompletedCyclesResponse,
CurrentAndUpcomingCyclesResponse,
DraftCyclesResponse,
ICycle,
} from "types";
// fetch-keys
import {
CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DETAILS,
CYCLE_DRAFT_LIST,
} from "constants/fetch-keys";
import { ICycle } from "types";
type TSingleStatProps = {
cycle: ICycle;
handleEditCycle: () => void;
handleDeleteCycle: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
isCompleted?: boolean;
};
@ -94,6 +80,8 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
cycle,
handleEditCycle,
handleDeleteCycle,
handleAddToFavorites,
handleRemoveFromFavorites,
isCompleted = false,
}) => {
const router = useRouter();
@ -105,142 +93,6 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_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 handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
@ -393,7 +245,7 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
<div className="w-16">Creator:</div>
<div className="flex items-center gap-2.5 text-brand-secondary">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<Image
<img
src={cycle.owned_by.avatar}
height={16}
width={16}

View File

@ -4,17 +4,12 @@ import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import cyclesService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { CustomMenu, LinearProgressIndicator, Tooltip } from "components/ui";
// icons
import { CalendarDaysIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid";
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
import {
TargetIcon,
ContrastIcon,
@ -32,25 +27,14 @@ import {
} from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import {
CompletedCyclesResponse,
CurrentAndUpcomingCyclesResponse,
DraftCyclesResponse,
ICycle,
} from "types";
// fetch-keys
import {
CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DETAILS,
CYCLE_DRAFT_LIST,
} from "constants/fetch-keys";
import { type } from "os";
import { ICycle } from "types";
type TSingleStatProps = {
cycle: ICycle;
handleEditCycle: () => void;
handleDeleteCycle: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
isCompleted?: boolean;
};
@ -128,6 +112,8 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
cycle,
handleEditCycle,
handleDeleteCycle,
handleAddToFavorites,
handleRemoveFromFavorites,
isCompleted = false,
}) => {
const router = useRouter();
@ -139,142 +125,6 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_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 handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
@ -302,7 +152,7 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
return (
<div>
<div className="flex flex-col border-b border-brand-base text-xs hover:bg-brand-surface-2">
<div className="flex flex-col text-xs hover:bg-brand-surface-2">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full">
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
@ -394,7 +244,7 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
<div className="flex items-center gap-2.5 text-brand-secondary">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<Image
<img
src={cycle.owned_by.avatar}
height={16}
width={16}

View File

@ -10,16 +10,16 @@ import { Dialog, Transition } from "@headlessui/react";
import cyclesService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view";
//icons
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { ContrastIcon, CyclesIcon, ExclamationIcon, TransferIcon } from "components/icons";
import { ContrastIcon, ExclamationIcon, TransferIcon } from "components/icons";
// fetch-key
import { CYCLE_INCOMPLETE_LIST, CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
import { CYCLE_ISSUES_WITH_PARAMS, INCOMPLETE_CYCLES_LIST } from "constants/fetch-keys";
// types
import { ICycle } from "types";
//helper
import { getDateRangeStatus } from "helpers/date-time.helper";
import useIssuesView from "hooks/use-issues-view";
type Props = {
isOpen: boolean;
@ -57,9 +57,14 @@ export const TransferIssuesModal: React.FC<Props> = ({ isOpen, handleClose }) =>
};
const { data: incompleteCycles } = useSWR(
workspaceSlug && projectId ? CYCLE_INCOMPLETE_LIST(projectId as string) : null,
workspaceSlug && projectId ? INCOMPLETE_CYCLES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => cyclesService.getIncompleteCycles(workspaceSlug as string, projectId as string)
? () =>
cyclesService.getCyclesWithParams(
workspaceSlug as string,
projectId as string,
"incomplete"
)
: null
);

View File

@ -17,7 +17,7 @@ import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
// helpers
import { checkDuplicates } from "helpers/array.helper";
// types
import { IEstimate, IEstimateFormData } from "types";
import { ICurrentUserResponse, IEstimate, IEstimateFormData } from "types";
// fetch-keys
import { ESTIMATES_LIST, ESTIMATE_DETAILS } from "constants/fetch-keys";
@ -25,6 +25,7 @@ type Props = {
isOpen: boolean;
handleClose: () => void;
data?: IEstimate;
user: ICurrentUserResponse | undefined;
};
type FormValues = {
@ -49,7 +50,7 @@ const defaultValues: Partial<FormValues> = {
value6: "",
};
export const CreateUpdateEstimateModal: React.FC<Props> = ({ handleClose, data, isOpen }) => {
export const CreateUpdateEstimateModal: React.FC<Props> = ({ handleClose, data, isOpen, user }) => {
const {
register,
formState: { isSubmitting },
@ -73,7 +74,7 @@ export const CreateUpdateEstimateModal: React.FC<Props> = ({ handleClose, data,
if (!workspaceSlug || !projectId) return;
await estimatesService
.createEstimate(workspaceSlug as string, projectId as string, payload)
.createEstimate(workspaceSlug as string, projectId as string, payload, user)
.then(() => {
mutate(ESTIMATES_LIST(projectId as string));
onClose();
@ -118,7 +119,13 @@ export const CreateUpdateEstimateModal: React.FC<Props> = ({ handleClose, data,
);
await estimatesService
.patchEstimate(workspaceSlug as string, projectId as string, data?.id as string, payload)
.patchEstimate(
workspaceSlug as string,
projectId as string,
data?.id as string,
payload,
user
)
.then(() => {
mutate(ESTIMATES_LIST(projectId.toString()));
mutate(ESTIMATE_DETAILS(data.id));

View File

@ -16,15 +16,17 @@ import { PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types
import { IEstimate } from "types";
import { ICurrentUserResponse, IEstimate } from "types";
type Props = {
user: ICurrentUserResponse | undefined;
estimate: IEstimate;
editEstimate: (estimate: IEstimate) => void;
handleEstimateDelete: (estimateId: string) => void;
};
export const SingleEstimate: React.FC<Props> = ({
user,
estimate,
editEstimate,
handleEstimateDelete,
@ -52,7 +54,7 @@ export const SingleEstimate: React.FC<Props> = ({
}, false);
await projectService
.updateProject(workspaceSlug as string, projectId as string, payload)
.updateProject(workspaceSlug as string, projectId as string, payload, user)
.catch(() => {
setToastAlert({
type: "error",

View File

@ -49,7 +49,10 @@ export const GanttChartBlocks: FC<{
width: `${block?.position?.width}px`,
}}
>
{blockRender({ ...block?.data })}
{blockRender({
...block?.data,
infoToggle: block?.infoToggle ? true : false,
})}
</div>
<div className="flex-shrink-0 relative w-0 h-0 flex items-center invisible group-hover:visible whitespace-nowrap">

View File

@ -15,7 +15,7 @@ import { DangerButton, Input, SecondaryButton } from "components/ui";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// types
import { IImporterService } from "types";
import { ICurrentUserResponse, IImporterService } from "types";
// fetch-keys
import { IMPORTER_SERVICES_LIST } from "constants/fetch-keys";
@ -23,9 +23,10 @@ type Props = {
isOpen: boolean;
handleClose: () => void;
data: IImporterService | null;
user: ICurrentUserResponse | undefined;
};
export const DeleteImportModal: React.FC<Props> = ({ isOpen, handleClose, data }) => {
export const DeleteImportModal: React.FC<Props> = ({ isOpen, handleClose, data, user }) => {
const [deleteLoading, setDeleteLoading] = useState(false);
const [confirmDeleteImport, setConfirmDeleteImport] = useState(false);
@ -45,7 +46,7 @@ export const DeleteImportModal: React.FC<Props> = ({ isOpen, handleClose, data }
false
);
IntegrationService.deleteImporterService(workspaceSlug as string, data.service, data.id)
IntegrationService.deleteImporterService(workspaceSlug as string, data.service, data.id, user)
.catch(() =>
setToastAlert({
type: "error",

View File

@ -27,7 +27,7 @@ import { ArrowLeftIcon, ListBulletIcon } from "@heroicons/react/24/outline";
// images
import GithubLogo from "public/services/github.png";
// types
import { IGithubRepoCollaborator, IGithubServiceImportFormData } from "types";
import { ICurrentUserResponse, IGithubRepoCollaborator, IGithubServiceImportFormData } from "types";
// fetch-keys
import {
APP_INTEGRATIONS,
@ -89,7 +89,11 @@ const integrationWorkflowData = [
},
];
export const GithubImporterRoot = () => {
type Props = {
user: ICurrentUserResponse | undefined;
};
export const GithubImporterRoot: React.FC<Props> = ({ user }) => {
const [currentStep, setCurrentStep] = useState<IIntegrationData>({
state: "import-configure",
});
@ -157,7 +161,7 @@ export const GithubImporterRoot = () => {
project_id: formData.project,
};
await GithubIntegrationService.createGithubServiceImport(workspaceSlug as string, payload)
await GithubIntegrationService.createGithubServiceImport(workspaceSlug as string, payload, user)
.then(() => {
router.push(`/${workspaceSlug}/settings/import-export`);
mutate(IMPORTER_SERVICES_LIST(workspaceSlug as string));

View File

@ -1,4 +1,3 @@
import Image from "next/image";
import { useRouter } from "next/router";
import useSWR from "swr";
@ -66,11 +65,9 @@ export const SingleUserSelect: React.FC<Props> = ({ collaborator, index, users,
<div className="grid grid-cols-3 items-center gap-2 rounded-md bg-brand-surface-2 px-2 py-3">
<div className="flex items-center gap-2">
<div className="relative h-8 w-8 flex-shrink-0 rounded">
<Image
<img
src={collaborator.avatar_url}
layout="fill"
objectFit="cover"
className="rounded"
className="absolute top-0 left-0 h-full w-full object-cover rounded"
alt={`${collaborator.login} GitHub user`}
/>
</div>

View File

@ -6,6 +6,8 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// hooks
import useUserAuth from "hooks/use-user-auth";
// services
import IntegrationService from "services/integration";
// components
@ -35,6 +37,8 @@ const IntegrationGuide = () => {
const router = useRouter();
const { workspaceSlug, provider } = router.query;
const { user } = useUserAuth();
const { data: importerServices } = useSWR(
workspaceSlug ? IMPORTER_SERVICES_LIST(workspaceSlug as string) : null,
workspaceSlug ? () => IntegrationService.getImporterServicesList(workspaceSlug as string) : null
@ -51,6 +55,7 @@ const IntegrationGuide = () => {
isOpen={deleteImportModal}
handleClose={() => setDeleteImportModal(false)}
data={importToDelete}
user={user}
/>
<div className="h-full space-y-2">
{!provider && (
@ -156,8 +161,8 @@ const IntegrationGuide = () => {
</>
)}
{provider && provider === "github" && <GithubImporterRoot />}
{provider && provider === "jira" && <JiraImporterRoot />}
{provider && provider === "github" && <GithubImporterRoot user={user} />}
{provider && provider === "jira" && <JiraImporterRoot user={user} />}
</div>
</>
);

View File

@ -35,7 +35,7 @@ import {
import JiraLogo from "public/services/jira.png";
import { IJiraImporterForm } from "types";
import { ICurrentUserResponse, IJiraImporterForm } from "types";
const integrationWorkflowData: Array<{
title: string;
@ -64,7 +64,11 @@ const integrationWorkflowData: Array<{
},
];
export const JiraImporterRoot = () => {
type Props = {
user: ICurrentUserResponse | undefined;
};
export const JiraImporterRoot: React.FC<Props> = ({ user }) => {
const [currentStep, setCurrentStep] = useState<IJiraIntegrationData>({
state: "import-configure",
});
@ -85,7 +89,7 @@ export const JiraImporterRoot = () => {
if (!workspaceSlug) return;
await jiraImporterService
.createJiraImporter(workspaceSlug.toString(), data)
.createJiraImporter(workspaceSlug.toString(), data, user)
.then(() => {
mutate(IMPORTER_SERVICES_LIST(workspaceSlug.toString()));
router.push(`/${workspaceSlug}/settings/import-export`);

View File

@ -1,6 +1,7 @@
import React from "react";
import { useRouter } from "next/router";
import Image from "next/image";
import useSWR from "swr";
// icons
@ -27,7 +28,7 @@ import { Loader } from "components/ui";
import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssueComment, IIssueLabels } from "types";
import { ICurrentUserResponse, IIssueComment, IIssueLabels } from "types";
import { PROJECT_ISSUES_ACTIVITY, PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
import useEstimateOption from "hooks/use-estimate-option";
@ -110,7 +111,11 @@ const activityDetails: {
},
};
export const IssueActivitySection: React.FC = () => {
type Props = {
user: ICurrentUserResponse | undefined;
};
export const IssueActivitySection: React.FC<Props> = ({ user }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
@ -143,7 +148,8 @@ export const IssueActivitySection: React.FC = () => {
projectId as string,
issueId as string,
comment.id,
comment
comment,
user
)
.then((res) => {
mutateIssueActivities();
@ -160,7 +166,8 @@ export const IssueActivitySection: React.FC = () => {
workspaceSlug as string,
projectId as string,
issueId as string,
commentId
commentId,
user
)
.then(() => mutateIssueActivities());
};
@ -340,7 +347,7 @@ export const IssueActivitySection: React.FC = () => {
?.icon
) : activityItem.actor_detail.avatar &&
activityItem.actor_detail.avatar !== "" ? (
<Image
<img
src={activityItem.actor_detail.avatar}
alt={activityItem.actor_detail.first_name}
height={24}

View File

@ -14,7 +14,7 @@ import useToast from "hooks/use-toast";
// ui
import { Loader, SecondaryButton } from "components/ui";
// types
import type { IIssueComment } from "types";
import type { ICurrentUserResponse, IIssueComment } from "types";
// fetch-keys
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
@ -40,7 +40,11 @@ const defaultValues: Partial<IIssueComment> = {
comment_html: "",
};
export const AddComment: React.FC = () => {
type Props = {
user: ICurrentUserResponse | undefined;
};
export const AddComment: React.FC<Props> = ({ user }) => {
const {
handleSubmit,
control,
@ -67,7 +71,13 @@ export const AddComment: React.FC = () => {
)
return;
await issuesServices
.createIssueComment(workspaceSlug as string, projectId as string, issueId as string, formData)
.createIssueComment(
workspaceSlug as string,
projectId as string,
issueId as string,
formData,
user
)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
reset(defaultValues);

View File

@ -1,6 +1,5 @@
import React, { useEffect, useState } from "react";
import Image from "next/image";
import dynamic from "next/dynamic";
// react-hook-form
@ -68,12 +67,12 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
<Image
<img
src={comment.actor_detail.avatar}
alt={comment.actor_detail.first_name}
height={30}
width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
className="grid h-7 w-7 place-items-center rounded-full border-2 border-brand-base"
/>
) : (
<div

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