Merge pull request #1257 from makeplane/stage-release

promote: stage release to production
This commit is contained in:
Vamsi Kurama 2023-06-09 16:27:25 +05:30 committed by GitHub
commit 49f19c2c44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
251 changed files with 5026 additions and 4540 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= NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID="" NEXT_PUBLIC_GOOGLE_CLIENTID=""
NEXT_PUBLIC_GITHUB_APP_NAME="" # Github ID for Github OAuth
NEXT_PUBLIC_GITHUB_ID="" NEXT_PUBLIC_GITHUB_ID=""
# Github App Name for GitHub Integration
NEXT_PUBLIC_GITHUB_APP_NAME=""
# Sentry DSN for error monitoring
NEXT_PUBLIC_SENTRY_DSN="" NEXT_PUBLIC_SENTRY_DSN=""
# Enable/Disable OAUTH - default 0 for selfhosted instance
NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_ENABLE_OAUTH=0
# Enable/Disable sentry
NEXT_PUBLIC_ENABLE_SENTRY=0 NEXT_PUBLIC_ENABLE_SENTRY=0
# Enable/Disable session recording
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0 NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
# Enable/Disable event tracking
NEXT_PUBLIC_TRACK_EVENTS=0 NEXT_PUBLIC_TRACK_EVENTS=0
# Slack for Slack Integration
NEXT_PUBLIC_SLACK_CLIENT_ID="" NEXT_PUBLIC_SLACK_CLIENT_ID=""
# Backend
# Database Settings
PGUSER="plane"
PGPASSWORD="plane"
PGHOST="plane-db"
PGDATABASE="plane"
# Email Settings
EMAIL_HOST="" EMAIL_HOST=""
EMAIL_HOST_USER="" EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD="" EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS="1"
# AWS Settings
AWS_REGION="" AWS_REGION=""
AWS_ACCESS_KEY_ID="" AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="" AWS_SECRET_ACCESS_KEY="secret-key"
AWS_S3_BUCKET_NAME="" 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="" 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 FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER 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 # Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
# First install the dependencies (as they change less often) # First install the dependencies (as they change less often)
@ -44,10 +41,12 @@ FROM python:3.11.1-alpine3.17 AS backend
ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV DJANGO_SETTINGS_MODULE plane.settings.production
ENV DOCKERIZED 1
WORKDIR /code WORKDIR /code
RUN apk --update --no-cache add \ RUN apk --no-cache add \
"libpq~=15" \ "libpq~=15" \
"libxslt~=1.1" \ "libxslt~=1.1" \
"nodejs-current~=19" \ "nodejs-current~=19" \
@ -59,8 +58,8 @@ RUN apk --update --no-cache add \
COPY apiserver/requirements.txt ./ COPY apiserver/requirements.txt ./
COPY apiserver/requirements ./requirements COPY apiserver/requirements ./requirements
RUN apk add libffi-dev RUN apk add --no-cache libffi-dev
RUN apk --update --no-cache --virtual .build-deps add \ RUN apk add --no-cache --virtual .build-deps \
"bash~=5.2" \ "bash~=5.2" \
"g++~=12.2" \ "g++~=12.2" \
"gcc~=12.2" \ "gcc~=12.2" \
@ -81,18 +80,13 @@ COPY apiserver/plane plane/
COPY apiserver/templates templates/ COPY apiserver/templates templates/
COPY apiserver/gunicorn.config.py ./ 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/ COPY apiserver/bin ./bin/
RUN chmod +x ./bin/takeoff ./bin/worker RUN chmod +x ./bin/takeoff ./bin/worker
RUN chmod -R 777 /code RUN chmod -R 777 /code
# Expose container port and run entry point script # Expose container port and run entry point script
EXPOSE 8000
EXPOSE 3000
EXPOSE 80
WORKDIR /app 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/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh RUN chmod +x /usr/local/bin/start.sh
EXPOSE 80
CMD ["supervisord","-c","/code/supervisor.conf"] CMD ["supervisord","-c","/code/supervisor.conf"]

View File

@ -15,11 +15,18 @@
</a> </a>
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" /> <img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
</p> </p>
<br />
<p> <p>
<a href="https://app.plane.so/" target="_blank"> <a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
<img <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" alt="Plane Screens"
width="100%" width="100%"
/> />
@ -38,22 +45,18 @@ The easiest way to get started with Plane is by creating a [Plane Cloud](https:/
### Docker Compose Setup ### Docker Compose Setup
- Clone the Repository - Clone the repository
```bash ```bash
git clone https://github.com/makeplane/plane git clone https://github.com/makeplane/plane
```
- Change Directory
```bash
cd plane cd plane
chmod +x setup.sh
``` ```
- Run setup.sh - Run setup.sh
```bash ```bash
./setup.sh localhost ./setup.sh http://localhost
``` ```
> If running in a cloud env replace localhost with public facing IP address of the VM > If running in a cloud env replace localhost with public facing IP address of the VM
@ -69,7 +72,7 @@ set +a
- Run Docker compose up - Run Docker compose up
```bash ```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> <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 ## 📸 Screenshots
<p> <p>
<a href="https://app.plane.so/" target="_blank"> <a href="https://plane.so" target="_blank">
<img <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" alt="Plane Issue Details"
width="100%" width="100%"
/> />
</a> </a>
</p> </p>
<p> <p>
<a href="https://app.plane.so/" target="_blank"> <a href="https://plane.so" target="_blank">
<img <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" alt="Plane Cycles and Modules"
width="100%" width="100%"
/> />
</a> </a>
</p> </p>
<p> <p>
<a href="https://app.plane.so/" target="_blank"> <a href="https://plane.so" target="_blank">
<img <img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601713/Plane/plane_4_cqm0g8.png" src="https://ik.imagekit.io/killbluedog/Plane_Analytics_Dark_Mode.png?updatedAt=1684944596824"
alt="Plane Quick Lists" alt="Plane Analytics"
width="100%" width="100%"
/> />
</a> </a>
</p> </p>
<p> <p>
<a href="https://app.plane.so/" target="_blank"> <a href="https://plane.so" target="_blank">
<img <img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601712/Plane/plane_3_1_cu4fsc.png" src="https://ik.imagekit.io/killbluedog/Plane_Pages_Dark_Mode.png?updatedAt=1684943050202"
alt="Plane Command K" alt="Plane Pages"
width="100%" width="100%"
/> />
</a> </a>
</p>
</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 ## 📚Documentation

View File

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

View File

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

View File

@ -25,6 +25,10 @@ class UserSerializer(BaseSerializer):
] ]
extra_kwargs = {"password": {"write_only": True}} 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 UserLiteSerializer(BaseSerializer):
class Meta: class Meta:

View File

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

View File

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

View File

@ -49,12 +49,8 @@ from .cycle import (
CycleViewSet, CycleViewSet,
CycleIssueViewSet, CycleIssueViewSet,
CycleDateCheckEndpoint, CycleDateCheckEndpoint,
CurrentUpcomingCyclesEndpoint,
CompletedCyclesEndpoint,
CycleFavoriteViewSet, CycleFavoriteViewSet,
DraftCyclesEndpoint,
TransferCycleIssueEndpoint, TransferCycleIssueEndpoint,
InCompleteCyclesEndpoint,
) )
from .asset import FileAssetEndpoint, UserAssetsEndpoint from .asset import FileAssetEndpoint, UserAssetsEndpoint
from .issue import ( from .issue import (
@ -122,10 +118,6 @@ from .page import (
PageBlockViewSet, PageBlockViewSet,
PageFavoriteViewSet, PageFavoriteViewSet,
CreateIssueFromPageBlockEndpoint, CreateIssueFromPageBlockEndpoint,
RecentPagesEndpoint,
FavoritePagesEndpoint,
MyPagesEndpoint,
CreatedbyOtherPagesEndpoint,
) )
from .search import GlobalSearchEndpoint, IssueSearchEndpoint 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.response import Response
from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.parsers import MultiPartParser, FormParser
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
from django.conf import settings
# Module imports # Module imports
from .base import BaseAPIView from .base import BaseAPIView
from plane.db.models import FileAsset from plane.db.models import FileAsset, Workspace
from plane.api.serializers import FileAssetSerializer from plane.api.serializers import FileAssetSerializer
@ -27,15 +27,13 @@ class FileAssetEndpoint(BaseAPIView):
try: try:
serializer = FileAssetSerializer(data=request.data) serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
if request.user.last_workspace_id is None: # Get the workspace
return Response( workspace = Workspace.objects.get(slug=slug)
{"error": "Workspace id is required"}, serializer.save(workspace_id=workspace.id)
status=status.HTTP_400_BAD_REQUEST,
)
serializer.save(workspace_id=request.user.last_workspace_id)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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: except Exception as e:
capture_exception(e) capture_exception(e)
return Response( return Response(

View File

@ -152,6 +152,75 @@ class CycleViewSet(BaseViewSet):
.distinct() .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): def create(self, request, slug, project_id):
try: try:
if ( 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): class CycleFavoriteViewSet(BaseViewSet):
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
@ -948,22 +671,3 @@ class TransferCycleIssueEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
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 from itertools import chain
# Django imports # 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.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.conf import settings
# Third Party imports # Third Party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -144,9 +156,13 @@ class IssueViewSet(BaseViewSet):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true") 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 = ( issue_queryset = (
self.get_queryset() self.get_queryset()
.order_by(request.GET.get("order_by", "created_at"))
.filter(**filters) .filter(**filters)
.annotate(cycle_id=F("issue_cycle__id")) .annotate(cycle_id=F("issue_cycle__id"))
.annotate(module_id=F("issue_module__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 = (
issue_queryset issue_queryset
if show_sub_issues == "true" if show_sub_issues == "true"

View File

@ -126,6 +126,56 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST, 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): class PageBlockViewSet(BaseViewSet):
serializer_class = PageBlockSerializer serializer_class = PageBlockSerializer
@ -269,249 +319,3 @@ class CreateIssueFromPageBlockEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
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): def retrieve(self, request):
try: 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( workspace_invites = WorkspaceMemberInvite.objects.filter(
email=request.user.email email=request.user.email
).count() ).count()
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).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( return Response(
{ serialized_data,
"user": UserSerializer(request.user).data,
"slug": workspace.slug,
"workspace_invites": workspace_invites,
"assigned_issues": assigned_issues,
},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
except Workspace.DoesNotExist: except Workspace.DoesNotExist:
# This exception will be hit even when the `last_workspace_id` is None
workspace_invites = WorkspaceMemberInvite.objects.filter( workspace_invites = WorkspaceMemberInvite.objects.filter(
email=request.user.email email=request.user.email
).count() ).count()
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).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( return Response(
{ serialized_data,
"user": UserSerializer(request.user).data,
"slug": None,
"workspace_invites": workspace_invites,
"assigned_issues": assigned_issues,
},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
except Exception as e: except Exception as e:
capture_exception(e)
return Response( return Response(
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,

View File

@ -37,18 +37,19 @@ from plane.db.models import (
State, State,
TeamMember, TeamMember,
ProjectFavorite, 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 from plane.bgtasks.project_invitation_task import project_invitation
@ -133,12 +134,12 @@ class ProjectViewSet(BaseViewSet):
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
## Add the user as Administrator to the project # Add the user as Administrator to the project
ProjectMember.objects.create( ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20 project_id=serializer.data["id"], member=request.user, role=20
) )
## Default states # Default states
states = [ states = [
{ {
"name": "Backlog", "name": "Backlog",
@ -373,7 +374,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
] ]
) )
## Delete joined project invites # Delete joined project invites
project_invitations.delete() project_invitations.delete()
return Response(status=status.HTTP_200_OK) return Response(status=status.HTTP_200_OK)
@ -411,14 +412,23 @@ class ProjectMemberViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
try: 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: if request.user.id == project_member.member_id:
return Response( return Response(
{"error": "You cannot update your own role"}, {"error": "You cannot update your own role"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Check while updating user roles
if request.data.get("role", 10) > project_member.role: 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( return Response(
{ {
"error": "You cannot update a role that is higher than your own role" "error": "You cannot update a role that is higher than your own role"
@ -441,8 +451,70 @@ class ProjectMemberViewSet(BaseViewSet):
) )
except Exception as e: except Exception as e:
capture_exception(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): class AddMemberToProjectEndpoint(BaseAPIView):

View File

@ -210,13 +210,15 @@ class IssueSearchEndpoint(BaseAPIView):
blocker_blocked_by = request.query_params.get("blocker_blocked_by", False) blocker_blocked_by = request.query_params.get("blocker_blocked_by", False)
issue_id = request.query_params.get("issue_id", False) issue_id = request.query_params.get("issue_id", False)
issues = search_issues(query) issues = Issue.objects.filter(
issues = issues.filter(
workspace__slug=slug, workspace__slug=slug,
project_id=project_id, project_id=project_id,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
) )
if query:
issues = search_issues(query, issues)
if parent == "true" and issue_id: if parent == "true" and issue_id:
issue = Issue.objects.get(pk=issue_id) issue = Issue.objects.get(pk=issue_id)
issues = issues.filter( issues = issues.filter(
@ -227,7 +229,12 @@ class IssueSearchEndpoint(BaseAPIView):
) )
) )
if blocker_blocked_by == "true" and issue_id: 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( return Response(
issues.values( issues.values(

View File

@ -50,6 +50,14 @@ from plane.db.models import (
IssueActivity, IssueActivity,
Issue, Issue,
WorkspaceTheme, WorkspaceTheme,
IssueAssignee,
ProjectFavorite,
CycleFavorite,
ModuleMember,
ModuleFavorite,
PageFavorite,
Page,
IssueViewFavorite,
) )
from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
from plane.bgtasks.workspace_invitation_task import workspace_invitation from plane.bgtasks.workspace_invitation_task import workspace_invitation
@ -353,7 +361,7 @@ class WorkspaceInvitationsViewset(BaseViewSet):
super() super()
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .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() super()
.get_queryset() .get_queryset()
.filter(email=self.request.user.email) .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): def create(self, request):
@ -432,7 +441,17 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST, 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( return Response(
{ {
"error": "You cannot update a role that is higher than your own role" "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, 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): class TeamMemberViewSet(BaseViewSet):
serializer_class = TeamSerializer serializer_class = TeamSerializer

View File

@ -19,7 +19,7 @@ def email_verification(first_name, email, token, current_site):
try: try:
realtivelink = "/request-email-verification/" + "?token=" + str(token) realtivelink = "/request-email-verification/" + "?token=" + str(token)
abs_url = "http://" + current_site + realtivelink abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM 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): def forgot_password(first_name, email, uidb64, token, current_site):
try: try:
realtivelink = f"/email-verify/?uidb64={uidb64}&token={token}/" realtivelink = f"/reset-password/?uidb64={uidb64}&token={token}"
abs_url = "http://" + current_site + realtivelink abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM from_email_string = settings.EMAIL_FROM
subject = f"Verify your Email!" subject = f"Reset Your Password - Plane"
context = { context = {
"first_name": first_name, "first_name": first_name,

View File

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

View File

@ -23,9 +23,9 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
) )
realtivelink = ( 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 from_email_string = settings.EMAIL_FROM

View File

@ -4,6 +4,7 @@ from uuid import uuid4
# Django import # Django import
from django.db import models from django.db import models
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.conf import settings
# Module import # Module import
from . import BaseModel from . import BaseModel
@ -16,8 +17,7 @@ def get_upload_path(instance, filename):
def file_size(value): def file_size(value):
limit = 5 * 1024 * 1024 if value.size > settings.FILE_SIZE_LIMIT:
if value.size > limit:
raise ValidationError("File too large. Size should not exceed 5 MB.") 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): def file_size(value):
limit = 5 * 1024 * 1024 # File limit check is only for cloud hosted
if value.size > limit: if value.size > settings.FILE_SIZE_LIMIT:
raise ValidationError("File too large. Size should not exceed 5 MB.") 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: if DOCKERIZED:
DATABASES["default"] = dj_database_url.config() DATABASES["default"] = dj_database_url.config()
@ -68,7 +74,7 @@ MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
if DOCKERIZED: if DOCKERIZED:
REDIS_URL = os.environ.get("REDIS_URL") 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) PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", 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_RESULT_BACKEND = os.environ.get("REDIS_URL")
CELERY_BROKER_URL = 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() DATABASES["default"] = dj_database_url.config()
SITE_ID = 1 SITE_ID = 1
DOCKERIZED = os.environ.get( # Set the variable true if running in docker environment
"DOCKERIZED", False DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1
) # Set the variable true if running in docker-compose environment
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) # Enable Connection Pooling (if desired)
# DATABASES['default']['ENGINE'] = 'django_postgrespool' # DATABASES['default']['ENGINE'] = 'django_postgrespool'
@ -69,7 +72,7 @@ CORS_ALLOW_CREDENTIALS = True
# Simplified static file serving. # Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
if os.environ.get("SENTRY_DSN", False): if bool(os.environ.get("SENTRY_DSN", False)):
sentry_sdk.init( sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN", ""), dsn=os.environ.get("SENTRY_DSN", ""),
integrations=[DjangoIntegration(), RedisIntegration()], integrations=[DjangoIntegration(), RedisIntegration()],
@ -80,12 +83,27 @@ if os.environ.get("SENTRY_DSN", False):
environment="production", environment="production",
) )
if ( if DOCKERIZED and USE_MINIO:
os.environ.get("AWS_REGION", False) INSTALLED_APPS += ("storages",)
and os.environ.get("AWS_ACCESS_KEY_ID", False) DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
and os.environ.get("AWS_SECRET_ACCESS_KEY", False) # The AWS access key to use.
and os.environ.get("AWS_S3_BUCKET_NAME", False) 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. # The AWS region to connect to.
AWS_REGION = os.environ.get("AWS_REGION", "") AWS_REGION = os.environ.get("AWS_REGION", "")
@ -99,7 +117,7 @@ if (
# AWS_SESSION_TOKEN = "" # AWS_SESSION_TOKEN = ""
# The name of the bucket to store files in. # 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"). # How to construct S3 URLs ("auto", "path", "virtual").
AWS_S3_ADDRESSING_STYLE = "auto" AWS_S3_ADDRESSING_STYLE = "auto"
@ -166,14 +184,8 @@ if (
# extra characters appended. # extra characters appended.
AWS_S3_FILE_OVERWRITE = False AWS_S3_FILE_OVERWRITE = False
# AWS Settings End
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage" DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
# AWS Settings End
else:
MEDIA_URL = "/uploads/"
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
# Enable Connection Pooling (if desired) # Enable Connection Pooling (if desired)
# DATABASES['default']['ENGINE'] = 'django_postgrespool' # DATABASES['default']['ENGINE'] = 'django_postgrespool'
@ -218,14 +230,8 @@ else:
} }
} }
RQ_QUEUES = {
"default": {
"USE_REDIS_CACHE": "default",
}
}
WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so")
WEB_URL = os.environ.get("WEB_URL")
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) 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. # Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" 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( sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN"), dsn=os.environ.get("SENTRY_DSN"),
@ -165,7 +171,6 @@ CSRF_COOKIE_SECURE = True
REDIS_URL = os.environ.get("REDIS_URL") REDIS_URL = os.environ.get("REDIS_URL")
DOCKERIZED = os.environ.get("DOCKERIZED", False)
CACHES = { CACHES = {
"default": { "default": {

View File

@ -7,7 +7,7 @@ from django.urls import path
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.conf import settings 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 # from django.conf.urls.static import static
@ -17,9 +17,8 @@ urlpatterns = [
path("api/", include("plane.api.urls")), path("api/", include("plane.api.urls")),
path("", include("plane.web.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: if settings.DEBUG:
import debug_toolbar import debug_toolbar

View File

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

View File

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

View File

@ -1,11 +1,21 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<p>
<body>
<p>
Dear {{first_name}},<br /><br /> Dear {{first_name}},<br /><br />
Welcome! Your account has been created. We received a request to reset your password for your Plane account.
Verify your email by clicking on the link below <br /> <br /><br />
{{forgot_password_url}} To proceed with resetting your password, please click on the link below:
successfully.<br /><br /> <br />
</p> <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> </html>

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
FROM node:18-alpine AS builder FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER 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 FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 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 [codeResent, setCodeResent] = useState(false);
const [isCodeResending, setIsCodeResending] = useState(false); const [isCodeResending, setIsCodeResending] = useState(false);
const [errorResendingCode, setErrorResendingCode] = useState(false); const [errorResendingCode, setErrorResendingCode] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer(); const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer();
@ -64,12 +65,9 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
}; };
const handleSignin = async (formData: EmailCodeFormValues) => { const handleSignin = async (formData: EmailCodeFormValues) => {
await authenticationService setIsLoading(true);
.magicSignIn(formData) await authenticationService.magicSignIn(formData).catch((error) => {
.then((response) => { setIsLoading(false);
onSuccess(response);
})
.catch((error) => {
setToastAlert({ setToastAlert({
title: "Oops!", title: "Oops!",
type: "error", type: "error",
@ -88,6 +86,25 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
setErrorResendingCode(false); setErrorResendingCode(false);
}, [emailOld]); }, [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 ( return (
<> <>
<form className="space-y-5 py-5 px-5"> <form className="space-y-5 py-5 px-5">
@ -177,9 +194,9 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
size="md" size="md"
onClick={handleSubmit(handleSignin)} onClick={handleSubmit(handleSignin)}
disabled={!isValid && isDirty} disabled={!isValid && isDirty}
loading={isSubmitting} loading={isLoading}
> >
{isSubmitting ? "Signing in..." : "Sign in"} {isLoading ? "Signing in..." : "Sign in"}
</PrimaryButton> </PrimaryButton>
) : ( ) : (
<PrimaryButton <PrimaryButton

View File

@ -1,6 +1,4 @@
import React from "react"; import React, { useState } from "react";
import Link from "next/link";
// react hook form // react hook form
import { useForm } from "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"; import authenticationService from "services/authentication.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components
import { EmailResetPasswordForm } from "components/account";
// ui // ui
import { Input, SecondaryButton } from "components/ui"; import { Input, SecondaryButton } from "components/ui";
// types // types
@ -17,8 +17,11 @@ type EmailPasswordFormValues = {
medium?: string; medium?: string;
}; };
export const EmailPasswordForm = ({ onSuccess }: any) => { export const EmailPasswordForm = ({ handleSignIn }: any) => {
const [isResettingPassword, setIsResettingPassword] = useState(false);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { const {
register, register,
handleSubmit, handleSubmit,
@ -38,7 +41,7 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
authenticationService authenticationService
.emailLogin(formData) .emailLogin(formData)
.then((response) => { .then((response) => {
onSuccess(response); if (handleSignIn) handleSignIn(response);
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
@ -58,8 +61,12 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
}); });
}); });
}; };
return ( return (
<> <>
{isResettingPassword ? (
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} />
) : (
<form className="mt-5 py-5 px-5" onSubmit={handleSubmit(onSubmit)}> <form className="mt-5 py-5 px-5" onSubmit={handleSubmit(onSubmit)}>
<div> <div>
<Input <Input
@ -93,11 +100,13 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
</div> </div>
<div className="mt-2 flex items-center justify-between"> <div className="mt-2 flex items-center justify-between">
<div className="ml-auto text-sm"> <div className="ml-auto text-sm">
<Link href={"/forgot-password"}> <button
<a className="font-medium text-brand-accent hover:text-brand-accent"> type="button"
onClick={() => setIsResettingPassword(true)}
className="font-medium text-brand-accent hover:text-brand-accent"
>
Forgot your password? Forgot your password?
</a> </button>
</Link>
</div> </div>
</div> </div>
<div className="mt-5"> <div className="mt-5">
@ -111,6 +120,7 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
</SecondaryButton> </SecondaryButton>
</div> </div>
</form> </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(() => { useEffect(() => {
const origin = const origin =
typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
setLoginCallBackURL(`${origin}/signin` as any); setLoginCallBackURL(`${origin}/` as any);
}, []); }, []);
return ( return (

View File

@ -1,5 +1,5 @@
export * from "./google-login";
export * from "./email-code-form"; export * from "./email-code-form";
export * from "./email-password-form"; export * from "./email-password-form";
export * from "./email-reset-password-form";
export * from "./github-login-button"; 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 // helpers
import { convertResponseToBarGraphData } from "helpers/analytics.helper"; import { convertResponseToBarGraphData } from "helpers/analytics.helper";
// types // types
import { IAnalyticsParams, IAnalyticsResponse } from "types"; import { IAnalyticsParams, IAnalyticsResponse, ICurrentUserResponse } from "types";
// fetch-keys // fetch-keys
import { ANALYTICS } from "constants/fetch-keys"; import { ANALYTICS } from "constants/fetch-keys";
@ -29,6 +29,7 @@ type Props = {
control: Control<IAnalyticsParams, any>; control: Control<IAnalyticsParams, any>;
setValue: UseFormSetValue<IAnalyticsParams>; setValue: UseFormSetValue<IAnalyticsParams>;
fullScreen: boolean; fullScreen: boolean;
user: ICurrentUserResponse | undefined;
}; };
export const CustomAnalytics: React.FC<Props> = ({ export const CustomAnalytics: React.FC<Props> = ({
@ -38,6 +39,7 @@ export const CustomAnalytics: React.FC<Props> = ({
control, control,
setValue, setValue,
fullScreen, fullScreen,
user,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -124,6 +126,7 @@ export const CustomAnalytics: React.FC<Props> = ({
params={params} params={params}
fullScreen={fullScreen} fullScreen={fullScreen}
isProjectLevel={isProjectLevel} isProjectLevel={isProjectLevel}
user={user}
/> />
</div> </div>
); );

View File

@ -7,6 +7,7 @@ import analyticsService from "services/analytics.service";
import projectService from "services/project.service"; import projectService from "services/project.service";
import cyclesService from "services/cycles.service"; import cyclesService from "services/cycles.service";
import modulesService from "services/modules.service"; import modulesService from "services/modules.service";
import trackEventServices from "services/track-event.service";
// hooks // hooks
import useProjects from "hooks/use-projects"; import useProjects from "hooks/use-projects";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
@ -23,7 +24,14 @@ import { ContrastIcon, LayerDiagonalIcon } from "components/icons";
// helpers // helpers
import { renderShortDate } from "helpers/date-time.helper"; import { renderShortDate } from "helpers/date-time.helper";
// types // types
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IProject } from "types"; import {
IAnalyticsParams,
IAnalyticsResponse,
ICurrentUserResponse,
IExportAnalyticsFormData,
IProject,
IWorkspace,
} from "types";
// fetch-keys // fetch-keys
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys"; import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
// constants // constants
@ -34,6 +42,7 @@ type Props = {
params: IAnalyticsParams; params: IAnalyticsParams;
fullScreen: boolean; fullScreen: boolean;
isProjectLevel: boolean; isProjectLevel: boolean;
user: ICurrentUserResponse | undefined;
}; };
export const AnalyticsSidebar: React.FC<Props> = ({ export const AnalyticsSidebar: React.FC<Props> = ({
@ -41,6 +50,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
params, params,
fullScreen, fullScreen,
isProjectLevel = false, isProjectLevel = false,
user,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
@ -82,6 +92,60 @@ export const AnalyticsSidebar: React.FC<Props> = ({
: null : 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 = () => { const exportAnalytics = () => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
@ -95,13 +159,15 @@ export const AnalyticsSidebar: React.FC<Props> = ({
analyticsService analyticsService
.exportAnalytics(workspaceSlug.toString(), data) .exportAnalytics(workspaceSlug.toString(), data)
.then((res) => .then((res) => {
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
message: res.message, message: res.message,
});
trackExportAnalytics();
}) })
)
.catch(() => .catch(() =>
setToastAlert({ setToastAlert({
type: "error", type: "error",

View File

@ -13,6 +13,7 @@ import analyticsService from "services/analytics.service";
import projectService from "services/project.service"; import projectService from "services/project.service";
import cyclesService from "services/cycles.service"; import cyclesService from "services/cycles.service";
import modulesService from "services/modules.service"; import modulesService from "services/modules.service";
import trackEventServices from "services/track-event.service";
// components // components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics"; import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
// icons // icons
@ -22,9 +23,10 @@ import {
XMarkIcon, XMarkIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// types // types
import { IAnalyticsParams } from "types"; import { IAnalyticsParams, IWorkspace } from "types";
// fetch-keys // fetch-keys
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys"; import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
import useUserAuth from "hooks/use-user-auth";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -46,6 +48,8 @@ export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { user } = useUserAuth();
const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues }); const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues });
const params: IAnalyticsParams = { const params: IAnalyticsParams = {
@ -95,6 +99,51 @@ export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
: null : 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 = () => { const handleClose = () => {
onClose(); onClose();
}; };
@ -146,6 +195,7 @@ export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
selected ? "bg-brand-surface-2" : "" selected ? "bg-brand-surface-2" : ""
}` }`
} }
onClick={() => trackAnalyticsEvent(tab)}
> >
{tab} {tab}
</Tab> </Tab>
@ -164,6 +214,7 @@ export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
control={control} control={control}
setValue={setValue} setValue={setValue}
fullScreen={fullScreen} fullScreen={fullScreen}
user={user}
/> />
</Tab.Panel> </Tab.Panel>
</Tab.Panels> </Tab.Panels>

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
// icons // icons
import { ArrowLeftIcon } from "@heroicons/react/24/outline"; import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import { Icon } from "components/ui";
type BreadcrumbsProps = { type BreadcrumbsProps = {
children: any; children: any;
@ -16,10 +17,13 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
<div className="flex items-center"> <div className="flex items-center">
<button <button
type="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()} 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> </button>
{children} {children}
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,8 @@ import { useDropzone } from "react-dropzone";
import { Transition, Dialog } from "@headlessui/react"; import { Transition, Dialog } from "@headlessui/react";
// services // services
import fileServices from "services/file.service"; import fileServices from "services/file.service";
// hooks
import useWorkspaceDetails from "hooks/use-workspace-details";
// ui // ui
import { PrimaryButton, SecondaryButton } from "components/ui"; import { PrimaryButton, SecondaryButton } from "components/ui";
// icons // icons
@ -35,6 +37,8 @@ export const ImageUploadModal: React.FC<Props> = ({
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const { workspaceDetails } = useWorkspaceDetails();
const onDrop = useCallback((acceptedFiles: File[]) => { const onDrop = useCallback((acceptedFiles: File[]) => {
setImage(acceptedFiles[0]); setImage(acceptedFiles[0]);
}, []); }, []);
@ -62,12 +66,7 @@ export const ImageUploadModal: React.FC<Props> = ({
setIsImageUploading(false); setIsImageUploading(false);
setImage(null); setImage(null);
if (value) { if (value) fileServices.deleteUserFile(value);
const index = value.indexOf(".com");
const asset = value.substring(index + 5);
fileServices.deleteUserFile(asset);
}
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
@ -81,12 +80,7 @@ export const ImageUploadModal: React.FC<Props> = ({
setIsImageUploading(false); setIsImageUploading(false);
setImage(null); setImage(null);
if (value) { if (value && workspaceDetails) fileServices.deleteFile(workspaceDetails.id, value);
const index = value.indexOf(".com");
const asset = value.substring(index + 5);
fileServices.deleteFile(asset);
}
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
@ -16,8 +15,6 @@ import useLocalStorage from "hooks/use-local-storage";
import { SingleProgressStats } from "components/core"; import { SingleProgressStats } from "components/core";
// ui // ui
import { Avatar } from "components/ui"; import { Avatar } from "components/ui";
// icons
import User from "public/user.png";
// types // types
import { IIssue, IIssueLabels } from "types"; import { IIssue, IIssueLabels } from "types";
// fetch-keys // fetch-keys
@ -125,9 +122,9 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ issues }) => {
<SingleProgressStats <SingleProgressStats
title={ title={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full border-2 border-white bg-brand-surface-2"> <div className="h-5 w-5 rounded-full border-2 border-brand-base bg-brand-surface-2">
<Image <img
src={User} src="/user.png"
height="100%" height="100%"
width="100%" width="100%"
className="rounded-full" 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"; import { useRouter } from "next/router";
// components // components
import { GanttChartRoot } from "components/gantt-chart"; import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// types // types
import { ICycle } from "types"; import { ICycle } from "types";
@ -31,9 +33,11 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles }) => {
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${data?.id}`}> <Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm"> <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="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"> <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} {data?.name}
</div> </div>
</Tooltip>
</a> </a>
</Link> </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,189 +1,208 @@
import React, { useEffect } from "react"; import React, { useState } from "react";
import dynamic from "next/dynamic";
// headless ui import { useRouter } from "next/router";
import { Tab } from "@headlessui/react";
import { mutate } from "swr";
// services
import cyclesService from "services/cycles.service";
// hooks // hooks
import useLocalStorage from "hooks/use-local-storage"; import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth";
// components // components
import { import {
ActiveCycleDetails, CreateUpdateCycleModal,
CompletedCyclesListProps,
AllCyclesBoard,
AllCyclesList,
CyclesListGanttChartView, CyclesListGanttChartView,
DeleteCycleModal,
SingleCycleCard,
SingleCycleList,
} from "components/cycles"; } from "components/cycles";
// ui // ui
import { EmptyState, Loader } from "components/ui"; import { EmptyState, Loader } from "components/ui";
// icons // images
import { ChartBarIcon, ListBulletIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
import emptyCycle from "public/empty-state/empty-cycle.svg"; import emptyCycle from "public/empty-state/empty-cycle.svg";
// helpers
import { getDateRangeStatus } from "helpers/date-time.helper";
// types // types
import { ICycle } from "types";
// fetch-keys
import { import {
SelectCycleType, COMPLETED_CYCLES_LIST,
ICycle, CURRENT_CYCLE_LIST,
CurrentAndUpcomingCyclesResponse, CYCLES_LIST,
DraftCyclesResponse, DRAFT_CYCLES_LIST,
} from "types"; UPCOMING_CYCLES_LIST,
} from "constants/fetch-keys";
type Props = { type Props = {
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>; cycles: ICycle[] | undefined;
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>; viewType: string | null;
cyclesCompleteList: ICycle[] | undefined;
currentAndUpcomingCycles: CurrentAndUpcomingCyclesResponse | undefined;
draftCycles: DraftCyclesResponse | undefined;
}; };
export const CyclesView: React.FC<Props> = ({ export const CyclesView: React.FC<Props> = ({ cycles, viewType }) => {
setSelectedCycle, const [createUpdateCycleModal, setCreateUpdateCycleModal] = useState(false);
setCreateUpdateCycleModal, const [selectedCycleToUpdate, setSelectedCycleToUpdate] = useState<ICycle | null>(null);
cyclesCompleteList,
currentAndUpcomingCycles,
draftCycles,
}) => {
const { storedValue: cycleTab, setValue: setCycleTab } = useLocalStorage("cycleTab", "All");
const { storedValue: cyclesView, setValue: setCyclesView } = useLocalStorage("cycleView", "list");
const currentTabValue = (tab: string | null) => { const [deleteCycleModal, setDeleteCycleModal] = useState(false);
switch (tab) { const [selectedCycleToDelete, setSelectedCycleToDelete] = useState<ICycle | null>(null);
case "All":
return 0; const router = useRouter();
case "Active": const { workspaceSlug, projectId } = router.query;
return 1;
case "Upcoming": const { user } = useUserAuth();
return 2; const { setToastAlert } = useToast();
case "Completed":
return 3; const handleEditCycle = (cycle: ICycle) => {
case "Drafts": setSelectedCycleToUpdate(cycle);
return 4; setCreateUpdateCycleModal(true);
default:
return 0;
}
}; };
const CompletedCycles = dynamic<CompletedCyclesListProps>( const handleDeleteCycle = (cycle: ICycle) => {
() => import("components/cycles").then((a) => a.CompletedCycles), setSelectedCycleToDelete(cycle);
{ setDeleteCycleModal(true);
ssr: false, };
loading: () => (
<Loader className="mb-5"> const handleAddToFavorites = (cycle: ICycle) => {
<Loader.Item height="12rem" width="100%" /> if (!workspaceSlug || !projectId) return;
</Loader>
), 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 ( return (
<> <>
<div className="flex gap-4 justify-between"> <CreateUpdateCycleModal
<h3 className="text-2xl font-semibold text-brand-base">Cycles</h3> isOpen={createUpdateCycleModal}
<div className="flex items-center gap-x-1"> handleClose={() => setCreateUpdateCycleModal(false)}
<button data={selectedCycleToUpdate}
type="button" user={user}
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" : "" <DeleteCycleModal
}`} isOpen={deleteCycleModal}
onClick={() => setCyclesView("list")} setIsOpen={setDeleteCycleModal}
> data={selectedCycleToDelete}
<ListBulletIcon className="h-4 w-4 text-brand-secondary" /> user={user}
</button> />
<button {cycles ? (
type="button" cycles.length > 0 ? (
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-2 ${ viewType === "list" ? (
cyclesView === "board" ? "bg-brand-surface-2" : "" <div className="divide-y divide-brand-base">
}`} {cycles.map((cycle) => (
onClick={() => setCyclesView("board")} <div className="hover:bg-brand-surface-2">
> <div className="flex flex-col border-brand-base">
<Squares2X2Icon className="h-4 w-4 text-brand-secondary" /> <SingleCycleList
</button> key={cycle.id}
<button cycle={cycle}
type="button" handleDeleteCycle={() => handleDeleteCycle(cycle)}
className={`grid h-7 w-7 place-items-center rounded outline-none duration-300 hover:bg-brand-surface-2 ${ handleEditCycle={() => handleEditCycle(cycle)}
cyclesView === "gantt_chart" ? "bg-brand-surface-2" : "" handleAddToFavorites={() => handleAddToFavorites(cycle)}
}`} handleRemoveFromFavorites={() => handleRemoveFromFavorites(cycle)}
onClick={() => { />
setCyclesView("gantt_chart");
setCycleTab("All");
}}
>
<span className="material-symbols-rounded text-brand-secondary text-[18px] rotate-90">
waterfall_chart
</span>
</button>
</div> </div>
</div> </div>
<Tab.Group ))}
as={React.Fragment} </div>
defaultIndex={currentTabValue(cycleTab)} ) : viewType === "board" ? (
selectedIndex={currentTabValue(cycleTab)} <div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
onChange={(i) => { {cycles.map((cycle) => (
switch (i) { <SingleCycleCard
case 0: key={cycle.id}
return setCycleTab("All"); cycle={cycle}
case 1: handleDeleteCycle={() => handleDeleteCycle(cycle)}
return setCycleTab("Active"); handleEditCycle={() => handleEditCycle(cycle)}
case 2: handleAddToFavorites={() => handleAddToFavorites(cycle)}
return setCycleTab("Upcoming"); handleRemoveFromFavorites={() => handleRemoveFromFavorites(cycle)}
case 3: />
return setCycleTab("Completed"); ))}
case 4: </div>
return setCycleTab("Drafts"); ) : (
default: <CyclesListGanttChartView cycles={cycles ?? []} />
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 <EmptyState
type="cycle" type="cycle"
@ -191,59 +210,24 @@ export const CyclesView: React.FC<Props> = ({
description="Sprint more effectively with Cycles by confining your project to a fixed amount of time. Create new cycle now." description="Sprint more effectively with Cycles by confining your project to a fixed amount of time. Create new cycle now."
imgURL={emptyCycle} 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>
)} )}
</Tab.Panel>
)}
<Tab.Panel as="div" className="mt-7 space-y-5 h-full overflow-y-auto">
{cyclesView === "list" && (
<AllCyclesList
cycles={currentAndUpcomingCycles?.upcoming_cycle}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="upcoming"
/>
)}
{cyclesView === "board" && (
<AllCyclesBoard
cycles={currentAndUpcomingCycles?.upcoming_cycle}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="upcoming"
/>
)}
{cyclesView === "gantt_chart" && (
<CyclesListGanttChartView cycles={currentAndUpcomingCycles?.upcoming_cycle ?? []} />
)}
</Tab.Panel>
<Tab.Panel as="div" className="mt-7 space-y-5">
<CompletedCycles
cycleView={cyclesView ?? "list"}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
/>
</Tab.Panel>
{cyclesView !== "gantt_chart" && (
<Tab.Panel as="div" className="mt-7 space-y-5">
{cyclesView === "list" && (
<AllCyclesList
cycles={draftCycles?.draft_cycles}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="draft"
/>
)}
{cyclesView === "board" && (
<AllCyclesBoard
cycles={draftCycles?.draft_cycles}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="draft"
/>
)}
</Tab.Panel>
)}
</Tab.Panels>
</Tab.Group>
</> </>
); );
}; };

View File

@ -14,24 +14,20 @@ import { DangerButton, SecondaryButton } from "components/ui";
// icons // icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// types // types
import type { import type { ICurrentUserResponse, ICycle } from "types";
CompletedCyclesResponse,
CurrentAndUpcomingCyclesResponse,
DraftCyclesResponse,
ICycle,
} from "types";
type TConfirmCycleDeletionProps = { type TConfirmCycleDeletionProps = {
isOpen: boolean; isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data?: ICycle; data?: ICycle | null;
user: ICurrentUserResponse | undefined;
}; };
// fetch-keys // fetch-keys
import { import {
CYCLE_COMPLETE_LIST, COMPLETED_CYCLES_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST, CURRENT_CYCLE_LIST,
CYCLE_DETAILS, CYCLES_LIST,
CYCLE_DRAFT_LIST, DRAFT_CYCLES_LIST,
CYCLE_LIST, UPCOMING_CYCLES_LIST,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
import { getDateRangeStatus } from "helpers/date-time.helper"; import { getDateRangeStatus } from "helpers/date-time.helper";
@ -39,6 +35,7 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
isOpen, isOpen,
setIsOpen, setIsOpen,
data, data,
user,
}) => { }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
@ -58,65 +55,30 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
setIsDeleteLoading(true); setIsDeleteLoading(true);
await cycleService await cycleService
.deleteCycle(workspaceSlug as string, data.project, data.id) .deleteCycle(workspaceSlug as string, data.project, data.id, user)
.then(() => { .then(() => {
switch (getDateRangeStatus(data.start_date, data.end_date)) { const cycleType = getDateRangeStatus(data.start_date, data.end_date);
case "completed": const fetchKey =
mutate<CompletedCyclesResponse>( cycleType === "current"
CYCLE_COMPLETE_LIST(projectId as string), ? 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);
mutate<ICycle[]>(
fetchKey,
(prevData) => { (prevData) => {
if (!prevData) return; if (!prevData) return;
return { return prevData.filter((cycle) => cycle.id !== data?.id);
completed_cycles: prevData.completed_cycles?.filter(
(cycle) => cycle.id !== data?.id
),
};
}, },
false 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;
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( mutate(
CYCLE_DETAILS(projectId as string), CYCLES_LIST(projectId as string),
(prevData: any) => { (prevData: any) => {
if (!prevData) return; if (!prevData) return;
return prevData.filter((cycle: any) => cycle.id !== data?.id); 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>; handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
handleClose: () => void; handleClose: () => void;
status: boolean; status: boolean;
data?: ICycle; data?: ICycle | null;
}; };
const defaultValues: Partial<ICycle> = { const defaultValues: Partial<ICycle> = {
@ -28,7 +28,6 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
handleSubmit, handleSubmit,
control, control,
watch,
reset, reset,
} = useForm<ICycle>({ } = useForm<ICycle>({
defaultValues, defaultValues,

View File

@ -4,6 +4,8 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// components // components
import { GanttChartRoot } from "components/gantt-chart"; import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// hooks // hooks
import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view"; 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" className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: data?.state_detail?.color || "#858e96" }} 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"> <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} {data?.name}
</div> </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> </a>
</Link> </Link>
); );
@ -59,10 +75,20 @@ export const CycleIssuesGanttChartView: FC<Props> = ({}) => {
const blockFormat = (blocks: any) => const blockFormat = (blocks: any) =>
blocks && blocks.length > 0 blocks && blocks.length > 0
? blocks.map((_block: any) => { ? 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 { return {
start_date: new Date(_block.created_at), start_date: new Date(startDate),
target_date: new Date(_block.updated_at), target_date: new Date(targetDate),
infoToggle: infoToggle,
data: _block, data: _block,
}; };
}) })

View File

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

View File

@ -15,26 +15,29 @@ import { CycleForm } from "components/cycles";
// helper // helper
import { getDateRangeStatus, isDateGreaterThanToday } from "helpers/date-time.helper"; import { getDateRangeStatus, isDateGreaterThanToday } from "helpers/date-time.helper";
// types // types
import type { ICycle } from "types"; import type { ICurrentUserResponse, ICycle } from "types";
// fetch keys // fetch keys
import { import {
CYCLE_COMPLETE_LIST, COMPLETED_CYCLES_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST, CURRENT_CYCLE_LIST,
CYCLE_DETAILS, CYCLES_LIST,
CYCLE_DRAFT_LIST, DRAFT_CYCLES_LIST,
CYCLE_INCOMPLETE_LIST, INCOMPLETE_CYCLES_LIST,
UPCOMING_CYCLES_LIST,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
type CycleModalProps = { type CycleModalProps = {
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
data?: ICycle; data?: ICycle | null;
user: ICurrentUserResponse | undefined;
}; };
export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
isOpen, isOpen,
handleClose, handleClose,
data, data,
user,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -42,24 +45,26 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const createCycle = async (payload: Partial<ICycle>) => { const createCycle = async (payload: Partial<ICycle>) => {
if (!workspaceSlug || !projectId) return;
await cycleService await cycleService
.createCycle(workspaceSlug as string, projectId as string, payload) .createCycle(workspaceSlug.toString(), projectId.toString(), payload, user)
.then((res) => { .then((res) => {
switch (getDateRangeStatus(res.start_date, res.end_date)) { switch (getDateRangeStatus(res.start_date, res.end_date)) {
case "completed": case "completed":
mutate(CYCLE_COMPLETE_LIST(projectId as string)); mutate(COMPLETED_CYCLES_LIST(projectId.toString()));
break; break;
case "current": case "current":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string)); mutate(CURRENT_CYCLE_LIST(projectId.toString()));
break; break;
case "upcoming": case "upcoming":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string)); mutate(UPCOMING_CYCLES_LIST(projectId.toString()));
break; break;
default: default:
mutate(CYCLE_DRAFT_LIST(projectId as string)); mutate(DRAFT_CYCLES_LIST(projectId.toString()));
} }
mutate(CYCLE_INCOMPLETE_LIST(projectId as string)); mutate(INCOMPLETE_CYCLES_LIST(projectId.toString()));
mutate(CYCLE_DETAILS(projectId as string)); mutate(CYCLES_LIST(projectId.toString()));
handleClose(); handleClose();
setToastAlert({ setToastAlert({
@ -68,7 +73,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
message: "Cycle created successfully.", message: "Cycle created successfully.",
}); });
}) })
.catch((err) => { .catch(() => {
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
@ -78,39 +83,41 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
}; };
const updateCycle = async (cycleId: string, payload: Partial<ICycle>) => { const updateCycle = async (cycleId: string, payload: Partial<ICycle>) => {
if (!workspaceSlug || !projectId) return;
await cycleService await cycleService
.updateCycle(workspaceSlug as string, projectId as string, cycleId, payload) .updateCycle(workspaceSlug.toString(), projectId.toString(), cycleId, payload, user)
.then((res) => { .then((res) => {
switch (getDateRangeStatus(data?.start_date, data?.end_date)) { switch (getDateRangeStatus(data?.start_date, data?.end_date)) {
case "completed": case "completed":
mutate(CYCLE_COMPLETE_LIST(projectId as string)); mutate(COMPLETED_CYCLES_LIST(projectId.toString()));
break; break;
case "current": case "current":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string)); mutate(CURRENT_CYCLE_LIST(projectId.toString()));
break; break;
case "upcoming": case "upcoming":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string)); mutate(UPCOMING_CYCLES_LIST(projectId.toString()));
break; break;
default: 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 ( if (
getDateRangeStatus(data?.start_date, data?.end_date) != getDateRangeStatus(data?.start_date, data?.end_date) !=
getDateRangeStatus(res.start_date, res.end_date) getDateRangeStatus(res.start_date, res.end_date)
) { ) {
switch (getDateRangeStatus(res.start_date, res.end_date)) { switch (getDateRangeStatus(res.start_date, res.end_date)) {
case "completed": case "completed":
mutate(CYCLE_COMPLETE_LIST(projectId as string)); mutate(COMPLETED_CYCLES_LIST(projectId.toString()));
break; break;
case "current": case "current":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string)); mutate(CURRENT_CYCLE_LIST(projectId.toString()));
break; break;
case "upcoming": case "upcoming":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string)); mutate(UPCOMING_CYCLES_LIST(projectId.toString()));
break; break;
default: 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 useSWR from "swr";
import useUserAuth from "hooks/use-user-auth";
// headless ui // headless ui
import { Listbox, Transition } from "@headlessui/react"; import { Listbox, Transition } from "@headlessui/react";
// icons // icons
@ -14,7 +15,7 @@ import cycleServices from "services/cycles.service";
// components // components
import { CreateUpdateCycleModal } from "components/cycles"; import { CreateUpdateCycleModal } from "components/cycles";
// fetch-keys // fetch-keys
import { CYCLE_LIST } from "constants/fetch-keys"; import { CYCLES_LIST } from "constants/fetch-keys";
export type IssueCycleSelectProps = { export type IssueCycleSelectProps = {
projectId: string; projectId: string;
@ -35,10 +36,12 @@ export const CycleSelect: React.FC<IssueCycleSelectProps> = ({
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const { user } = useUserAuth();
const { data: cycles } = useSWR( const { data: cycles } = useSWR(
workspaceSlug && projectId ? CYCLE_LIST(projectId) : null, workspaceSlug && projectId ? CYCLES_LIST(projectId) : null,
workspaceSlug && projectId workspaceSlug && projectId
? () => cycleServices.getCycles(workspaceSlug as string, projectId) ? () => cycleServices.getCyclesWithParams(workspaceSlug as string, projectId as string, "all")
: null : null
); );
@ -54,7 +57,11 @@ export const CycleSelect: React.FC<IssueCycleSelectProps> = ({
return ( return (
<> <>
<CreateUpdateCycleModal isOpen={isCycleModalActive} handleClose={closeCycleModal} /> <CreateUpdateCycleModal
isOpen={isCycleModalActive}
handleClose={closeCycleModal}
user={user}
/>
<Listbox as="div" className="relative" value={value} onChange={onChange} multiple={multiple}> <Listbox as="div" className="relative" value={value} onChange={onChange} multiple={multiple}>
{({ open }) => ( {({ open }) => (
<> <>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -49,7 +49,10 @@ export const GanttChartBlocks: FC<{
width: `${block?.position?.width}px`, width: `${block?.position?.width}px`,
}} }}
> >
{blockRender({ ...block?.data })} {blockRender({
...block?.data,
infoToggle: block?.infoToggle ? true : false,
})}
</div> </div>
<div className="flex-shrink-0 relative w-0 h-0 flex items-center invisible group-hover:visible whitespace-nowrap"> <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 // icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// types // types
import { IImporterService } from "types"; import { ICurrentUserResponse, IImporterService } from "types";
// fetch-keys // fetch-keys
import { IMPORTER_SERVICES_LIST } from "constants/fetch-keys"; import { IMPORTER_SERVICES_LIST } from "constants/fetch-keys";
@ -23,9 +23,10 @@ type Props = {
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
data: IImporterService | null; 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 [deleteLoading, setDeleteLoading] = useState(false);
const [confirmDeleteImport, setConfirmDeleteImport] = useState(false); const [confirmDeleteImport, setConfirmDeleteImport] = useState(false);
@ -45,7 +46,7 @@ export const DeleteImportModal: React.FC<Props> = ({ isOpen, handleClose, data }
false false
); );
IntegrationService.deleteImporterService(workspaceSlug as string, data.service, data.id) IntegrationService.deleteImporterService(workspaceSlug as string, data.service, data.id, user)
.catch(() => .catch(() =>
setToastAlert({ setToastAlert({
type: "error", type: "error",

View File

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

View File

@ -1,4 +1,3 @@
import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; 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="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="flex items-center gap-2">
<div className="relative h-8 w-8 flex-shrink-0 rounded"> <div className="relative h-8 w-8 flex-shrink-0 rounded">
<Image <img
src={collaborator.avatar_url} src={collaborator.avatar_url}
layout="fill" className="absolute top-0 left-0 h-full w-full object-cover rounded"
objectFit="cover"
className="rounded"
alt={`${collaborator.login} GitHub user`} alt={`${collaborator.login} GitHub user`}
/> />
</div> </div>

View File

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

View File

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

View File

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

View File

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

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