Merge branch 'develop' of github.com:makeplane/plane into feat/issue_relation

This commit is contained in:
NarayanBavisetti 2023-08-31 12:02:44 +05:30
commit 76ec76864e
135 changed files with 6319 additions and 2218 deletions

View File

@ -0,0 +1,55 @@
name: Build Pull Request Contents
on:
pull_request:
types: ["opened", "synchronize"]
jobs:
build-pull-request-contents:
name: Build Pull Request Contents
runs-on: ubuntu-20.04
permissions:
pull-requests: read
steps:
- name: Checkout Repository to Actions
uses: actions/checkout@v3.3.0
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 18.x
cache: 'yarn'
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v38
with:
files_yaml: |
apiserver:
- apiserver/**
web:
- apps/app/**
deploy:
- apps/space/**
- name: Setup .npmrc for repository
run: |
echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc
- name: Build Plane's Main App
if: steps.changed-files.outputs.web_any_changed == 'true'
run: |
mv ./.npmrc ./apps/app
cd apps/app
yarn
yarn build
- name: Build Plane's Deploy App
if: steps.changed-files.outputs.deploy_any_changed == 'true'
run: |
cd apps/space
yarn
yarn build

View File

@ -0,0 +1,111 @@
name: Update Docker Images for Plane on Release
on:
release:
types: [released]
jobs:
build_push_backend:
name: Build and Push Api Server Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup .npmrc for repository
run: |
echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaFrontend
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend
tags: |
type=ref,event=tag
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaBackend
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend
tags: |
type=ref,event=tag
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaDeploy
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-deploy
tags: |
type=ref,event=tag
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaProxy
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy
tags: |
type=ref,event=tag
- name: Build and Push Frontend to Docker Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./apps/app/Dockerfile.web
platforms: linux/amd64
tags: ${{ steps.metaFrontend.outputs.tags }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Backend to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: linux/amd64
push: true
tags: ${{ steps.metaBackend.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Plane-Deploy to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./apps/space/Dockerfile.space
platforms: linux/amd64
push: true
tags: ${{ steps.metaDeploy.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Plane-Proxy to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: ./nginx
file: ./nginx/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.metaProxy.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}

View File

@ -1,57 +0,0 @@
name: Build and Push Backend Docker Image
on:
pull_request:
types: [closed]
branches: [develop]
release:
types: [released]
jobs:
build_push_backend:
name: Build and Push Api Server Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Pull Request
id: meta_pr
if: ${{ github.event_name == 'pull_request' }}
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend
tags: type=raw,value=develop
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: meta
uses: docker/metadata-action@v4.3.0
if: ${{ github.event_name == 'release' }}
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend
tags: |
type=ref,event=tag
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: linux/amd64
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || steps.meta_pr.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}

View File

@ -1,57 +0,0 @@
name: Build and Push Plane Deploy Docker Image
on:
pull_request:
types: [closed]
branches: [develop]
release:
types: [released]
jobs:
build_push_plane_deploy:
name: Build and Push Plane Deploy Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Pull Request
id: meta_pr
if: ${{ github.event_name == 'pull_request' }}
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-deploy
tags: type=raw,value=develop
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: meta
uses: docker/metadata-action@v4.3.0
if: ${{ github.event_name == 'release' }}
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-deploy
tags: |
type=ref,event=tag
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./apps/space/Dockerfile.space
platforms: linux/amd64
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || steps.meta_pr.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}

View File

@ -1,60 +0,0 @@
name: Build and Push Frontend Docker Image
on:
pull_request:
types: [closed]
branches: [develop]
release:
types: [released]
jobs:
build_push_frontend:
name: Build Frontend Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Setup .npmrc for repository
run: |
echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: meta
if: ${{ github.event_name == 'release' }}
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend
tags: |
type=ref,event=tag
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Pull Request
id: meta_pr
if: ${{ github.event_name == 'pull_request' }}
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend
tags: type=raw,value=develop
- name: Build and Push to Docker Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./apps/app/Dockerfile.web
platforms: linux/amd64
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || steps.meta_pr.outputs.tags }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}

View File

@ -1,56 +0,0 @@
name: Build and Push Proxy Docker Image
on:
pull_request:
types: [closed]
branches: [develop]
release:
types: [released]
jobs:
build_push_proxy:
name: Build and Push Proxy Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Pull Request
id: meta_pr
if: ${{ github.event_name == 'pull_request' }}
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy
tags: type=raw,value=develop
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: meta
uses: docker/metadata-action@v4.3.0
if: ${{ github.event_name == 'release' }}
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy
tags: |
type=ref,event=tag
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: ./nginx
file: ./nginx/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || steps.meta_pr.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}

View File

@ -1,3 +1,3 @@
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
worker: celery -A plane worker -l info worker: celery -A plane worker -l info
beat: celery -A plane beat -l INFO beat: celery -A plane beat -l INFO

View File

@ -20,6 +20,7 @@ from .project import (
ProjectMemberLiteSerializer, ProjectMemberLiteSerializer,
ProjectDeployBoardSerializer, ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer, ProjectMemberAdminSerializer,
ProjectPublicMemberSerializer
) )
from .state import StateSerializer, StateLiteSerializer from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer from .view import IssueViewSerializer, IssueViewFavoriteSerializer
@ -44,6 +45,7 @@ from .issue import (
IssueVoteSerializer, IssueVoteSerializer,
IssueRelationSerializer, IssueRelationSerializer,
RelatedIssueSerializer, RelatedIssueSerializer,
IssuePublicSerializer,
) )
from .module import ( from .module import (

View File

@ -99,7 +99,11 @@ class IssueCreateSerializer(BaseSerializer):
] ]
def validate(self, data): def validate(self, data):
if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None): if (
data.get("start_date", None) is not None
and data.get("target_date", None) is not None
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date") raise serializers.ValidationError("Start date cannot exceed target date")
return data return data
@ -477,9 +481,7 @@ class CommentReactionSerializer(BaseSerializer):
read_only_fields = ["workspace", "project", "comment", "actor"] read_only_fields = ["workspace", "project", "comment", "actor"]
class IssueVoteSerializer(BaseSerializer): class IssueVoteSerializer(BaseSerializer):
class Meta: class Meta:
model = IssueVote model = IssueVote
fields = ["issue", "vote", "workspace_id", "project_id", "actor"] fields = ["issue", "vote", "workspace_id", "project_id", "actor"]
@ -492,7 +494,7 @@ class IssueCommentSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project") project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True)
is_member = serializers.BooleanField(read_only=True)
class Meta: class Meta:
model = IssueComment model = IssueComment
@ -505,7 +507,6 @@ class IssueCommentSerializer(BaseSerializer):
"updated_by", "updated_by",
"created_at", "created_at",
"updated_at", "updated_at",
"access",
] ]
@ -597,6 +598,32 @@ class IssueLiteSerializer(BaseSerializer):
] ]
class IssuePublicSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateLiteSerializer(read_only=True, source="state")
issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True)
votes = IssueVoteSerializer(read_only=True, many=True)
class Meta:
model = Issue
fields = [
"id",
"name",
"description_html",
"sequence_id",
"state",
"state_detail",
"project",
"project_detail",
"workspace",
"priority",
"target_date",
"issue_reactions",
"votes",
]
read_only_fields = fields
class IssueSubscriberSerializer(BaseSerializer): class IssueSubscriberSerializer(BaseSerializer):
class Meta: class Meta:
model = IssueSubscriber model = IssueSubscriber

View File

@ -15,6 +15,7 @@ from plane.db.models import (
ProjectIdentifier, ProjectIdentifier,
ProjectFavorite, ProjectFavorite,
ProjectDeployBoard, ProjectDeployBoard,
ProjectPublicMember,
) )
@ -177,5 +178,17 @@ class ProjectDeployBoardSerializer(BaseSerializer):
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"workspace", "workspace",
"project" "anchor", "project", "anchor",
]
class ProjectPublicMemberSerializer(BaseSerializer):
class Meta:
model = ProjectPublicMember
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"member",
] ]

View File

@ -165,16 +165,18 @@ from plane.api.views import (
# Notification # Notification
NotificationViewSet, NotificationViewSet,
UnreadNotificationEndpoint, UnreadNotificationEndpoint,
MarkAllReadNotificationViewSet,
## End Notification ## End Notification
# Public Boards # Public Boards
ProjectDeployBoardViewSet, ProjectDeployBoardViewSet,
ProjectDeployBoardIssuesPublicEndpoint, ProjectIssuesPublicEndpoint,
ProjectDeployBoardPublicSettingsEndpoint, ProjectDeployBoardPublicSettingsEndpoint,
IssueReactionPublicViewSet, IssueReactionPublicViewSet,
CommentReactionPublicViewSet, CommentReactionPublicViewSet,
InboxIssuePublicViewSet, InboxIssuePublicViewSet,
IssueVotePublicViewSet, IssueVotePublicViewSet,
WorkspaceProjectDeployBoardEndpoint, WorkspaceProjectDeployBoardEndpoint,
IssueRetrievePublicEndpoint,
## End Public Boards ## End Public Boards
## Exporter ## Exporter
ExportIssuesEndpoint, ExportIssuesEndpoint,
@ -236,7 +238,7 @@ urlpatterns = [
UpdateUserTourCompletedEndpoint.as_view(), UpdateUserTourCompletedEndpoint.as_view(),
name="user-tour", name="user-tour",
), ),
path("users/activities/", UserActivityEndpoint.as_view(), name="user-activities"), path("users/workspaces/<str:slug>/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
# user workspaces # user workspaces
path( path(
"users/me/workspaces/", "users/me/workspaces/",
@ -1515,6 +1517,15 @@ urlpatterns = [
UnreadNotificationEndpoint.as_view(), UnreadNotificationEndpoint.as_view(),
name="unread-notifications", name="unread-notifications",
), ),
path(
"workspaces/<str:slug>/users/notifications/mark-all-read/",
MarkAllReadNotificationViewSet.as_view(
{
"post": "create",
}
),
name="mark-all-read-notifications",
),
## End Notification ## End Notification
# Public Boards # Public Boards
path( path(
@ -1545,9 +1556,14 @@ urlpatterns = [
), ),
path( path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/", "public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/",
ProjectDeployBoardIssuesPublicEndpoint.as_view(), ProjectIssuesPublicEndpoint.as_view(),
name="project-deploy-board", name="project-deploy-board",
), ),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/",
IssueRetrievePublicEndpoint.as_view(),
name="workspace-project-boards",
),
path( path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/", "public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
IssueCommentPublicViewSet.as_view( IssueCommentPublicViewSet.as_view(

View File

@ -12,7 +12,6 @@ from .project import (
ProjectUserViewsEndpoint, ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint, ProjectMemberUserEndpoint,
ProjectFavoritesViewSet, ProjectFavoritesViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectDeployBoardViewSet, ProjectDeployBoardViewSet,
ProjectDeployBoardPublicSettingsEndpoint, ProjectDeployBoardPublicSettingsEndpoint,
ProjectMemberEndpoint, ProjectMemberEndpoint,
@ -86,6 +85,8 @@ from .issue import (
CommentReactionPublicViewSet, CommentReactionPublicViewSet,
IssueVotePublicViewSet, IssueVotePublicViewSet,
IssueRelationViewSet, IssueRelationViewSet,
IssueRetrievePublicEndpoint,
ProjectIssuesPublicEndpoint,
) )
from .auth_extended import ( from .auth_extended import (
@ -163,6 +164,6 @@ from .analytic import (
DefaultAnalyticsEndpoint, DefaultAnalyticsEndpoint,
) )
from .notification import NotificationViewSet, UnreadNotificationEndpoint from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
from .exporter import ExportIssuesEndpoint from .exporter import ExportIssuesEndpoint

View File

@ -18,10 +18,21 @@ class FileAssetEndpoint(BaseAPIView):
""" """
def get(self, request, workspace_id, asset_key): def get(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key try:
files = FileAsset.objects.filter(asset=asset_key) asset_key = str(workspace_id) + "/" + asset_key
serializer = FileAssetSerializer(files, context={"request": request}, many=True) files = FileAsset.objects.filter(asset=asset_key)
return Response(serializer.data) if files.exists():
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK)
else:
return Response({"error": "Asset key does not exist", "status": False}, 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,
)
def post(self, request, slug): def post(self, request, slug):
try: try:
@ -68,11 +79,16 @@ class UserAssetsEndpoint(BaseAPIView):
def get(self, request, asset_key): def get(self, request, asset_key):
try: try:
files = FileAsset.objects.filter(asset=asset_key, created_by=request.user) files = FileAsset.objects.filter(asset=asset_key, created_by=request.user)
serializer = FileAssetSerializer(files, context={"request": request}) if files.exists():
return Response(serializer.data) serializer = FileAssetSerializer(files, context={"request": request})
except FileAsset.DoesNotExist: return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK)
else:
return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response( return Response(
{"error": "File Asset does not exist"}, status=status.HTTP_404_NOT_FOUND {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
) )
def post(self, request): def post(self, request):

View File

@ -1,24 +1,41 @@
# Python imports
import zoneinfo
# Django imports # Django imports
from django.urls import resolve from django.urls import resolve
from django.conf import settings from django.conf import settings
from django.utils import timezone
# Third part imports # Third part imports
from rest_framework import status from rest_framework import status
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.filters import SearchFilter from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.exceptions import NotFound
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
# Module imports # Module imports
from plane.db.models import Workspace, Project
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
class BaseViewSet(ModelViewSet, BasePaginator): class TimezoneMixin:
"""
This enables timezone conversion according
to the user set timezone
"""
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
if request.user.is_authenticated:
timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone))
else:
timezone.deactivate()
class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
model = None model = None
@ -67,7 +84,7 @@ class BaseViewSet(ModelViewSet, BasePaginator):
return self.kwargs.get("pk", None) return self.kwargs.get("pk", None)
class BaseAPIView(APIView, BasePaginator): class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
permission_classes = [ permission_classes = [
IsAuthenticated, IsAuthenticated,

View File

@ -29,6 +29,7 @@ from django.db import IntegrityError
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.permissions import AllowAny
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
# Module imports # Module imports
@ -51,6 +52,7 @@ from plane.api.serializers import (
CommentReactionSerializer, CommentReactionSerializer,
IssueVoteSerializer, IssueVoteSerializer,
IssueRelationSerializer, IssueRelationSerializer,
IssuePublicSerializer,
) )
from plane.api.permissions import ( from plane.api.permissions import (
WorkspaceEntityPermission, WorkspaceEntityPermission,
@ -76,10 +78,12 @@ from plane.db.models import (
ProjectDeployBoard, ProjectDeployBoard,
IssueVote, IssueVote,
IssueRelation, IssueRelation,
ProjectPublicMember,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.bgtasks.export_task import issue_export_task
class IssueViewSet(BaseViewSet): class IssueViewSet(BaseViewSet):
@ -485,7 +489,7 @@ class IssueActivityEndpoint(BaseAPIView):
issue_activities = ( issue_activities = (
IssueActivity.objects.filter(issue_id=issue_id) IssueActivity.objects.filter(issue_id=issue_id)
.filter( .filter(
~Q(field="comment"), ~Q(field__in=["comment", "vote", "reaction"]),
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
) )
.select_related("actor", "workspace", "issue", "project") .select_related("actor", "workspace", "issue", "project")
@ -495,6 +499,12 @@ class IssueActivityEndpoint(BaseAPIView):
.filter(project__project_projectmember__member=self.request.user) .filter(project__project_projectmember__member=self.request.user)
.order_by("created_at") .order_by("created_at")
.select_related("actor", "issue", "project", "workspace") .select_related("actor", "issue", "project", "workspace")
.prefetch_related(
Prefetch(
"comment_reactions",
queryset=CommentReaction.objects.select_related("actor"),
)
)
) )
issue_activities = IssueActivitySerializer(issue_activities, many=True).data issue_activities = IssueActivitySerializer(issue_activities, many=True).data
issue_comments = IssueCommentSerializer(issue_comments, many=True).data issue_comments = IssueCommentSerializer(issue_comments, many=True).data
@ -591,6 +601,15 @@ class IssueCommentViewSet(BaseViewSet):
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("issue") .select_related("issue")
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
member_id=self.request.user.id,
)
)
)
.distinct() .distinct()
) )
@ -772,7 +791,9 @@ class SubIssuesEndpoint(BaseAPIView):
.order_by("state_group") .order_by("state_group")
) )
result = {item["state_group"]: item["state_count"] for item in state_distribution} result = {
item["state_group"]: item["state_count"] for item in state_distribution
}
serializer = IssueLiteSerializer( serializer = IssueLiteSerializer(
sub_issues, sub_issues,
@ -1387,6 +1408,14 @@ class IssueReactionViewSet(BaseViewSet):
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
actor=self.request.user, actor=self.request.user,
) )
issue_activity.delay(
type="issue_reaction.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
def destroy(self, request, slug, project_id, issue_id, reaction_code): def destroy(self, request, slug, project_id, issue_id, reaction_code):
try: try:
@ -1397,6 +1426,19 @@ class IssueReactionViewSet(BaseViewSet):
reaction=reaction_code, reaction=reaction_code,
actor=request.user, actor=request.user,
) )
issue_activity.delay(
type="issue_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(issue_reaction.id),
}
),
)
issue_reaction.delete() issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
except IssueReaction.DoesNotExist: except IssueReaction.DoesNotExist:
@ -1437,6 +1479,14 @@ class CommentReactionViewSet(BaseViewSet):
comment_id=self.kwargs.get("comment_id"), comment_id=self.kwargs.get("comment_id"),
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
) )
issue_activity.delay(
type="comment_reaction.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
def destroy(self, request, slug, project_id, comment_id, reaction_code): def destroy(self, request, slug, project_id, comment_id, reaction_code):
try: try:
@ -1447,6 +1497,20 @@ class CommentReactionViewSet(BaseViewSet):
reaction=reaction_code, reaction=reaction_code,
actor=request.user, actor=request.user,
) )
issue_activity.delay(
type="comment_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(comment_reaction.id),
"comment_id": str(comment_id)
}
),
)
comment_reaction.delete() comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
except CommentReaction.DoesNotExist: except CommentReaction.DoesNotExist:
@ -1482,9 +1546,19 @@ class IssueCommentPublicViewSet(BaseViewSet):
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(access="EXTERNAL")
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("issue") .select_related("issue")
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
member_id=self.request.user.id,
)
)
)
.distinct() .distinct()
) )
else: else:
@ -1502,21 +1576,13 @@ class IssueCommentPublicViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
access = (
"INTERNAL"
if ProjectMember.objects.filter(
project_id=project_id, member=request.user
).exists()
else "EXTERNAL"
)
serializer = IssueCommentSerializer(data=request.data) serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
serializer.save( serializer.save(
project_id=project_id, project_id=project_id,
issue_id=issue_id, issue_id=issue_id,
actor=request.user, actor=request.user,
access=access, access="EXTERNAL",
) )
issue_activity.delay( issue_activity.delay(
type="comment.activity.created", type="comment.activity.created",
@ -1526,6 +1592,16 @@ class IssueCommentPublicViewSet(BaseViewSet):
project_id=str(project_id), project_id=str(project_id),
current_instance=None, current_instance=None,
) )
if not ProjectMember.objects.filter(
project_id=project_id,
member=request.user,
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
member=request.user,
)
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 Exception as e: except Exception as e:
@ -1570,7 +1646,8 @@ class IssueCommentPublicViewSet(BaseViewSet):
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist): except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
return Response( return Response(
{"error": "IssueComent Does not exists"}, {"error": "IssueComent Does not exists"},
status=status.HTTP_400_BAD_REQUEST,) status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, issue_id, pk): def destroy(self, request, slug, project_id, issue_id, pk):
try: try:
@ -1651,6 +1728,23 @@ class IssueReactionPublicViewSet(BaseViewSet):
serializer.save( serializer.save(
project_id=project_id, issue_id=issue_id, actor=request.user project_id=project_id, issue_id=issue_id, actor=request.user
) )
if not ProjectMember.objects.filter(
project_id=project_id,
member=request.user,
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
member=request.user,
)
issue_activity.delay(
type="issue_reaction.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
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 ProjectDeployBoard.DoesNotExist: except ProjectDeployBoard.DoesNotExist:
@ -1682,6 +1776,19 @@ class IssueReactionPublicViewSet(BaseViewSet):
reaction=reaction_code, reaction=reaction_code,
actor=request.user, actor=request.user,
) )
issue_activity.delay(
type="issue_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(issue_reaction.id),
}
),
)
issue_reaction.delete() issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
except IssueReaction.DoesNotExist: except IssueReaction.DoesNotExist:
@ -1736,8 +1843,29 @@ class CommentReactionPublicViewSet(BaseViewSet):
serializer.save( serializer.save(
project_id=project_id, comment_id=comment_id, actor=request.user project_id=project_id, comment_id=comment_id, actor=request.user
) )
if not ProjectMember.objects.filter(
project_id=project_id, member=request.user
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
member=request.user,
)
issue_activity.delay(
type="comment_reaction.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
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 IssueComment.DoesNotExist:
return Response(
{"error": "Comment does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except ProjectDeployBoard.DoesNotExist: except ProjectDeployBoard.DoesNotExist:
return Response( return Response(
{"error": "Project board does not exist"}, {"error": "Project board does not exist"},
@ -1768,6 +1896,20 @@ class CommentReactionPublicViewSet(BaseViewSet):
reaction=reaction_code, reaction=reaction_code,
actor=request.user, actor=request.user,
) )
issue_activity.delay(
type="comment_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(comment_reaction.id),
"comment_id": str(comment_id)
}
),
)
comment_reaction.delete() comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
except CommentReaction.DoesNotExist: except CommentReaction.DoesNotExist:
@ -1802,8 +1944,25 @@ class IssueVotePublicViewSet(BaseViewSet):
actor_id=request.user.id, actor_id=request.user.id,
project_id=project_id, project_id=project_id,
issue_id=issue_id, issue_id=issue_id,
vote=request.data.get("vote", 1),
) )
# Add the user for workspace tracking
if not ProjectMember.objects.filter(
project_id=project_id, member=request.user
).exists():
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
member=request.user,
)
issue_vote.vote = request.data.get("vote", 1)
issue_vote.save()
issue_activity.delay(
type="issue_vote.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
serializer = IssueVoteSerializer(issue_vote) serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e: except Exception as e:
@ -1821,6 +1980,19 @@ class IssueVotePublicViewSet(BaseViewSet):
issue_id=issue_id, issue_id=issue_id,
actor_id=request.user.id, actor_id=request.user.id,
) )
issue_activity.delay(
type="issue_vote.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"vote": str(issue_vote.vote),
"identifier": str(issue_vote.id),
}
),
)
issue_vote.delete() issue_vote.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e: except Exception as e:
@ -1923,3 +2095,174 @@ class IssueRelationViewSet(BaseViewSet):
.select_related("issue") .select_related("issue")
.distinct() .distinct()
) )
class IssueRetrievePublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id, issue_id):
try:
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=issue_id
)
serializer = IssuePublicSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
except Issue.DoesNotExist:
return Response(
{"error": "Issue Does not exist"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
print(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
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")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssuePublicSerializer(issue_queryset, many=True).data
states = State.objects.filter(
workspace__slug=slug, project_id=project_id
).values("name", "group", "color", "id")
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
issues = group_results(issues, group_by)
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND
)
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

@ -10,7 +10,13 @@ from plane.utils.paginator import BasePaginator
# Module imports # Module imports
from .base import BaseViewSet, BaseAPIView from .base import BaseViewSet, BaseAPIView
from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue, WorkspaceMember from plane.db.models import (
Notification,
IssueAssignee,
IssueSubscriber,
Issue,
WorkspaceMember,
)
from plane.api.serializers import NotificationSerializer from plane.api.serializers import NotificationSerializer
@ -83,13 +89,17 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
# Created issues # Created issues
if type == "created": if type == "created":
if WorkspaceMember.objects.filter(workspace__slug=slug, member=request.user, role__lt=15).exists(): if WorkspaceMember.objects.filter(
workspace__slug=slug, member=request.user, role__lt=15
).exists():
notifications = Notification.objects.none() notifications = Notification.objects.none()
else: else:
issue_ids = Issue.objects.filter( issue_ids = Issue.objects.filter(
workspace__slug=slug, created_by=request.user workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True) ).values_list("pk", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids) notifications = notifications.filter(
entity_identifier__in=issue_ids
)
# Pagination # Pagination
if request.GET.get("per_page", False) and request.GET.get("cursor", False): if request.GET.get("per_page", False) and request.GET.get("cursor", False):
@ -274,3 +284,80 @@ class UnreadNotificationEndpoint(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 MarkAllReadNotificationViewSet(BaseViewSet):
def create(self, request, slug):
try:
snoozed = request.data.get("snoozed", False)
archived = request.data.get("archived", False)
type = request.data.get("type", "all")
notifications = (
Notification.objects.filter(
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
)
.select_related("workspace", "project", "triggered_by", "receiver")
.order_by("snoozed_till", "-created_at")
)
# Filter for snoozed notifications
if snoozed:
notifications = notifications.filter(
Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False)
)
else:
notifications = notifications.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
)
# Filter for archived or unarchive
if archived:
notifications = notifications.filter(archived_at__isnull=False)
else:
notifications = notifications.filter(archived_at__isnull=True)
# Subscribed issues
if type == "watching":
issue_ids = IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id
).values_list("issue_id", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
# Assigned Issues
if type == "assigned":
issue_ids = IssueAssignee.objects.filter(
workspace__slug=slug, assignee_id=request.user.id
).values_list("issue_id", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
# Created issues
if type == "created":
if WorkspaceMember.objects.filter(
workspace__slug=slug, member=request.user, role__lt=15
).exists():
notifications = Notification.objects.none()
else:
issue_ids = Issue.objects.filter(
workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True)
notifications = notifications.filter(
entity_identifier__in=issue_ids
)
updated_notifications = []
for notification in notifications:
notification.read_at = timezone.now()
updated_notifications.append(notification)
Notification.objects.bulk_update(
updated_notifications, ["read_at"], batch_size=100
)
return Response({"message": "Successful"}, 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

@ -1143,154 +1143,6 @@ class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
) )
class ProjectDeployBoardIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
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")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
states = State.objects.filter(
workspace__slug=slug, project_id=project_id
).values("name", "group", "color", "id")
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
issues = group_results(issues, group_by)
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
permission_classes = [AllowAny,] permission_classes = [AllowAny,]

View File

@ -137,11 +137,11 @@ class UpdateUserTourCompletedEndpoint(BaseAPIView):
class UserActivityEndpoint(BaseAPIView, BasePaginator): class UserActivityEndpoint(BaseAPIView, BasePaginator):
def get(self, request): def get(self, request, slug):
try: try:
queryset = IssueActivity.objects.filter(actor=request.user).select_related( queryset = IssueActivity.objects.filter(
"actor", "workspace", "issue", "project" actor=request.user, workspace__slug=slug
) ).select_related("actor", "workspace", "issue", "project")
return self.paginate( return self.paginate(
request=request, request=request,

View File

@ -1100,7 +1100,6 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
created_issues = ( created_issues = (
Issue.issue_objects.filter( Issue.issue_objects.filter(
workspace__slug=slug, workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
created_by_id=user_id, created_by_id=user_id,
) )
@ -1198,6 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
projects = request.query_params.getlist("project", []) projects = request.query_params.getlist("project", [])
queryset = IssueActivity.objects.filter( queryset = IssueActivity.objects.filter(
~Q(field__in=["comment", "vote", "reaction"]),
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
actor=user_id, actor=user_id,

View File

@ -4,6 +4,7 @@ import io
import json import json
import boto3 import boto3
import zipfile import zipfile
from urllib.parse import urlparse, urlunparse
# Django imports # Django imports
from django.conf import settings from django.conf import settings
@ -23,9 +24,11 @@ def dateTimeConverter(time):
if time: if time:
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z") return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
def dateConverter(time): def dateConverter(time):
if time: if time:
return time.strftime("%a, %d %b %Y") return time.strftime("%a, %d %b %Y")
def create_csv_file(data): def create_csv_file(data):
csv_buffer = io.StringIO() csv_buffer = io.StringIO()
@ -66,28 +69,53 @@ def create_zip_file(files):
def upload_to_s3(zip_file, workspace_id, token_id, slug): def upload_to_s3(zip_file, workspace_id, token_id, slug):
s3 = boto3.client(
"s3",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip" file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
s3.upload_fileobj(
zip_file,
settings.AWS_S3_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
expires_in = 7 * 24 * 60 * 60 expires_in = 7 * 24 * 60 * 60
presigned_url = s3.generate_presigned_url(
"get_object", if settings.DOCKERIZED and settings.USE_MINIO:
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name}, s3 = boto3.client(
ExpiresIn=expires_in, "s3",
) endpoint_url=settings.AWS_S3_ENDPOINT_URL,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
s3.upload_fileobj(
zip_file,
settings.AWS_STORAGE_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)
# Create the new url with updated domain and protocol
presigned_url = presigned_url.replace(
"http://plane-minio:9000/uploads/",
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/",
)
else:
s3 = boto3.client(
"s3",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
s3.upload_fileobj(
zip_file,
settings.AWS_S3_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)
exporter_instance = ExporterHistory.objects.get(token=token_id) exporter_instance = ExporterHistory.objects.get(token=token_id)
@ -98,7 +126,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
else: else:
exporter_instance.status = "failed" exporter_instance.status = "failed"
exporter_instance.save(update_fields=["status", "url","key"]) exporter_instance.save(update_fields=["status", "url", "key"])
def generate_table_row(issue): def generate_table_row(issue):
@ -145,7 +173,7 @@ def generate_json_row(issue):
else "", else "",
"Labels": issue["labels__name"], "Labels": issue["labels__name"],
"Cycle Name": issue["issue_cycle__cycle__name"], "Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]), "Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]), "Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"], "Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]), "Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
@ -242,7 +270,9 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
workspace_issues = ( workspace_issues = (
( (
Issue.objects.filter( Issue.objects.filter(
workspace__id=workspace_id, project_id__in=project_ids workspace__id=workspace_id,
project_id__in=project_ids,
project__project_projectmember__member=exporter_instance.initiated_by_id,
) )
.select_related("project", "workspace", "state", "parent", "created_by") .select_related("project", "workspace", "state", "parent", "created_by")
.prefetch_related( .prefetch_related(
@ -275,7 +305,7 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
"labels__name", "labels__name",
) )
) )
.order_by("project__identifier","sequence_id") .order_by("project__identifier", "sequence_id")
.distinct() .distinct()
) )
# CSV header # CSV header
@ -338,7 +368,6 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
exporter_instance.status = "failed" exporter_instance.status = "failed"
exporter_instance.reason = str(e) exporter_instance.reason = str(e)
exporter_instance.save(update_fields=["status", "reason"]) exporter_instance.save(update_fields=["status", "reason"])
# Print logs if in DEBUG mode # Print logs if in DEBUG mode
if settings.DEBUG: if settings.DEBUG:
print(e) print(e)

View File

@ -21,18 +21,29 @@ def delete_old_s3_link():
expired_exporter_history = ExporterHistory.objects.filter( expired_exporter_history = ExporterHistory.objects.filter(
Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8)) Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8))
).values_list("key", "id") ).values_list("key", "id")
if settings.DOCKERIZED and settings.USE_MINIO:
s3 = boto3.client( s3 = boto3.client(
"s3", "s3",
region_name="ap-south-1", endpoint_url=settings.AWS_S3_ENDPOINT_URL,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID, aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"), config=Config(signature_version="s3v4"),
) )
else:
s3 = boto3.client(
"s3",
region_name="ap-south-1",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
for file_name, exporter_id in expired_exporter_history: for file_name, exporter_id in expired_exporter_history:
# Delete object from S3 # Delete object from S3
if file_name: if file_name:
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name) if settings.DOCKERIZED and settings.USE_MINIO:
s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name)
else:
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
ExporterHistory.objects.filter(id=exporter_id).update(url=None) ExporterHistory.objects.filter(id=exporter_id).update(url=None)

View File

@ -24,6 +24,9 @@ from plane.db.models import (
IssueSubscriber, IssueSubscriber,
Notification, Notification,
IssueAssignee, IssueAssignee,
IssueReaction,
CommentReaction,
IssueComment,
) )
from plane.api.serializers import IssueActivitySerializer from plane.api.serializers import IssueActivitySerializer
@ -505,7 +508,7 @@ def update_issue_activity(
"parent": track_parent, "parent": track_parent,
"priority": track_priority, "priority": track_priority,
"state": track_state, "state": track_state,
"description": track_description, "description_html": track_description,
"target_date": track_target_date, "target_date": track_target_date,
"start_date": track_start_date, "start_date": track_start_date,
"labels_list": track_labels, "labels_list": track_labels,
@ -896,6 +899,150 @@ def delete_attachment_activity(
) )
) )
def create_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None:
issue_reaction = IssueReaction.objects.filter(reaction=requested_data.get("reaction"), project=project, actor=actor).values_list('id', flat=True).first()
if issue_reaction is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="created",
old_value=None,
new_value=requested_data.get("reaction"),
field="reaction",
project=project,
workspace=project.workspace,
comment="added the reaction",
old_identifier=None,
new_identifier=issue_reaction,
)
)
def delete_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance and current_instance.get("reaction") is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="deleted",
old_value=current_instance.get("reaction"),
new_value=None,
field="reaction",
project=project,
workspace=project.workspace,
comment="removed the reaction",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
)
)
def create_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None:
comment_reaction_id, comment_id = CommentReaction.objects.filter(reaction=requested_data.get("reaction"), project=project, actor=actor).values_list('id', 'comment__id').first()
comment = IssueComment.objects.get(pk=comment_id,project=project)
if comment is not None and comment_reaction_id is not None and comment_id is not None:
issue_activities.append(
IssueActivity(
issue_id=comment.issue_id,
actor=actor,
verb="created",
old_value=None,
new_value=requested_data.get("reaction"),
field="reaction",
project=project,
workspace=project.workspace,
comment="added the reaction",
old_identifier=None,
new_identifier=comment_reaction_id,
)
)
def delete_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance and current_instance.get("reaction") is not None:
issue_id = IssueComment.objects.filter(pk=current_instance.get("comment_id"), project=project).values_list('issue_id', flat=True).first()
if issue_id is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="deleted",
old_value=current_instance.get("reaction"),
new_value=None,
field="reaction",
project=project,
workspace=project.workspace,
comment="removed the reaction",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
)
)
def create_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("vote") is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="created",
old_value=None,
new_value=requested_data.get("vote"),
field="vote",
project=project,
workspace=project.workspace,
comment="added the vote",
old_identifier=None,
new_identifier=None,
)
)
def delete_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance and current_instance.get("vote") is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="deleted",
old_value=current_instance.get("vote"),
new_value=None,
field="vote",
project=project,
workspace=project.workspace,
comment="removed the vote",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
)
)
def create_issue_relation_activity( def create_issue_relation_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities
@ -970,6 +1117,12 @@ def issue_activity(
"cycle.activity.deleted", "cycle.activity.deleted",
"module.activity.created", "module.activity.created",
"module.activity.deleted", "module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
]: ]:
issue = Issue.objects.filter(pk=issue_id).first() issue = Issue.objects.filter(pk=issue_id).first()
@ -1007,6 +1160,12 @@ def issue_activity(
"attachment.activity.deleted": delete_attachment_activity, "attachment.activity.deleted": delete_attachment_activity,
"issue_relation.activity.created": create_issue_relation_activity, "issue_relation.activity.created": create_issue_relation_activity,
"issue_relation.activity.deleted": delete_issue_relation_activity, "issue_relation.activity.deleted": delete_issue_relation_activity,
"issue_reaction.activity.created": create_issue_reaction_activity,
"issue_reaction.activity.deleted": delete_issue_reaction_activity,
"comment_reaction.activity.created": create_comment_reaction_activity,
"comment_reaction.activity.deleted": delete_comment_reaction_activity,
"issue_vote.activity.created": create_issue_vote_activity,
"issue_vote.activity.deleted": delete_issue_vote_activity,
} }
func = ACTIVITY_MAPPER.get(type) func = ACTIVITY_MAPPER.get(type)
@ -1046,6 +1205,12 @@ def issue_activity(
"cycle.activity.deleted", "cycle.activity.deleted",
"module.activity.created", "module.activity.created",
"module.activity.deleted", "module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
]: ]:
# Create Notifications # Create Notifications
bulk_notifications = [] bulk_notifications = []

View File

@ -64,7 +64,7 @@ def archive_old_issues():
issues_to_update.append(issue) issues_to_update.append(issue)
# Bulk Update the issues and log the activity # Bulk Update the issues and log the activity
Issue.objects.bulk_update( updated_issues = Issue.objects.bulk_update(
issues_to_update, ["archived_at"], batch_size=100 issues_to_update, ["archived_at"], batch_size=100
) )
[ [
@ -77,7 +77,7 @@ def archive_old_issues():
current_instance=None, current_instance=None,
subscriber=False, subscriber=False,
) )
for issue in issues_to_update for issue in updated_issues
] ]
return return
except Exception as e: except Exception as e:
@ -136,7 +136,7 @@ def close_old_issues():
issues_to_update.append(issue) issues_to_update.append(issue)
# Bulk Update the issues and log the activity # Bulk Update the issues and log the activity
Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
[ [
issue_activity.delay( issue_activity.delay(
type="issue.activity.updated", type="issue.activity.updated",
@ -147,7 +147,7 @@ def close_old_issues():
current_instance=None, current_instance=None,
subscriber=False, subscriber=False,
) )
for issue in issues_to_update for issue in updated_issues
] ]
return return
except Exception as e: except Exception as e:

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.3 on 2023-08-29 07:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0041_cycle_sort_order_issuecomment_access_and_more'),
]
operations = [
migrations.AlterUniqueTogether(
name='issuevote',
unique_together=set(),
),
migrations.AlterField(
model_name='issuevote',
name='vote',
field=models.IntegerField(choices=[(-1, 'DOWNVOTE'), (1, 'UPVOTE')], default=1),
),
migrations.AlterUniqueTogether(
name='issuevote',
unique_together={('issue', 'actor', 'vote')},
),
]

View File

@ -19,6 +19,7 @@ from .project import (
ProjectIdentifier, ProjectIdentifier,
ProjectFavorite, ProjectFavorite,
ProjectDeployBoard, ProjectDeployBoard,
ProjectPublicMember,
) )
from .issue import ( from .issue import (

View File

@ -507,10 +507,12 @@ class IssueVote(ProjectBaseModel):
choices=( choices=(
(-1, "DOWNVOTE"), (-1, "DOWNVOTE"),
(1, "UPVOTE"), (1, "UPVOTE"),
) ),
default=1,
) )
class Meta: class Meta:
unique_together = ["issue", "actor"] unique_together = ["issue", "actor", "vote"]
verbose_name = "Issue Vote" verbose_name = "Issue Vote"
verbose_name_plural = "Issue Votes" verbose_name_plural = "Issue Votes"
db_table = "issue_votes" db_table = "issue_votes"

View File

@ -254,3 +254,18 @@ class ProjectDeployBoard(ProjectBaseModel):
def __str__(self): def __str__(self):
"""Return project and anchor""" """Return project and anchor"""
return f"{self.anchor} <{self.project.name}>" return f"{self.anchor} <{self.project.name}>"
class ProjectPublicMember(ProjectBaseModel):
member = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="public_project_members",
)
class Meta:
unique_together = ["project", "member"]
verbose_name = "Project Public Member"
verbose_name_plural = "Project Public Members"
db_table = "project_public_members"
ordering = ("-created_at",)

View File

@ -2,6 +2,7 @@
import uuid import uuid
import string import string
import random import random
import pytz
# Django imports # Django imports
from django.db import models from django.db import models
@ -9,9 +10,6 @@ from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.contrib.auth.models import AbstractBaseUser, UserManager, PermissionsMixin from django.contrib.auth.models import AbstractBaseUser, UserManager, PermissionsMixin
from django.utils import timezone from django.utils import timezone
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings from django.conf import settings
# Third party imports # Third party imports
@ -66,7 +64,8 @@ class User(AbstractBaseUser, PermissionsMixin):
billing_address = models.JSONField(null=True) billing_address = models.JSONField(null=True)
has_billing_address = models.BooleanField(default=False) has_billing_address = models.BooleanField(default=False)
user_timezone = models.CharField(max_length=255, default="Asia/Kolkata") USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
user_timezone = models.CharField(max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES)
last_active = models.DateTimeField(default=timezone.now, null=True) last_active = models.DateTimeField(default=timezone.now, null=True)
last_login_time = models.DateTimeField(null=True) last_login_time = models.DateTimeField(null=True)

View File

@ -49,7 +49,7 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"crum.CurrentRequestUserMiddleware", "crum.CurrentRequestUserMiddleware",
"django.middleware.gzip.GZipMiddleware", "django.middleware.gzip.GZipMiddleware",
] ]
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ( "DEFAULT_AUTHENTICATION_CLASSES": (
@ -161,7 +161,7 @@ MEDIA_URL = "/media/"
LANGUAGE_CODE = "en-us" LANGUAGE_CODE = "en-us"
TIME_ZONE = "Asia/Kolkata" TIME_ZONE = "UTC"
USE_I18N = True USE_I18N = True

View File

@ -665,7 +665,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
className="focus:outline-none" className="focus:outline-none"
> >
<div className="flex items-center gap-2 text-custom-text-200"> <div className="flex items-center gap-2 text-custom-text-200">
<DiscordIcon className="h-4 w-4" color="#6b7280" /> <DiscordIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Join our Discord Join our Discord
</div> </div>
</Command.Item> </Command.Item>

View File

@ -113,43 +113,41 @@ export const IssuesFilterView: React.FC = () => {
))} ))}
</div> </div>
)} )}
{issueView !== "gantt_chart" && ( <SelectFilters
<SelectFilters filters={filters}
filters={filters} onSelect={(option) => {
onSelect={(option) => { const key = option.key as keyof typeof filters;
const key = option.key as keyof typeof filters;
if (key === "start_date" || key === "target_date") { if (key === "start_date" || key === "target_date") {
const valueExists = checkIfArraysHaveSameElements(filters[key] ?? [], option.value); const valueExists = checkIfArraysHaveSameElements(filters[key] ?? [], option.value);
setFilters({ setFilters({
[key]: valueExists ? null : option.value, [key]: valueExists ? null : option.value,
}); });
} else { } else {
const valueExists = filters[key]?.includes(option.value); const valueExists = filters[key]?.includes(option.value);
if (valueExists) if (valueExists)
setFilters( setFilters(
{ {
[option.key]: ((filters[key] ?? []) as any[])?.filter( [option.key]: ((filters[key] ?? []) as any[])?.filter(
(val) => val !== option.value (val) => val !== option.value
), ),
}, },
!Boolean(viewId) !Boolean(viewId)
); );
else else
setFilters( setFilters(
{ {
[option.key]: [...((filters[key] ?? []) as any[]), option.value], [option.key]: [...((filters[key] ?? []) as any[]), option.value],
}, },
!Boolean(viewId) !Boolean(viewId)
); );
} }
}} }}
direction="left" direction="left"
height="rg" height="rg"
/> />
)}
<Popover className="relative"> <Popover className="relative">
{({ open }) => ( {({ open }) => (
<> <>

View File

@ -1,7 +1,5 @@
import React, { useEffect, useState, forwardRef, useRef } from "react"; import React, { useEffect, useState, forwardRef, useRef } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// react-hook-form // react-hook-form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// services // services
@ -12,9 +10,10 @@ import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
// ui // ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui"; import { Input, PrimaryButton, SecondaryButton } from "components/ui";
import { TipTapEditor } from "components/tiptap";
// types
import { IIssue, IPageBlock } from "types"; import { IIssue, IPageBlock } from "types";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
@ -32,12 +31,6 @@ type FormData = {
task: string; task: string;
}; };
const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
(props, ref) => <Tiptap {...props} forwardedRef={ref} />
);
TiptapEditor.displayName = "TiptapEditor";
export const GptAssistantModal: React.FC<Props> = ({ export const GptAssistantModal: React.FC<Props> = ({
isOpen, isOpen,
handleClose, handleClose,
@ -140,13 +133,14 @@ export const GptAssistantModal: React.FC<Props> = ({
return ( return (
<div <div
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${isOpen ? "block" : "hidden" className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${
}`} isOpen ? "block" : "hidden"
}`}
> >
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && ( {((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
<div className="text-sm"> <div className="text-sm">
Content: Content:
<TiptapEditor <TipTapEditor
workspaceSlug={workspaceSlug as string} workspaceSlug={workspaceSlug as string}
value={htmlContent ?? `<p>${content}</p>`} value={htmlContent ?? `<p>${content}</p>`}
customClassName="-m-3" customClassName="-m-3"
@ -160,7 +154,7 @@ export const GptAssistantModal: React.FC<Props> = ({
{response !== "" && ( {response !== "" && (
<div className="page-block-section text-sm"> <div className="page-block-section text-sm">
Response: Response:
<Tiptap <TipTapEditor
workspaceSlug={workspaceSlug as string} workspaceSlug={workspaceSlug as string}
value={`<p>${response}</p>`} value={`<p>${response}</p>`}
customClassName="-mx-3 -my-3" customClassName="-mx-3 -my-3"
@ -180,10 +174,11 @@ export const GptAssistantModal: React.FC<Props> = ({
type="text" type="text"
name="task" name="task"
register={register} register={register}
placeholder={`${content && content !== "" placeholder={`${
content && content !== ""
? "Tell AI what action to perform on this content..." ? "Tell AI what action to perform on this content..."
: "Ask AI anything..." : "Ask AI anything..."
}`} }`}
autoComplete="off" autoComplete="off"
/> />
<div className={`flex gap-2 ${response === "" ? "justify-end" : "justify-between"}`}> <div className={`flex gap-2 ${response === "" ? "justify-end" : "justify-between"}`}>
@ -219,8 +214,8 @@ export const GptAssistantModal: React.FC<Props> = ({
{isSubmitting {isSubmitting
? "Generating response..." ? "Generating response..."
: response === "" : response === ""
? "Generate response" ? "Generate response"
: "Generate again"} : "Generate again"}
</PrimaryButton> </PrimaryButton>
</div> </div>
</div> </div>

View File

@ -114,7 +114,10 @@ export const AllViews: React.FC<Props> = ({
)} )}
</StrictModeDroppable> </StrictModeDroppable>
{groupedIssues ? ( {groupedIssues ? (
!isEmpty || issueView === "kanban" || issueView === "calendar" ? ( !isEmpty ||
issueView === "kanban" ||
issueView === "calendar" ||
issueView === "gantt_chart" ? (
<> <>
{issueView === "list" ? ( {issueView === "list" ? (
<AllLists <AllLists

View File

@ -6,7 +6,6 @@ import { mutate } from "swr";
// components // components
import { import {
IssuePeekOverview,
ViewAssigneeSelect, ViewAssigneeSelect,
ViewDueDateSelect, ViewDueDateSelect,
ViewEstimateSelect, ViewEstimateSelect,
@ -76,9 +75,6 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
// issue peek overview
const [issuePeekOverview, setIssuePeekOverview] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
@ -161,6 +157,15 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
[workspaceSlug, projectId, cycleId, moduleId, viewId, params, user] [workspaceSlug, projectId, cycleId, moduleId, viewId, params, user]
); );
const openPeekOverview = () => {
const { query } = router;
router.push({
pathname: router.pathname,
query: { ...query, peekIssue: issue.id },
});
};
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 : "";
@ -183,15 +188,6 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
return ( return (
<> <>
<IssuePeekOverview
handleDeleteIssue={() => handleDeleteIssue(issue)}
handleUpdateIssue={async (formData) => partialUpdateIssue(formData, issue)}
issue={issue}
isOpen={issuePeekOverview}
onClose={() => setIssuePeekOverview(false)}
workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={isNotAllowed}
/>
<div <div
className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max" className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max"
style={{ gridTemplateColumns }} style={{ gridTemplateColumns }}
@ -280,7 +276,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
<button <button
type="button" type="button"
className="truncate text-custom-text-100 text-left cursor-pointer w-full text-[0.825rem]" className="truncate text-custom-text-100 text-left cursor-pointer w-full text-[0.825rem]"
onClick={() => setIssuePeekOverview(true)} onClick={openPeekOverview}
> >
{issue.name} {issue.name}
</button> </button>

View File

@ -6,6 +6,7 @@ import { useRouter } from "next/router";
// components // components
import { SpreadsheetColumns, SpreadsheetIssues } from "components/core"; import { SpreadsheetColumns, SpreadsheetIssues } from "components/core";
import { CustomMenu, Spinner } from "components/ui"; import { CustomMenu, Spinner } from "components/ui";
import { IssuePeekOverview } from "components/issues";
// hooks // hooks
import useIssuesProperties from "hooks/use-issue-properties"; import useIssuesProperties from "hooks/use-issue-properties";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
@ -38,7 +39,7 @@ export const SpreadsheetView: React.FC<Props> = ({
const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
const { spreadsheetIssues } = useSpreadsheetIssuesView(); const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView();
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
@ -59,80 +60,88 @@ export const SpreadsheetView: React.FC<Props> = ({
.join(" "); .join(" ");
return ( return (
<div className="h-full rounded-lg text-custom-text-200 overflow-x-auto whitespace-nowrap bg-custom-background-100"> <>
<div className="sticky z-[2] top-0 border-b border-custom-border-200 bg-custom-background-90 w-full min-w-max"> <IssuePeekOverview
<SpreadsheetColumns columnData={columnData} gridTemplateColumns={gridTemplateColumns} /> handleMutation={() => mutateIssues()}
</div> projectId={projectId?.toString() ?? ""}
{spreadsheetIssues ? ( workspaceSlug={workspaceSlug?.toString() ?? ""}
<div className="flex flex-col h-full w-full bg-custom-background-100 rounded-sm "> readOnly={disableUserActions}
{spreadsheetIssues.map((issue: IIssue, index) => ( />
<SpreadsheetIssues <div className="h-full rounded-lg text-custom-text-200 overflow-x-auto whitespace-nowrap bg-custom-background-100">
key={`${issue.id}_${index}`} <div className="sticky z-[2] top-0 border-b border-custom-border-200 bg-custom-background-90 w-full min-w-max">
index={index} <SpreadsheetColumns columnData={columnData} gridTemplateColumns={gridTemplateColumns} />
issue={issue}
expandedIssues={expandedIssues}
setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns}
properties={properties}
handleIssueAction={handleIssueAction}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
/>
))}
<div
className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max"
style={{ gridTemplateColumns }}
>
{type === "issue" ? (
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
) : (
!disableUserActions && (
<CustomMenu
className="sticky left-0 z-[1]"
customButton={
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full"
type="button"
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
}
position="left"
optionsClassName="left-5 !w-36"
noBorder
>
<CustomMenu.MenuItem
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)
)}
</div>
</div> </div>
) : ( {spreadsheetIssues ? (
<Spinner /> <div className="flex flex-col h-full w-full bg-custom-background-100 rounded-sm ">
)} {spreadsheetIssues.map((issue: IIssue, index) => (
</div> <SpreadsheetIssues
key={`${issue.id}_${index}`}
index={index}
issue={issue}
expandedIssues={expandedIssues}
setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns}
properties={properties}
handleIssueAction={handleIssueAction}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
/>
))}
<div
className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max"
style={{ gridTemplateColumns }}
>
{type === "issue" ? (
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
) : (
!disableUserActions && (
<CustomMenu
className="sticky left-0 z-[1]"
customButton={
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full"
type="button"
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
}
position="left"
optionsClassName="left-5 !w-36"
noBorder
>
<CustomMenu.MenuItem
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)
)}
</div>
</div>
) : (
<Spinner />
)}
</div>
</>
); );
}; };

View File

@ -190,7 +190,7 @@ export const CyclesView: React.FC<Props> = ({ cycles, mutateCycles, viewType })
))} ))}
</div> </div>
) : viewType === "board" ? ( ) : viewType === "board" ? (
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-9 lg:grid-cols-2 xl:grid-cols-3">
{cycles.map((cycle) => ( {cycles.map((cycle) => (
<SingleCycleCard <SingleCycleCard
key={cycle.id} key={cycle.id}

View File

@ -0,0 +1,83 @@
import { useRouter } from "next/router";
// ui
import { Tooltip } from "components/ui";
// icons
import { ContrastIcon } from "components/icons";
// helpers
import { getDateRangeStatus, renderShortDate } from "helpers/date-time.helper";
// types
import { ICycle } from "types";
export const CycleGanttBlock = ({ data }: { data: ICycle }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const cycleStatus = getDateRangeStatus(data?.start_date, data?.end_date);
return (
<div
className="flex items-center relative h-full w-full rounded"
style={{
backgroundColor:
cycleStatus === "current"
? "#09a953"
: cycleStatus === "upcoming"
? "#f7ae59"
: cycleStatus === "completed"
? "#3f76ff"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: "",
}}
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/cycles/${data?.id}`)}
>
<div className="absolute top-0 left-0 h-full w-full bg-custom-background-100/50" />
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{data?.name}</h5>
<div>
{renderShortDate(data?.start_date ?? "")} to {renderShortDate(data?.end_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="relative text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{data?.name}
</div>
</Tooltip>
</div>
);
};
export const CycleGanttSidebarBlock = ({ data }: { data: ICycle }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const cycleStatus = getDateRangeStatus(data?.start_date, data?.end_date);
return (
<div
className="relative w-full flex items-center gap-2 h-full"
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/cycles/${data?.id}`)}
>
<ContrastIcon
className="h-5 w-5 flex-shrink-0"
color={`${
cycleStatus === "current"
? "#09a953"
: cycleStatus === "upcoming"
? "#f7ae59"
: cycleStatus === "completed"
? "#3f76ff"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
<h6 className="text-sm font-medium flex-grow truncate">{data?.name}</h6>
</div>
);
};

View File

@ -6,11 +6,8 @@ import useUser from "hooks/use-user";
import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view"; import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view";
import { updateGanttIssue } from "components/gantt-chart/hooks/block-update"; import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
// components // components
import { import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
GanttChartRoot, import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
IssueGanttBlock,
renderIssueBlocksStructure,
} from "components/gantt-chart";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
@ -28,29 +25,20 @@ export const CycleIssuesGanttChartView = () => {
cycleId as string cycleId as string
); );
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
return ( return (
<div className="w-full h-full p-3"> <div className="w-full h-full">
<GanttChartRoot <GanttChartRoot
title="Cycles" border={false}
loaderTitle="Cycles" title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null} blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={(block, payload) => blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
} }
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />} SidebarBlockRender={IssueGanttSidebarBlock}
blockRender={(data: any) => <IssueGanttBlock issue={data as IIssue} />} BlockRender={IssueGanttBlock}
enableReorder={orderBy === "sort_order"} enableReorder={orderBy === "sort_order"}
bottomSpacing
/> />
</div> </div>
); );

View File

@ -9,7 +9,8 @@ import cyclesService from "services/cycles.service";
// hooks // hooks
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
// components // components
import { CycleGanttBlock, GanttChartRoot, IBlockUpdateData } from "components/gantt-chart"; import { GanttChartRoot, IBlockUpdateData } from "components/gantt-chart";
import { CycleGanttBlock, CycleGanttSidebarBlock } from "components/cycles";
// types // types
import { ICycle } from "types"; import { ICycle } from "types";
@ -24,17 +25,6 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles, mutateCycles }) =>
const { user } = useUser(); const { user } = useUser();
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => { const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => {
if (!workspaceSlug || !user) return; if (!workspaceSlug || !user) return;
@ -88,10 +78,11 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles, mutateCycles }) =>
loaderTitle="Cycles" loaderTitle="Cycles"
blocks={cycles ? blockFormat(cycles) : null} blocks={cycles ? blockFormat(cycles) : null}
blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)} blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />} SidebarBlockRender={CycleGanttSidebarBlock}
blockRender={(data: any) => <CycleGanttBlock cycle={data as ICycle} />} BlockRender={CycleGanttBlock}
enableLeftDrag={false} enableBlockLeftResize={false}
enableRightDrag={false} enableBlockRightResize={false}
enableBlockMove={false}
/> />
</div> </div>
); );

View File

@ -0,0 +1,3 @@
export * from "./blocks";
export * from "./cycle-issues-layout";
export * from "./cycles-list-layout";

View File

@ -1,11 +1,10 @@
export * from "./cycles-list"; export * from "./cycles-list";
export * from "./active-cycle-details"; export * from "./active-cycle-details";
export * from "./active-cycle-stats"; export * from "./active-cycle-stats";
export * from "./cycles-list-gantt-chart"; export * from "./gantt-chart";
export * from "./cycles-view"; export * from "./cycles-view";
export * from "./delete-cycle-modal"; export * from "./delete-cycle-modal";
export * from "./form"; export * from "./form";
export * from "./gantt-chart";
export * from "./modal"; export * from "./modal";
export * from "./select"; export * from "./select";
export * from "./sidebar"; export * from "./sidebar";

View File

@ -106,6 +106,7 @@ function RadialProgressBar({ progress }: progress) {
</div> </div>
); );
} }
export const SingleCycleList: React.FC<TSingleStatProps> = ({ export const SingleCycleList: React.FC<TSingleStatProps> = ({
cycle, cycle,
handleEditCycle, handleEditCycle,

View File

@ -1,6 +1,4 @@
import React from "react"; import React from "react";
// next imports
import Link from "next/link";
// ui // ui
import { PrimaryButton } from "components/ui"; // icons import { PrimaryButton } from "components/ui"; // icons
// helpers // helpers
@ -65,11 +63,11 @@ export const SingleExport: React.FC<Props> = ({ service, refreshing }) => {
<> <>
{service.status == "completed" && ( {service.status == "completed" && (
<div> <div>
<Link href={service?.url}> <a target="_blank" href={service?.url} rel="noopener noreferrer">
<PrimaryButton className="w-full text-center"> <PrimaryButton className="w-full text-center">
{isLoading ? "Downloading..." : "Download"} {isLoading ? "Downloading..." : "Download"}
</PrimaryButton> </PrimaryButton>
</Link> </a>
</div> </div>
)} )}
</> </>

View File

@ -1,103 +0,0 @@
import Link from "next/link";
import { useRouter } from "next/router";
// ui
import { Tooltip } from "components/ui";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
// types
import { ICycle, IIssue, IModule } from "types";
// constants
import { MODULE_STATUS } from "constants/module";
export const IssueGanttBlock = ({ issue }: { issue: IIssue }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
<div
className="flex-shrink-0 w-0.5 h-full"
style={{ backgroundColor: issue.state_detail?.color }}
/>
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{issue.name}</h5>
<div>
{renderShortDate(issue.start_date ?? "")} to{" "}
{renderShortDate(issue.target_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{issue.name}
</div>
</Tooltip>
</a>
</Link>
);
};
export const CycleGanttBlock = ({ cycle }: { cycle: ICycle }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Link href={`/${workspaceSlug}/projects/${cycle.project}/cycles/${cycle.id}`}>
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
<div className="flex-shrink-0 w-0.5 h-full bg-custom-primary-100" />
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{cycle.name}</h5>
<div>
{renderShortDate(cycle.start_date ?? "")} to {renderShortDate(cycle.end_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{cycle.name}
</div>
</Tooltip>
</a>
</Link>
);
};
export const ModuleGanttBlock = ({ module }: { module: IModule }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
<div
className="flex-shrink-0 w-0.5 h-full"
style={{ backgroundColor: MODULE_STATUS.find((s) => s.value === module.status)?.color }}
/>
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{module.name}</h5>
<div>
{renderShortDate(module.start_date ?? "")} to{" "}
{renderShortDate(module.target_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{module.name}
</div>
</Tooltip>
</a>
</Link>
);
};

View File

@ -1,8 +1,7 @@
import { FC } from "react"; import { FC } from "react";
// react-beautiful-dnd // hooks
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd"; import { useChart } from "../hooks";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// helpers // helpers
import { ChartDraggable } from "../helpers/draggable"; import { ChartDraggable } from "../helpers/draggable";
import { renderDateFormat } from "helpers/date-time.helper"; import { renderDateFormat } from "helpers/date-time.helper";
@ -12,90 +11,59 @@ import { IBlockUpdateData, IGanttBlock } from "../types";
export const GanttChartBlocks: FC<{ export const GanttChartBlocks: FC<{
itemsContainerWidth: number; itemsContainerWidth: number;
blocks: IGanttBlock[] | null; blocks: IGanttBlock[] | null;
sidebarBlockRender: FC; BlockRender: React.FC<any>;
blockRender: FC;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
enableLeftDrag: boolean; enableBlockLeftResize: boolean;
enableRightDrag: boolean; enableBlockRightResize: boolean;
enableReorder: boolean; enableBlockMove: boolean;
}> = ({ }> = ({
itemsContainerWidth, itemsContainerWidth,
blocks, blocks,
sidebarBlockRender, BlockRender,
blockRender,
blockUpdateHandler, blockUpdateHandler,
enableLeftDrag, enableBlockLeftResize,
enableRightDrag, enableBlockRightResize,
enableReorder, enableBlockMove,
}) => { }) => {
const handleChartBlockPosition = ( const { activeBlock, dispatch } = useChart();
block: IGanttBlock,
totalBlockShifts: number,
dragDirection: "left" | "right"
) => {
let updatedDate = new Date();
if (dragDirection === "left") { // update the active block on hover
const originalDate = new Date(block.start_date); const updateActiveBlock = (block: IGanttBlock | null) => {
dispatch({
const currentDay = originalDate.getDate(); type: "PARTIAL_UPDATE",
updatedDate = new Date(originalDate); payload: {
activeBlock: block,
updatedDate.setDate(currentDay - totalBlockShifts); },
} else {
const originalDate = new Date(block.target_date);
const currentDay = originalDate.getDate();
updatedDate = new Date(originalDate);
updatedDate.setDate(currentDay + totalBlockShifts);
}
blockUpdateHandler(block.data, {
[dragDirection === "left" ? "start_date" : "target_date"]: renderDateFormat(updatedDate),
}); });
}; };
const handleOrderChange = (result: DropResult) => { const handleChartBlockPosition = (
if (!blocks) return; block: IGanttBlock,
totalBlockShifts: number,
dragDirection: "left" | "right" | "move"
) => {
const originalStartDate = new Date(block.start_date);
const updatedStartDate = new Date(originalStartDate);
const { source, destination, draggableId } = result; const originalTargetDate = new Date(block.target_date);
const updatedTargetDate = new Date(originalTargetDate);
if (!destination) return; // update the start date on left resize
if (dragDirection === "left")
if (source.index === destination.index && document) { updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts);
// const draggedBlock = document.querySelector(`#${draggableId}`) as HTMLElement; // update the target date on right resize
// const blockStyles = window.getComputedStyle(draggedBlock); else if (dragDirection === "right")
updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
// console.log(blockStyles.marginLeft); // update both the dates on x-axis move
else if (dragDirection === "move") {
return; updatedStartDate.setDate(originalStartDate.getDate() + totalBlockShifts);
updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
} }
let updatedSortOrder = blocks[source.index].sort_order; // call the block update handler with the updated dates
blockUpdateHandler(block.data, {
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; start_date: renderDateFormat(updatedStartDate),
else if (destination.index === blocks.length - 1) target_date: renderDateFormat(updatedTargetDate),
updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
}); });
}; };
@ -104,75 +72,29 @@ export const GanttChartBlocks: FC<{
className="relative z-[5] mt-[72px] h-full overflow-hidden overflow-y-auto" className="relative z-[5] mt-[72px] h-full overflow-hidden overflow-y-auto"
style={{ width: `${itemsContainerWidth}px` }} style={{ width: `${itemsContainerWidth}px` }}
> >
<DragDropContext onDragEnd={handleOrderChange}> {blocks &&
<StrictModeDroppable droppableId="gantt"> blocks.length > 0 &&
{(droppableProvided, droppableSnapshot) => ( blocks.map(
<div (block) =>
className="w-full space-y-2" block.start_date &&
ref={droppableProvided.innerRef} block.target_date && (
{...droppableProvided.droppableProps} <div
> key={`block-${block.id}`}
<> className={`h-11 ${activeBlock?.id === block.id ? "bg-custom-background-80" : ""}`}
{blocks && onMouseEnter={() => updateActiveBlock(block)}
blocks.length > 0 && onMouseLeave={() => updateActiveBlock(null)}
blocks.map( >
(block, index: number) => <ChartDraggable
block.start_date && block={block}
block.target_date && ( BlockRender={BlockRender}
<Draggable handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
key={`block-${block.id}`} enableBlockLeftResize={enableBlockLeftResize}
draggableId={`block-${block.id}`} enableBlockRightResize={enableBlockRightResize}
index={index} enableBlockMove={enableBlockMove}
isDragDisabled={!enableReorder} />
> </div>
{(provided) => ( )
<div )}
className={
droppableSnapshot.isDraggingOver ? "bg-custom-border-100/10" : ""
}
ref={provided.innerRef}
{...provided.draggableProps}
>
<ChartDraggable
block={block}
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
enableLeftDrag={enableLeftDrag}
enableRightDrag={enableRightDrag}
provided={provided}
>
<div
className="rounded shadow-sm bg-custom-background-80 overflow-hidden h-9 flex items-center transition-all"
style={{
width: `${block.position?.width}px`,
}}
>
{blockRender({
...block.data,
})}
</div>
</ChartDraggable>
</div>
)}
</Draggable>
)
)}
{droppableProvided.placeholder}
</>
</div>
)}
</StrictModeDroppable>
</DragDropContext>
{/* sidebar */}
{/* <div className="fixed top-0 bottom-0 w-[300px] flex-shrink-0 divide-y divide-custom-border-200 border-r border-custom-border-200 overflow-y-auto">
{blocks &&
blocks.length > 0 &&
blocks.map((block: any, _idx: number) => (
<div className="relative h-[40px] bg-custom-background-100" key={`sidebar-blocks-${_idx}`}>
{sidebarBlockRender(block?.data)}
</div>
))}
</div> */}
</div> </div>
); );
}; };

View File

@ -1,2 +1 @@
export * from "./block";
export * from "./blocks-display"; export * from "./blocks-display";

View File

@ -3,6 +3,7 @@ import { FC, useEffect, useState } from "react";
import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid"; import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid";
// components // components
import { GanttChartBlocks } from "components/gantt-chart"; import { GanttChartBlocks } from "components/gantt-chart";
import { GanttSidebar } from "../sidebar";
// import { HourChartView } from "./hours"; // import { HourChartView } from "./hours";
// import { DayChartView } from "./day"; // import { DayChartView } from "./day";
// import { WeekChartView } from "./week"; // import { WeekChartView } from "./week";
@ -25,7 +26,7 @@ import {
getMonthChartItemPositionWidthInMonth, getMonthChartItemPositionWidthInMonth,
} from "../views"; } from "../views";
// types // types
import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types"; import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types";
// data // data
import { currentViewDataWithView } from "../data"; import { currentViewDataWithView } from "../data";
// context // context
@ -33,15 +34,17 @@ import { useChart } from "../hooks";
type ChartViewRootProps = { type ChartViewRootProps = {
border: boolean; border: boolean;
title: null | string; title: string;
loaderTitle: string; loaderTitle: string;
blocks: IGanttBlock[] | null; blocks: IGanttBlock[] | null;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
sidebarBlockRender: FC<any>; SidebarBlockRender: React.FC<any>;
blockRender: FC<any>; BlockRender: React.FC<any>;
enableLeftDrag: boolean; enableBlockLeftResize: boolean;
enableRightDrag: boolean; enableBlockRightResize: boolean;
enableBlockMove: boolean;
enableReorder: boolean; enableReorder: boolean;
bottomSpacing: boolean;
}; };
export const ChartViewRoot: FC<ChartViewRootProps> = ({ export const ChartViewRoot: FC<ChartViewRootProps> = ({
@ -50,22 +53,24 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
blocks = null, blocks = null,
loaderTitle, loaderTitle,
blockUpdateHandler, blockUpdateHandler,
sidebarBlockRender, SidebarBlockRender,
blockRender, BlockRender,
enableLeftDrag, enableBlockLeftResize,
enableRightDrag, enableBlockRightResize,
enableBlockMove,
enableReorder, enableReorder,
bottomSpacing,
}) => { }) => {
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
const [itemsContainerWidth, setItemsContainerWidth] = useState<number>(0); const [itemsContainerWidth, setItemsContainerWidth] = useState<number>(0);
const [fullScreenMode, setFullScreenMode] = useState<boolean>(false); const [fullScreenMode, setFullScreenMode] = useState<boolean>(false);
const [blocksSidebarView, setBlocksSidebarView] = useState<boolean>(false);
// blocks state management starts // blocks state management starts
const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null); const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null);
const renderBlockStructure = (view: any, blocks: IGanttBlock[]) => const { currentView, currentViewData, renderView, dispatch, allViews, updateScrollLeft } =
useChart();
const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) =>
blocks && blocks.length > 0 blocks && blocks.length > 0
? blocks.map((block: any) => ({ ? blocks.map((block: any) => ({
...block, ...block,
@ -74,16 +79,16 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
: []; : [];
useEffect(() => { useEffect(() => {
if (currentViewData && blocks && blocks.length > 0) if (currentViewData && blocks)
setChartBlocks(() => renderBlockStructure(currentViewData, blocks)); setChartBlocks(() => renderBlockStructure(currentViewData, blocks));
}, [currentViewData, blocks]); }, [currentViewData, blocks]);
// blocks state management ends // blocks state management ends
const handleChartView = (key: string) => updateCurrentViewRenderPayload(null, key); const handleChartView = (key: TGanttViews) => updateCurrentViewRenderPayload(null, key);
const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: string) => { const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews) => {
const selectedCurrentView = view; const selectedCurrentView: TGanttViews = view;
const selectedCurrentViewData: ChartDataType | undefined = const selectedCurrentViewData: ChartDataType | undefined =
selectedCurrentView && selectedCurrentView === currentViewData?.key selectedCurrentView && selectedCurrentView === currentViewData?.key
? currentViewData ? currentViewData
@ -155,6 +160,9 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
const updatingCurrentLeftScrollPosition = (width: number) => { const updatingCurrentLeftScrollPosition = (width: number) => {
const scrollContainer = document.getElementById("scroll-container") as HTMLElement; const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
if (!scrollContainer) return;
scrollContainer.scrollLeft = width + scrollContainer?.scrollLeft; scrollContainer.scrollLeft = width + scrollContainer?.scrollLeft;
setItemsContainerWidth(width + scrollContainer?.scrollLeft); setItemsContainerWidth(width + scrollContainer?.scrollLeft);
}; };
@ -195,6 +203,8 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
const clientVisibleWidth: number = scrollContainer?.clientWidth; const clientVisibleWidth: number = scrollContainer?.clientWidth;
const currentScrollPosition: number = scrollContainer?.scrollLeft; const currentScrollPosition: number = scrollContainer?.scrollLeft;
updateScrollLeft(currentScrollPosition);
const approxRangeLeft: number = const approxRangeLeft: number =
scrollWidth >= clientVisibleWidth + 1000 ? 1000 : scrollWidth - clientVisibleWidth; scrollWidth >= clientVisibleWidth + 1000 ? 1000 : scrollWidth - clientVisibleWidth;
const approxRangeRight: number = scrollWidth - (approxRangeLeft + clientVisibleWidth); const approxRangeRight: number = scrollWidth - (approxRangeLeft + clientVisibleWidth);
@ -205,16 +215,6 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
updateCurrentViewRenderPayload("left", currentView); updateCurrentViewRenderPayload("left", currentView);
}; };
useEffect(() => {
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
scrollContainer.addEventListener("scroll", onScroll);
return () => {
scrollContainer.removeEventListener("scroll", onScroll);
};
}, [renderView]);
return ( return (
<div <div
className={`${ className={`${
@ -225,44 +225,14 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
border ? `border border-custom-border-200` : `` border ? `border border-custom-border-200` : ``
} flex h-full flex-col rounded-sm select-none bg-custom-background-100 shadow`} } flex h-full flex-col rounded-sm select-none bg-custom-background-100 shadow`}
> >
{/* chart title */}
{/* <div className="flex w-full flex-shrink-0 flex-wrap items-center gap-5 gap-y-3 whitespace-nowrap p-2 border-b border-custom-border-200">
{title && (
<div className="text-lg font-medium flex gap-2 items-center">
<div>{title}</div>
<div className="text-xs rounded-full px-2 py-1 font-bold border border-custom-primary/75 bg-custom-primary/5 text-custom-text-100">
Gantt View Beta
</div>
</div>
)}
{blocks === null ? (
<div className="text-sm font-medium ml-auto">Loading...</div>
) : (
<div className="text-sm font-medium ml-auto">
{blocks.length} {loaderTitle}
</div>
)}
</div> */}
{/* chart header */} {/* chart header */}
<div className="flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap p-2"> <div className="flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2">
{/* <div
className="transition-all border border-custom-border-200 w-[30px] h-[30px] flex justify-center items-center cursor-pointer rounded-sm hover:bg-custom-background-80"
onClick={() => setBlocksSidebarView(() => !blocksSidebarView)}
>
{blocksSidebarView ? (
<XMarkIcon className="h-5 w-5" />
) : (
<Bars4Icon className="h-4 w-4" />
)}
</div> */}
{title && ( {title && (
<div className="text-lg font-medium flex gap-2 items-center"> <div className="text-lg font-medium flex gap-2 items-center">
<div>{title}</div> <div>{title}</div>
<div className="text-xs rounded-full px-2 py-1 font-bold border border-custom-primary/75 bg-custom-primary/5 text-custom-text-100"> {/* <div className="text-xs rounded-full px-2 py-1 font-bold border border-custom-primary/75 bg-custom-primary/5 text-custom-text-100">
Gantt View Beta Gantt View Beta
</div> </div> */}
</div> </div>
)} )}
@ -282,7 +252,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
allViews.map((_chatView: any, _idx: any) => ( allViews.map((_chatView: any, _idx: any) => (
<div <div
key={_chatView?.key} key={_chatView?.key}
className={`cursor-pointer rounded-sm border border-custom-border-200 p-1 px-2 text-xs ${ className={`cursor-pointer rounded-sm p-1 px-2 text-xs ${
currentView === _chatView?.key currentView === _chatView?.key
? `bg-custom-background-80` ? `bg-custom-background-80`
: `hover:bg-custom-background-90` : `hover:bg-custom-background-90`
@ -296,7 +266,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div <div
className={`cursor-pointer rounded-sm border border-custom-border-200 p-1 px-2 text-xs hover:bg-custom-background-80`} className="cursor-pointer rounded-sm p-1 px-2 text-xs hover:bg-custom-background-80"
onClick={handleToday} onClick={handleToday}
> >
Today Today
@ -316,26 +286,30 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
</div> </div>
{/* content */} {/* content */}
<div className="relative flex h-full w-full flex-1 overflow-hidden border-t border-custom-border-200"> <div
id="gantt-container"
className={`relative flex h-full w-full flex-1 overflow-hidden border-t border-custom-border-200 ${
bottomSpacing ? "mb-8" : ""
}`}
>
<div <div
className="relative flex h-full w-full flex-1 flex-col overflow-hidden overflow-x-auto" id="gantt-sidebar"
id="scroll-container" className="h-full w-1/4 flex flex-col border-r border-custom-border-200 space-y-3"
>
<div className="h-[60px] border-b border-custom-border-200 box-border flex-shrink-0" />
<GanttSidebar
title={title}
blockUpdateHandler={blockUpdateHandler}
blocks={chartBlocks}
SidebarBlockRender={SidebarBlockRender}
enableReorder={enableReorder}
/>
</div>
<div
className="relative flex h-full w-full flex-1 flex-col overflow-hidden overflow-x-auto horizontal-scroll-enable"
id="scroll-container"
onScroll={onScroll}
> >
{/* blocks components */}
{currentView && currentViewData && (
<GanttChartBlocks
itemsContainerWidth={itemsContainerWidth}
blocks={chartBlocks}
sidebarBlockRender={sidebarBlockRender}
blockRender={blockRender}
blockUpdateHandler={blockUpdateHandler}
enableLeftDrag={enableLeftDrag}
enableRightDrag={enableRightDrag}
enableReorder={enableReorder}
/>
)}
{/* chart */}
{/* {currentView && currentView === "hours" && <HourChartView />} */} {/* {currentView && currentView === "hours" && <HourChartView />} */}
{/* {currentView && currentView === "day" && <DayChartView />} */} {/* {currentView && currentView === "day" && <DayChartView />} */}
{/* {currentView && currentView === "week" && <WeekChartView />} */} {/* {currentView && currentView === "week" && <WeekChartView />} */}
@ -343,6 +317,19 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
{currentView && currentView === "month" && <MonthChartView />} {currentView && currentView === "month" && <MonthChartView />}
{/* {currentView && currentView === "quarter" && <QuarterChartView />} */} {/* {currentView && currentView === "quarter" && <QuarterChartView />} */}
{/* {currentView && currentView === "year" && <YearChartView />} */} {/* {currentView && currentView === "year" && <YearChartView />} */}
{/* blocks */}
{currentView && currentViewData && (
<GanttChartBlocks
itemsContainerWidth={itemsContainerWidth}
blocks={chartBlocks}
BlockRender={BlockRender}
blockUpdateHandler={blockUpdateHandler}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
/>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -17,9 +17,38 @@ export const MonthChartView: FC<any> = () => {
monthBlocks.length > 0 && monthBlocks.length > 0 &&
monthBlocks.map((block, _idxRoot) => ( monthBlocks.map((block, _idxRoot) => (
<div key={`month-${block?.month}-${block?.year}`} className="relative flex flex-col"> <div key={`month-${block?.month}-${block?.year}`} className="relative flex flex-col">
<div className="relative border-b border-custom-border-200"> <div className="h-[60px] w-full">
<div className="sticky left-0 inline-flex whitespace-nowrap px-2 py-1 text-sm font-medium capitalize"> <div className="relative h-[30px]">
{block?.title} <div className="sticky left-0 inline-flex whitespace-nowrap px-3 py-2 text-xs font-medium capitalize">
{block?.title}
</div>
</div>
<div className="flex w-full h-[30px]">
{block?.children &&
block?.children.length > 0 &&
block?.children.map((monthDay, _idx) => (
<div
key={`sub-title-${_idxRoot}-${_idx}`}
className="flex-shrink-0 border-b py-1 text-center capitalize border-custom-border-200"
style={{ width: `${currentViewData?.data.width}px` }}
>
<div className="text-xs space-x-1">
<span className="text-custom-text-200">
{monthDay.dayData.shortTitle[0]}
</span>{" "}
<span
className={
monthDay.today
? "bg-custom-primary-100 text-white px-1 rounded-full"
: ""
}
>
{monthDay.day}
</span>
</div>
</div>
))}
</div> </div>
</div> </div>
@ -28,19 +57,10 @@ export const MonthChartView: FC<any> = () => {
block?.children.length > 0 && block?.children.length > 0 &&
block?.children.map((monthDay, _idx) => ( block?.children.map((monthDay, _idx) => (
<div <div
key={`sub-title-${_idxRoot}-${_idx}`} key={`column-${_idxRoot}-${_idx}`}
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap" className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
style={{ width: `${currentViewData?.data.width}px` }} style={{ width: `${currentViewData?.data.width}px` }}
> >
<div
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
monthDay?.today
? `text-red-500 border-red-500`
: `border-custom-border-200`
}`}
>
<div>{monthDay?.title}</div>
</div>
<div <div
className={`relative h-full w-full flex-1 flex justify-center ${ className={`relative h-full w-full flex-1 flex justify-center ${
["sat", "sun"].includes(monthDay?.dayData?.shortTitle || "") ["sat", "sun"].includes(monthDay?.dayData?.shortTitle || "")
@ -48,9 +68,9 @@ export const MonthChartView: FC<any> = () => {
: `` : ``
}`} }`}
> >
{monthDay?.today && ( {/* {monthDay?.today && (
<div className="absolute top-0 bottom-0 w-[1px] bg-red-500" /> <div className="absolute top-0 bottom-0 w-[1px] bg-red-500" />
)} )} */}
</div> </div>
</div> </div>
))} ))}

View File

@ -32,16 +32,27 @@ export const ChartContextProvider: React.FC<{ children: React.ReactNode }> = ({
currentViewData: currentViewDataWithView(initialView), currentViewData: currentViewDataWithView(initialView),
renderView: [], renderView: [],
allViews: allViewsWithData, allViews: allViewsWithData,
activeBlock: null,
}); });
const [scrollLeft, setScrollLeft] = useState(0);
const handleDispatch = (action: ChartContextActionPayload): ChartContextData => { const handleDispatch = (action: ChartContextActionPayload): ChartContextData => {
const newState = chartReducer(state, action); const newState = chartReducer(state, action);
dispatch(() => newState); dispatch(() => newState);
return newState; return newState;
}; };
const updateScrollLeft = (scrollLeft: number) => {
setScrollLeft(scrollLeft);
};
return ( return (
<ChartContext.Provider value={{ ...state, dispatch: handleDispatch }}> <ChartContext.Provider
value={{ ...state, scrollLeft, updateScrollLeft, dispatch: handleDispatch }}
>
{children} {children}
</ChartContext.Provider> </ChartContext.Provider>
); );

View File

@ -108,8 +108,8 @@ export const allViewsWithData: ChartDataType[] = [
startDate: new Date(), startDate: new Date(),
currentDate: new Date(), currentDate: new Date(),
endDate: new Date(), endDate: new Date(),
approxFilterRange: 8, approxFilterRange: 6,
width: 80, // it will preview monthly all dates with weekends highlighted with no limitations ex: title (1, 2, 3) width: 55, // it will preview monthly all dates with weekends highlighted with no limitations ex: title (1, 2, 3)
}, },
}, },
// { // {

View File

@ -1,45 +1,57 @@
import React, { useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
// react-beautiful-dnd // icons
import { DraggableProvided } from "react-beautiful-dnd"; import { Icon } from "components/ui";
// hooks
import { useChart } from "../hooks"; import { useChart } from "../hooks";
// types // types
import { IGanttBlock } from "../types"; import { IGanttBlock } from "../types";
type Props = { type Props = {
children: any;
block: IGanttBlock; block: IGanttBlock;
handleBlock: (totalBlockShifts: number, dragDirection: "left" | "right") => void; BlockRender: React.FC<any>;
enableLeftDrag: boolean; handleBlock: (totalBlockShifts: number, dragDirection: "left" | "right" | "move") => void;
enableRightDrag: boolean; enableBlockLeftResize: boolean;
provided: DraggableProvided; enableBlockRightResize: boolean;
enableBlockMove: boolean;
}; };
export const ChartDraggable: React.FC<Props> = ({ export const ChartDraggable: React.FC<Props> = ({
children,
block, block,
BlockRender,
handleBlock, handleBlock,
enableLeftDrag = true, enableBlockLeftResize,
enableRightDrag = true, enableBlockRightResize,
provided, enableBlockMove,
}) => { }) => {
const [isLeftResizing, setIsLeftResizing] = useState(false); const [isLeftResizing, setIsLeftResizing] = useState(false);
const [isRightResizing, setIsRightResizing] = useState(false); const [isRightResizing, setIsRightResizing] = useState(false);
const [isMoving, setIsMoving] = useState(false);
const [posFromLeft, setPosFromLeft] = useState<number | null>(null);
const parentDivRef = useRef<HTMLDivElement>(null);
const resizableRef = useRef<HTMLDivElement>(null); const resizableRef = useRef<HTMLDivElement>(null);
const { currentViewData } = useChart(); const { currentViewData, scrollLeft } = useChart();
// check if cursor reaches either end while resizing/dragging
const checkScrollEnd = (e: MouseEvent): number => { const checkScrollEnd = (e: MouseEvent): number => {
const SCROLL_THRESHOLD = 70;
let delWidth = 0; let delWidth = 0;
const ganttContainer = document.querySelector("#gantt-container") as HTMLElement;
const ganttSidebar = document.querySelector("#gantt-sidebar") as HTMLElement;
const scrollContainer = document.querySelector("#scroll-container") as HTMLElement; const scrollContainer = document.querySelector("#scroll-container") as HTMLElement;
const appSidebar = document.querySelector("#app-sidebar") as HTMLElement;
if (!ganttContainer || !ganttSidebar || !scrollContainer) return 0;
const posFromLeft = e.clientX; const posFromLeft = e.clientX;
// manually scroll to left if reached the left end while dragging // manually scroll to left if reached the left end while dragging
if (posFromLeft - appSidebar.clientWidth <= 70) { if (
posFromLeft - (ganttContainer.getBoundingClientRect().left + ganttSidebar.clientWidth) <=
SCROLL_THRESHOLD
) {
if (e.movementX > 0) return 0; if (e.movementX > 0) return 0;
delWidth = -5; delWidth = -5;
@ -48,8 +60,8 @@ export const ChartDraggable: React.FC<Props> = ({
} else delWidth = e.movementX; } else delWidth = e.movementX;
// manually scroll to right if reached the right end while dragging // manually scroll to right if reached the right end while dragging
const posFromRight = window.innerWidth - e.clientX; const posFromRight = ganttContainer.getBoundingClientRect().right - e.clientX;
if (posFromRight <= 70) { if (posFromRight <= SCROLL_THRESHOLD) {
if (e.movementX < 0) return 0; if (e.movementX < 0) return 0;
delWidth = 5; delWidth = 5;
@ -60,12 +72,11 @@ export const ChartDraggable: React.FC<Props> = ({
return delWidth; return delWidth;
}; };
const handleLeftDrag = () => { // handle block resize from the left end
if (!currentViewData || !resizableRef.current || !parentDivRef.current || !block.position) const handleBlockLeftResize = () => {
return; if (!currentViewData || !resizableRef.current || !block.position) return;
const resizableDiv = resizableRef.current; const resizableDiv = resizableRef.current;
const parentDiv = parentDivRef.current;
const columnWidth = currentViewData.data.width; const columnWidth = currentViewData.data.width;
@ -73,11 +84,9 @@ export const ChartDraggable: React.FC<Props> = ({
resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10); resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10); let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
let initialMarginLeft = parseInt(parentDiv.style.marginLeft); let initialMarginLeft = parseInt(resizableDiv.style.marginLeft);
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!window) return;
let delWidth = 0; let delWidth = 0;
delWidth = checkScrollEnd(e); delWidth = checkScrollEnd(e);
@ -92,7 +101,7 @@ export const ChartDraggable: React.FC<Props> = ({
if (newWidth < columnWidth) return; if (newWidth < columnWidth) return;
resizableDiv.style.width = `${newWidth}px`; resizableDiv.style.width = `${newWidth}px`;
parentDiv.style.marginLeft = `${newMarginLeft}px`; resizableDiv.style.marginLeft = `${newMarginLeft}px`;
if (block.position) { if (block.position) {
block.position.width = newWidth; block.position.width = newWidth;
@ -100,6 +109,7 @@ export const ChartDraggable: React.FC<Props> = ({
} }
}; };
// remove event listeners and call block handler with the updated start date
const handleMouseUp = () => { const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
@ -115,9 +125,9 @@ export const ChartDraggable: React.FC<Props> = ({
document.addEventListener("mouseup", handleMouseUp); document.addEventListener("mouseup", handleMouseUp);
}; };
const handleRightDrag = () => { // handle block resize from the right end
if (!currentViewData || !resizableRef.current || !parentDivRef.current || !block.position) const handleBlockRightResize = () => {
return; if (!currentViewData || !resizableRef.current || !block.position) return;
const resizableDiv = resizableRef.current; const resizableDiv = resizableRef.current;
@ -129,8 +139,6 @@ export const ChartDraggable: React.FC<Props> = ({
let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10); let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!window) return;
let delWidth = 0; let delWidth = 0;
delWidth = checkScrollEnd(e); delWidth = checkScrollEnd(e);
@ -145,6 +153,7 @@ export const ChartDraggable: React.FC<Props> = ({
if (block.position) block.position.width = Math.max(newWidth, 80); if (block.position) block.position.width = Math.max(newWidth, 80);
}; };
// remove event listeners and call block handler with the updated target date
const handleMouseUp = () => { const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
@ -160,46 +169,148 @@ export const ChartDraggable: React.FC<Props> = ({
document.addEventListener("mouseup", handleMouseUp); document.addEventListener("mouseup", handleMouseUp);
}; };
// handle block x-axis move
const handleBlockMove = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (!enableBlockMove || !currentViewData || !resizableRef.current || !block.position) return;
e.preventDefault();
e.stopPropagation();
setIsMoving(true);
const resizableDiv = resizableRef.current;
const columnWidth = currentViewData.data.width;
const blockInitialMarginLeft = parseInt(resizableDiv.style.marginLeft);
let initialMarginLeft = parseInt(resizableDiv.style.marginLeft);
const handleMouseMove = (e: MouseEvent) => {
let delWidth = 0;
delWidth = checkScrollEnd(e);
// calculate new marginLeft and update the initial marginLeft using -=
const newMarginLeft = Math.round((initialMarginLeft += delWidth) / columnWidth) * columnWidth;
resizableDiv.style.marginLeft = `${newMarginLeft}px`;
if (block.position) block.position.marginLeft = newMarginLeft;
};
// remove event listeners and call block handler with the updated dates
const handleMouseUp = () => {
setIsMoving(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
const totalBlockShifts = Math.ceil(
(parseInt(resizableDiv.style.marginLeft) - blockInitialMarginLeft) / columnWidth
);
handleBlock(totalBlockShifts, "move");
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
// scroll to a hidden block
const handleScrollToBlock = () => {
const scrollContainer = document.querySelector("#scroll-container") as HTMLElement;
if (!scrollContainer || !block.position) return;
// update container's scroll position to the block's position
scrollContainer.scrollLeft = block.position.marginLeft - 4;
};
// update block position from viewport's left end on scroll
useEffect(() => {
const block = resizableRef.current;
if (!block) return;
setPosFromLeft(block.getBoundingClientRect().left);
}, [scrollLeft]);
// check if block is hidden on either side
const isBlockHiddenOnLeft =
block.position?.marginLeft &&
block.position?.width &&
scrollLeft > block.position.marginLeft + block.position.width;
const isBlockHiddenOnRight = posFromLeft && window && posFromLeft > window.innerWidth;
return ( return (
<div <>
id={`block-${block.id}`} {/* move to left side hidden block button */}
ref={parentDivRef} {isBlockHiddenOnLeft && (
className="relative group inline-flex cursor-pointer items-center font-medium transition-all" <div
style={{ className="fixed ml-1 mt-1.5 z-[1] h-8 w-8 grid place-items-center border border-custom-border-300 rounded cursor-pointer bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100"
marginLeft: `${block.position?.marginLeft}px`, onClick={handleScrollToBlock}
}} >
> <Icon iconName="arrow_back" />
{enableLeftDrag && ( </div>
<>
<div
onMouseDown={handleLeftDrag}
onMouseEnter={() => setIsLeftResizing(true)}
onMouseLeave={() => setIsLeftResizing(false)}
className="absolute top-1/2 -left-2.5 -translate-y-1/2 z-[1] w-6 h-10 bg-brand-backdrop rounded-md cursor-col-resize"
/>
<div
className={`absolute top-1/2 -translate-y-1/2 w-1 h-4/5 rounded-sm bg-custom-background-80 transition-all duration-300 ${
isLeftResizing ? "-left-2.5" : "left-1"
}`}
/>
</>
)} )}
{React.cloneElement(children, { ref: resizableRef, ...provided.dragHandleProps })} {/* move to right side hidden block button */}
{enableRightDrag && ( {isBlockHiddenOnRight && (
<> <div
<div className="fixed right-1 mt-1.5 z-[1] h-8 w-8 grid place-items-center border border-custom-border-300 rounded cursor-pointer bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100"
onMouseDown={handleRightDrag} onClick={handleScrollToBlock}
onMouseEnter={() => setIsRightResizing(true)} >
onMouseLeave={() => setIsRightResizing(false)} <Icon iconName="arrow_forward" />
className="absolute top-1/2 -right-2.5 -translate-y-1/2 z-[1] w-6 h-6 bg-brand-backdrop rounded-md cursor-col-resize" </div>
/>
<div
className={`absolute top-1/2 -translate-y-1/2 w-1 h-4/5 rounded-sm bg-custom-background-80 transition-all duration-300 ${
isRightResizing ? "-right-2.5" : "right-1"
}`}
/>
</>
)} )}
</div> <div
id={`block-${block.id}`}
ref={resizableRef}
className="relative group cursor-pointer font-medium rounded shadow-sm h-full inline-flex items-center transition-all"
style={{
marginLeft: `${block.position?.marginLeft}px`,
width: `${block.position?.width}px`,
}}
>
{/* left resize drag handle */}
{enableBlockLeftResize && (
<>
<div
onMouseDown={handleBlockLeftResize}
onMouseEnter={() => setIsLeftResizing(true)}
onMouseLeave={() => setIsLeftResizing(false)}
className="absolute top-1/2 -left-2.5 -translate-y-1/2 z-[3] w-6 h-full rounded-md cursor-col-resize"
/>
<div
className={`absolute top-1/2 -translate-y-1/2 w-1 h-7 rounded-sm bg-custom-background-100 transition-all duration-300 ${
isLeftResizing ? "-left-2.5" : "left-1"
}`}
/>
</>
)}
<div
className="relative z-[2] rounded h-8 w-full flex items-center"
onMouseDown={handleBlockMove}
>
<BlockRender data={block.data} />
</div>
{/* right resize drag handle */}
{enableBlockRightResize && (
<>
<div
onMouseDown={handleBlockRightResize}
onMouseEnter={() => setIsRightResizing(true)}
onMouseLeave={() => setIsRightResizing(false)}
className="absolute top-1/2 -right-2.5 -translate-y-1/2 z-[2] w-6 h-full rounded-md cursor-col-resize"
/>
<div
className={`absolute top-1/2 -translate-y-1/2 w-1 h-7 rounded-sm bg-custom-background-100 transition-all duration-300 ${
isRightResizing ? "-right-2.5" : "right-1"
}`}
/>
</>
)}
</div>
</>
); );
}; };

View File

@ -7,9 +7,7 @@ import { ChartContext } from "../contexts";
export const useChart = (): ChartContextReducer => { export const useChart = (): ChartContextReducer => {
const context = useContext(ChartContext); const context = useContext(ChartContext);
if (!context) { if (!context) throw new Error("useChart must be used within a GanttChart");
throw new Error("useChart must be used within a GanttChart");
}
return context; return context;
}; };

View File

@ -8,28 +8,32 @@ import { IBlockUpdateData, IGanttBlock } from "./types";
type GanttChartRootProps = { type GanttChartRootProps = {
border?: boolean; border?: boolean;
title: null | string; title: string;
loaderTitle: string; loaderTitle: string;
blocks: IGanttBlock[] | null; blocks: IGanttBlock[] | null;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
sidebarBlockRender: FC<any>; SidebarBlockRender: FC<any>;
blockRender: FC<any>; BlockRender: FC<any>;
enableLeftDrag?: boolean; enableBlockLeftResize?: boolean;
enableRightDrag?: boolean; enableBlockRightResize?: boolean;
enableBlockMove?: boolean;
enableReorder?: boolean; enableReorder?: boolean;
bottomSpacing?: boolean;
}; };
export const GanttChartRoot: FC<GanttChartRootProps> = ({ export const GanttChartRoot: FC<GanttChartRootProps> = ({
border = true, border = true,
title = null, title,
blocks, blocks,
loaderTitle = "blocks", loaderTitle = "blocks",
blockUpdateHandler, blockUpdateHandler,
sidebarBlockRender, SidebarBlockRender,
blockRender, BlockRender,
enableLeftDrag = true, enableBlockLeftResize = true,
enableRightDrag = true, enableBlockRightResize = true,
enableBlockMove = true,
enableReorder = true, enableReorder = true,
bottomSpacing = false,
}) => ( }) => (
<ChartContextProvider> <ChartContextProvider>
<ChartViewRoot <ChartViewRoot
@ -38,11 +42,13 @@ export const GanttChartRoot: FC<GanttChartRootProps> = ({
blocks={blocks} blocks={blocks}
loaderTitle={loaderTitle} loaderTitle={loaderTitle}
blockUpdateHandler={blockUpdateHandler} blockUpdateHandler={blockUpdateHandler}
sidebarBlockRender={sidebarBlockRender} SidebarBlockRender={SidebarBlockRender}
blockRender={blockRender} BlockRender={BlockRender}
enableLeftDrag={enableLeftDrag} enableBlockLeftResize={enableBlockLeftResize}
enableRightDrag={enableRightDrag} enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
enableReorder={enableReorder} enableReorder={enableReorder}
bottomSpacing={bottomSpacing}
/> />
</ChartContextProvider> </ChartContextProvider>
); );

View File

@ -0,0 +1,156 @@
// react-beautiful-dnd
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// hooks
import { useChart } from "./hooks";
// ui
import { Loader } from "components/ui";
// icons
import { EllipsisVerticalIcon } from "@heroicons/react/24/outline";
// types
import { IBlockUpdateData, IGanttBlock } from "./types";
type Props = {
title: string;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null;
SidebarBlockRender: React.FC<any>;
enableReorder: boolean;
};
export const GanttSidebar: React.FC<Props> = ({
title,
blockUpdateHandler,
blocks,
SidebarBlockRender,
enableReorder,
}) => {
const { activeBlock, dispatch } = useChart();
// update the active block on hover
const updateActiveBlock = (block: IGanttBlock | null) => {
dispatch({
type: "PARTIAL_UPDATE",
payload: {
activeBlock: block,
},
});
};
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
const { source, destination } = result;
// return if dropped outside the list
if (!destination) return;
// return if dropped on the same index
if (source.index === destination.index) return;
let updatedSortOrder = blocks[source.index].sort_order;
// update the sort order to the lowest if dropped at the top
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
// update the sort order to the highest if dropped at the bottom
else if (destination.index === blocks.length - 1)
updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
// update the sort order to the average of the two adjacent blocks if dropped in between
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
// extract the element from the source index and insert it at the destination index without updating the entire array
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
// call the block update handler with the updated sort order, new and old index
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
});
};
return (
<DragDropContext onDragEnd={handleOrderChange}>
<StrictModeDroppable droppableId="gantt-sidebar">
{(droppableProvided) => (
<div
className="h-full overflow-y-auto pl-2.5"
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
>
<>
{blocks ? (
blocks.length > 0 ? (
blocks.map((block, index) => (
<Draggable
key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided, snapshot) => (
<div
className={`h-11 ${
snapshot.isDragging ? "bg-custom-background-80 rounded" : ""
}`}
onMouseEnter={() => updateActiveBlock(block)}
onMouseLeave={() => updateActiveBlock(null)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
id={`sidebar-block-${block.id}`}
className={`group h-full w-full flex items-center gap-2 rounded-l px-2 pr-4 ${
activeBlock?.id === block.id ? "bg-custom-background-80" : ""
}`}
>
{enableReorder && (
<button
type="button"
className="rounded p-0.5 text-custom-sidebar-text-200 flex flex-shrink-0 opacity-0 group-hover:opacity-100"
{...provided.dragHandleProps}
>
<EllipsisVerticalIcon className="h-4" />
<EllipsisVerticalIcon className="h-4 -ml-5" />
</button>
)}
<div className="flex-grow truncate w-full h-full">
<SidebarBlockRender data={block.data} />
</div>
</div>
</div>
)}
</Draggable>
))
) : (
<div className="text-custom-text-200 text-sm text-center mt-8">
No <span className="lowercase">{title}</span> found
</div>
)
) : (
<Loader className="pr-2 space-y-3">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
{droppableProvided.placeholder}
</>
</div>
)}
</StrictModeDroppable>
</DragDropContext>
);
};

View File

@ -27,19 +27,33 @@ export interface IBlockUpdateData {
target_date?: string; target_date?: string;
} }
export type TGanttViews = "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year";
export interface ChartContextData { export interface ChartContextData {
allViews: allViewsType[]; allViews: allViewsType[];
currentView: "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year"; currentView: TGanttViews;
currentViewData: ChartDataType | undefined; currentViewData: ChartDataType | undefined;
renderView: any; renderView: any;
activeBlock: IGanttBlock | null;
} }
export type ChartContextActionPayload = { export type ChartContextActionPayload =
type: "CURRENT_VIEW" | "CURRENT_VIEW_DATA" | "PARTIAL_UPDATE" | "RENDER_VIEW"; | {
payload: any; type: "CURRENT_VIEW";
}; payload: TGanttViews;
}
| {
type: "CURRENT_VIEW_DATA" | "RENDER_VIEW";
payload: ChartDataType | undefined;
}
| {
type: "PARTIAL_UPDATE";
payload: Partial<ChartContextData>;
};
export interface ChartContextReducer extends ChartContextData { export interface ChartContextReducer extends ChartContextData {
scrollLeft: number;
updateScrollLeft: (scrollLeft: number) => void;
dispatch: (action: ChartContextActionPayload) => void; dispatch: (action: ChartContextActionPayload) => void;
} }

View File

@ -27,6 +27,7 @@ export * from "./started-state-icon";
export * from "./layer-diagonal-icon"; export * from "./layer-diagonal-icon";
export * from "./lock-icon"; export * from "./lock-icon";
export * from "./menu-icon"; export * from "./menu-icon";
export * from "./module";
export * from "./pencil-scribble-icon"; export * from "./pencil-scribble-icon";
export * from "./plus-icon"; export * from "./plus-icon";
export * from "./person-running-icon"; export * from "./person-running-icon";

View File

@ -0,0 +1,57 @@
import React from "react";
type Props = {
width?: string;
height?: string;
className?: string;
color?: string;
};
export const ModuleBacklogIcon: React.FC<Props> = ({ width = "20", height = "20", className }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
className={className}
viewBox="0 0 247.63 247.6"
>
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path fill="#f6aa3e" d="M87.76,165.33a2.1,2.1,0,0,1-2.33,0Z" />
<path fill="#f5a839" d="M94.08,165.33a1.67,1.67,0,0,1-2,0Z" />
<path
fill="#a3a3a2"
d="M.29,115.46A130.18,130.18,0,0,1,2.05,101c.15-1,.53-1.37,1.62-1.15q7.78,1.64,15.6,3.12c1,.2,1.27.56,1.07,1.63a105.92,105.92,0,0,0-1.7,23.11,99.36,99.36,0,0,0,1.7,15.3c.2,1.05,0,1.44-1.06,1.64q-7.82,1.49-15.6,3.12c-1.22.25-1.5-.29-1.66-1.29C1.33,142,.64,137.63.34,133.16c0-.28-.05-.56-.34-.71v-.66c.36-.68.08-1.41.17-2.12A15,15,0,0,0,0,126.13v-4.66a17,17,0,0,0,.17-3.7A9.41,9.41,0,0,1,.29,115.46Z"
/>
<path
fill="#a3a3a2"
d="M132.14.29a130.64,130.64,0,0,1,14.48,1.76c1,.15,1.36.55,1.13,1.63q-1.62,7.79-3.11,15.6c-.2,1-.58,1.26-1.63,1.06a106.48,106.48,0,0,0-23-1.71,101.71,101.71,0,0,0-15.47,1.71c-1.08.21-1.42-.05-1.62-1.08-1-5.2-2-10.41-3.12-15.59-.23-1.1.17-1.47,1.15-1.62A137.72,137.72,0,0,1,115.46.28a5.78,5.78,0,0,1,1.66-.11h1.66A9,9,0,0,0,121.47,0h4.66a17,17,0,0,0,3.7.17A9.41,9.41,0,0,1,132.14.29Z"
/>
<path
fill="#a3a3a2"
d="M229,123.63a92.74,92.74,0,0,0-1.71-18.81c-.25-1.28.09-1.7,1.31-1.93q7.57-1.44,15.12-3c1.13-.24,1.66,0,1.88,1.22a133,133,0,0,1,2,26.28,141.92,141.92,0,0,1-2,19.29c-.19,1.08-.71,1.27-1.69,1.07q-7.71-1.58-15.45-3.07c-1.07-.21-1.36-.6-1.15-1.73A98.45,98.45,0,0,0,229,123.63Z"
/>
<path
fill="#a3a3a2"
d="M123.83,247.6a131.89,131.89,0,0,1-22.79-2c-1.14-.19-1.4-.68-1.18-1.76q1.61-7.71,3.09-15.45c.19-1,.45-1.34,1.6-1.12a105.9,105.9,0,0,0,23,1.72A101.84,101.84,0,0,0,143,227.26c1.05-.19,1.44,0,1.64,1.07q1.49,7.81,3.12,15.61c.22,1.08-.15,1.45-1.15,1.62A129.86,129.86,0,0,1,123.83,247.6Z"
/>
<path
fill="#a3a3a2"
d="M65.13,211.57c-.17.28-.37.62-.58.94-2.93,4.37-5.88,8.72-8.76,13.13-.6.91-1,1-1.92.4a126.44,126.44,0,0,1-32-32c-.8-1.15-.84-1.7.45-2.52,4.35-2.77,8.61-5.66,12.86-8.56.9-.62,1.33-.5,1.94.38a103.22,103.22,0,0,0,27.2,27.21C65.1,211.15,65.14,211.21,65.13,211.57Z"
/>
<path
fill="#a3a3a2"
d="M192.79,226.56c-.51-.06-.61-.4-.79-.67-3.05-4.55-6.08-9.12-9.17-13.65-.6-.89-.16-1.22.5-1.66a103.37,103.37,0,0,0,16.56-14.05,93.49,93.49,0,0,0,10.53-13c.68-1,1.15-1.13,2.18-.42,4.32,3,8.7,5.91,13.09,8.81.83.54,1,.94.38,1.8a125.4,125.4,0,0,1-32,32C193.65,226,193.18,226.31,192.79,226.56Z"
/>
<path
fill="#a3a3a2"
d="M36,65.13c-.28-.18-.62-.37-.94-.59-4.37-2.92-8.72-5.88-13.12-8.75-.95-.62-1-1-.36-1.91A127.22,127.22,0,0,1,53.69,21.72c1.07-.74,1.56-.65,2.27.46,2.79,4.32,5.67,8.6,8.57,12.85.63.91.68,1.38-.32,2.07A105,105,0,0,0,37,64.29C36.43,65.13,36.4,65.14,36,65.13Z"
/>
<path
fill="#a3a3a2"
d="M226.53,54.77c.07.52-.35.64-.66.85-4.56,3.05-9.12,6.08-13.65,9.15-.76.51-1.12.3-1.57-.38a97.64,97.64,0,0,0-11.79-14.34,97,97,0,0,0-15.37-12.87c-1.05-.7-1.09-1.18-.4-2.19,3-4.33,5.91-8.7,8.8-13.09.57-.86,1-.91,1.79-.32A126.12,126.12,0,0,1,225.92,53.8C226.14,54.11,226.33,54.45,226.53,54.77Z"
/>
</g>
</g>
</svg>
);

View File

@ -0,0 +1,35 @@
import React from "react";
type Props = {
width?: string;
height?: string;
className?: string;
color?: string;
};
export const ModuleCancelledIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_4052_100277)">
<path
d="M8 8.84L10.58 11.42C10.7 11.54 10.84 11.6 11 11.6C11.16 11.6 11.3 11.54 11.42 11.42C11.54 11.3 11.6 11.16 11.6 11C11.6 10.84 11.54 10.7 11.42 10.58L8.84 8L11.42 5.42C11.54 5.3 11.6 5.16 11.6 5C11.6 4.84 11.54 4.7 11.42 4.58C11.3 4.46 11.16 4.4 11 4.4C10.84 4.4 10.7 4.46 10.58 4.58L8 7.16L5.42 4.58C5.3 4.46 5.16 4.4 5 4.4C4.84 4.4 4.7 4.46 4.58 4.58C4.46 4.7 4.4 4.84 4.4 5C4.4 5.16 4.46 5.3 4.58 5.42L7.16 8L4.58 10.58C4.46 10.7 4.4 10.84 4.4 11C4.4 11.16 4.46 11.3 4.58 11.42C4.7 11.54 4.84 11.6 5 11.6C5.16 11.6 5.3 11.54 5.42 11.42L8 8.84ZM8 16C6.90667 16 5.87333 15.79 4.9 15.37C3.92667 14.95 3.07667 14.3767 2.35 13.65C1.62333 12.9233 1.05 12.0733 0.63 11.1C0.21 10.1267 0 9.09333 0 8C0 6.89333 0.21 5.85333 0.63 4.88C1.05 3.90667 1.62333 3.06 2.35 2.34C3.07667 1.62 3.92667 1.05 4.9 0.63C5.87333 0.21 6.90667 0 8 0C9.10667 0 10.1467 0.21 11.12 0.63C12.0933 1.05 12.94 1.62 13.66 2.34C14.38 3.06 14.95 3.90667 15.37 4.88C15.79 5.85333 16 6.89333 16 8C16 9.09333 15.79 10.1267 15.37 11.1C14.95 12.0733 14.38 12.9233 13.66 13.65C12.94 14.3767 12.0933 14.95 11.12 15.37C10.1467 15.79 9.10667 16 8 16ZM8 14.8C9.89333 14.8 11.5 14.1367 12.82 12.81C14.14 11.4833 14.8 9.88 14.8 8C14.8 6.10667 14.14 4.5 12.82 3.18C11.5 1.86 9.89333 1.2 8 1.2C6.12 1.2 4.51667 1.86 3.19 3.18C1.86333 4.5 1.2 6.10667 1.2 8C1.2 9.88 1.86333 11.4833 3.19 12.81C4.51667 14.1367 6.12 14.8 8 14.8Z"
fill="#ef4444"
/>
</g>
<defs>
<clipPath id="clip0_4052_100277">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);

View File

@ -0,0 +1,28 @@
import React from "react";
type Props = {
width?: string;
height?: string;
className?: string;
color?: string;
};
export const ModuleCompletedIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.80486 9.80731L4.84856 7.85103C4.73197 7.73443 4.58542 7.67478 4.4089 7.67208C4.23238 7.66937 4.08312 7.72902 3.96113 7.85103C3.83913 7.97302 3.77814 8.12093 3.77814 8.29474C3.77814 8.46855 3.83913 8.61645 3.96113 8.73844L6.27206 11.0494C6.42428 11.2016 6.60188 11.2777 6.80486 11.2777C7.00782 11.2777 7.18541 11.2016 7.33764 11.0494L12.0227 6.36435C12.1393 6.24776 12.1989 6.10121 12.2016 5.92469C12.2043 5.74817 12.1447 5.59891 12.0227 5.47692C11.9007 5.35493 11.7528 5.29393 11.579 5.29393C11.4051 5.29393 11.2572 5.35493 11.1353 5.47692L6.80486 9.80731ZM8.00141 16C6.89494 16 5.85491 15.79 4.88132 15.3701C3.90772 14.9502 3.06082 14.3803 2.34064 13.6604C1.62044 12.9405 1.05028 12.094 0.63017 11.1208C0.210057 10.1477 0 9.10788 0 8.00141C0 6.89494 0.209966 5.85491 0.629896 4.88132C1.04983 3.90772 1.61972 3.06082 2.33958 2.34064C3.05946 1.62044 3.90598 1.05028 4.87915 0.630171C5.8523 0.210058 6.89212 0 7.99859 0C9.10506 0 10.1451 0.209966 11.1187 0.629897C12.0923 1.04983 12.9392 1.61972 13.6594 2.33959C14.3796 3.05946 14.9497 3.90598 15.3698 4.87915C15.7899 5.8523 16 6.89212 16 7.99859C16 9.10506 15.79 10.1451 15.3701 11.1187C14.9502 12.0923 14.3803 12.9392 13.6604 13.6594C12.9405 14.3796 12.094 14.9497 11.1208 15.3698C10.1477 15.7899 9.10788 16 8.00141 16ZM8 14.7369C9.88071 14.7369 11.4737 14.0842 12.779 12.779C14.0842 11.4737 14.7369 9.88071 14.7369 8C14.7369 6.11929 14.0842 4.52631 12.779 3.22104C11.4737 1.91577 9.88071 1.26314 8 1.26314C6.11929 1.26314 4.52631 1.91577 3.22104 3.22104C1.91577 4.52631 1.26314 6.11929 1.26314 8C1.26314 9.88071 1.91577 11.4737 3.22104 12.779C4.52631 14.0842 6.11929 14.7369 8 14.7369Z"
fill="#16a34a"
/>
</svg>
);

View File

@ -0,0 +1,71 @@
import React from "react";
type Props = {
width?: string;
height?: string;
className?: string;
color?: string;
};
export const ModuleInProgressIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
}) => (
<svg
width={width}
height={height}
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 234.83 234.82"
>
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path fill="#f7b964" d="M0,111.14c.63.7.21,1.53.3,2.29-.07.26-.17.28-.3,0Z" />
<path fill="#f6ab3e" d="M0,119.46a3.11,3.11,0,0,1,.3,2q-.19.33-.3,0Z" />
<path
fill="#facf96"
d="M.27,123.16c0,.66.38,1.38-.27,2v-2C.13,122.89.22,122.91.27,123.16Z"
/>
<path fill="#f5a939" d="M0,113.47l.3,0a2.39,2.39,0,0,1-.3,1.71Z" />
<path fill="#f8ba67" d="M.27,123.16a.63.63,0,0,1-.27,0v-1.66l.3,0Z" />
<path
fill="#f39e1f"
d="M234.58,106.92a72,72,0,0,0-.65-8.42,117.08,117.08,0,0,0-13.46-38.74,118.87,118.87,0,0,0-31.73-36.49A115,115,0,0,0,151.17,4.14,83.24,83.24,0,0,0,134.28.58c-2.94-.24-5.89-.22-8.83-.58h-4a2.66,2.66,0,0,1-2,0h-4.32a3.45,3.45,0,0,1-2.33,0h-3.66c-.51.33-1.08.14-1.62.16A87.24,87.24,0,0,0,90,2.35,118.53,118.53,0,0,0,23.16,46,115.24,115.24,0,0,0,4.29,83,85.41,85.41,0,0,0,.6,100.15c-.26,3-.22,6-.6,9v2a6.63,6.63,0,0,1,.17,2.26c-.08.58.17,1.19-.17,1.74v4.32c.35.66.08,1.37.17,2.05v1.57c-.09.68.18,1.39-.17,2v.67c.3.39.14.85.16,1.28.2,3.18.22,6.38.66,9.53a101.21,101.21,0,0,0,4.27,17.76A118.17,118.17,0,0,0,99,234a100.25,100.25,0,0,0,11.37.65,167.86,167.86,0,0,0,23.84-.54,100.39,100.39,0,0,0,23.35-5.72,117.87,117.87,0,0,0,39.67-24.08,117.77,117.77,0,0,0,33.27-53.2,85.63,85.63,0,0,0,3.71-17.37A212.22,212.22,0,0,0,234.58,106.92ZM117.31,217a99.63,99.63,0,0,1-99.7-100.05c0-54.91,44.8-99.35,100.09-99.33,54.89,0,99.32,44.83,99.29,100.14C217,172.43,172.21,217,117.31,217Z"
/>
<path
fill="#f39e1f"
d="M117.33,44a84.49,84.49,0,0,1,12.9,1.15c1.09.19,1.37.56,1.15,1.6q-1.51,7.41-2.94,14.82c-.16.82-.45,1.11-1.33.95a53.31,53.31,0,0,0-19.67,0c-.77.14-1.11-.06-1.26-.83q-1.47-7.59-3-15.16c-.2-1,.21-1.19,1.08-1.35A80.7,80.7,0,0,1,117.33,44Z"
/>
<path
fill="#f39e1f"
d="M44,117.2a80.88,80.88,0,0,1,1.17-12.9c.18-1,.49-1.3,1.49-1.1q7.49,1.53,15,3c.89.18,1,.59.85,1.39a53.54,53.54,0,0,0,0,19.51c.15.83,0,1.2-.88,1.36-5,1-10,2-15,3-.85.17-1.25,0-1.43-1A82.68,82.68,0,0,1,44,117.2Z"
/>
<path
fill="#f39e1f"
d="M190.64,117.39a80.88,80.88,0,0,1-1.17,12.9c-.18,1-.46,1.32-1.48,1.11q-7.49-1.53-15-3c-.88-.17-1-.57-.86-1.38a53.54,53.54,0,0,0,0-19.51c-.18-1,.16-1.23,1-1.39q7.33-1.41,14.66-2.91c1-.21,1.46-.09,1.65,1.08A86.71,86.71,0,0,1,190.64,117.39Z"
/>
<path
fill="#f39e1f"
d="M117.28,190.64a83.24,83.24,0,0,1-12.9-1.15c-1.07-.19-1.38-.53-1.16-1.6q1.52-7.39,2.94-14.82c.16-.8.43-1.12,1.32-.95a53.31,53.31,0,0,0,19.67,0c.92-.17,1.14.2,1.29,1q1.44,7.42,2.95,14.82c.19.95,0,1.35-1,1.54A83,83,0,0,1,117.28,190.64Z"
/>
<path
fill="#f39e1f"
d="M70.7,86.15,70,85.74c-4.23-2.84-8.45-5.69-12.71-8.49-.76-.5-.93-.86-.36-1.67A75.59,75.59,0,0,1,75.41,57.11c.85-.6,1.29-.66,1.93.33,2.71,4.18,5.5,8.3,8.3,12.42.53.78.62,1.18-.28,1.81A54.6,54.6,0,0,0,71.68,85.32C71.07,86.18,71.05,86.17,70.7,86.15Z"
/>
<path
fill="#f39e1f"
d="M178,76.28c.05.58-.38.7-.69.9-4.27,2.87-8.55,5.72-12.82,8.6-.6.41-1,.47-1.44-.23a54.76,54.76,0,0,0-14-14c-.66-.46-.69-.8-.25-1.45q4.33-6.39,8.59-12.83c.47-.72.82-.84,1.56-.33A74.64,74.64,0,0,1,177.53,75.5C177.72,75.77,177.89,76.06,178,76.28Z"
/>
<path
fill="#f39e1f"
d="M70.68,148.46c.48-.11.59.27.77.52A55.65,55.65,0,0,0,85.59,163.1c.58.4.66.72.26,1.32q-4.38,6.47-8.69,13c-.41.63-.74.81-1.43.32a74.65,74.65,0,0,1-18.8-18.8c-.42-.61-.48-1,.23-1.46,4.34-2.87,8.65-5.78,13-8.67C70.32,148.67,70.52,148.56,70.68,148.46Z"
/>
<path
fill="#f39e1f"
d="M158.24,178.06c-.56-.08-.67-.5-.87-.8-2.84-4.23-5.66-8.47-8.52-12.68-.47-.69-.47-1,.29-1.56A54.46,54.46,0,0,0,163,149.11c.53-.77.9-.7,1.57-.24q6.33,4.29,12.7,8.49c.81.53.86.91.32,1.68a74.06,74.06,0,0,1-18.46,18.45C158.84,177.71,158.5,177.9,158.24,178.06Z"
/>
</g>
</g>
</svg>
);

View File

@ -0,0 +1,7 @@
export * from "./backlog";
export * from "./cancelled";
export * from "./completed";
export * from "./in-progress";
export * from "./module-status-icon";
export * from "./paused";
export * from "./planned";

View File

@ -0,0 +1,37 @@
// icons
import {
ModuleBacklogIcon,
ModuleCancelledIcon,
ModuleCompletedIcon,
ModuleInProgressIcon,
ModulePausedIcon,
ModulePlannedIcon,
} from "components/icons";
// types
import { TModuleStatus } from "types";
type Props = {
status: TModuleStatus;
className?: string;
height?: string;
width?: string;
};
export const ModuleStatusIcon: React.FC<Props> = ({
status,
className,
height = "12px",
width = "12px",
}) => {
if (status === "backlog")
return <ModuleBacklogIcon className={className} height={height} width={width} />;
else if (status === "cancelled")
return <ModuleCancelledIcon className={className} height={height} width={width} />;
else if (status === "completed")
return <ModuleCompletedIcon className={className} height={height} width={width} />;
else if (status === "in-progress")
return <ModuleInProgressIcon className={className} height={height} width={width} />;
else if (status === "paused")
return <ModulePausedIcon className={className} height={height} width={width} />;
else return <ModulePlannedIcon className={className} height={height} width={width} />;
};

View File

@ -0,0 +1,31 @@
import React from "react";
type Props = {
width?: string;
height?: string;
className?: string;
color?: string;
};
export const ModulePausedIcon: React.FC<Props> = ({ width = "20", height = "20", className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_4052_100275)">
<path
d="M6.4435 10.34C6.6145 10.34 6.75667 10.2825 6.87 10.1675C6.98333 10.0525 7.04 9.91 7.04 9.74V6.24C7.04 6.07 6.98217 5.9275 6.8665 5.8125C6.75082 5.6975 6.60749 5.64 6.4365 5.64C6.2655 5.64 6.12333 5.6975 6.01 5.8125C5.89667 5.9275 5.84 6.07 5.84 6.24V9.74C5.84 9.91 5.89783 10.0525 6.0135 10.1675C6.12918 10.2825 6.27251 10.34 6.4435 10.34ZM9.5635 10.34C9.7345 10.34 9.87667 10.2825 9.99 10.1675C10.1033 10.0525 10.16 9.91 10.16 9.74V6.24C10.16 6.07 10.1022 5.9275 9.9865 5.8125C9.87082 5.6975 9.72749 5.64 9.5565 5.64C9.3855 5.64 9.24333 5.6975 9.13 5.8125C9.01667 5.9275 8.96 6.07 8.96 6.24V9.74C8.96 9.91 9.01783 10.0525 9.1335 10.1675C9.24918 10.2825 9.39251 10.34 9.5635 10.34ZM8 16C6.89333 16 5.85333 15.79 4.88 15.37C3.90667 14.95 3.06 14.38 2.34 13.66C1.62 12.94 1.05 12.0933 0.63 11.12C0.21 10.1467 0 9.10667 0 8C0 7.54667 0.0366667 7.09993 0.11 6.6598C0.183333 6.21965 0.293333 5.78639 0.44 5.36C0.493333 5.21333 0.593333 5.11667 0.74 5.07C0.886667 5.02333 1.02667 5.04199 1.16 5.12596C1.30285 5.20993 1.40523 5.33327 1.46714 5.49596C1.52905 5.65865 1.54 5.82 1.5 5.98C1.42 6.31333 1.35 6.64765 1.29 6.98294C1.23 7.31823 1.2 7.65725 1.2 8C1.2 9.89833 1.85875 11.5063 3.17624 12.8238C4.49375 14.1413 6.10167 14.8 8 14.8C9.89833 14.8 11.5063 14.1413 12.8238 12.8238C14.1413 11.5063 14.8 9.89833 14.8 8C14.8 6.10167 14.1413 4.49375 12.8238 3.17624C11.5063 1.85875 9.89833 1.2 8 1.2C7.63235 1.2 7.26852 1.22667 6.90852 1.28C6.54852 1.33333 6.19235 1.41333 5.84 1.52C5.68 1.57333 5.52 1.56667 5.36 1.5C5.2 1.43333 5.08667 1.32667 5.02 1.18C4.95333 1.03333 4.96 0.886667 5.04 0.74C5.12 0.593333 5.23333 0.493333 5.38 0.44C5.79333 0.306667 6.21333 0.2 6.64 0.12C7.06667 0.04 7.49333 0 7.92 0C9.02667 0 10.07 0.21 11.05 0.63C12.03 1.05 12.8863 1.62 13.6189 2.34C14.3516 3.06 14.9316 3.90667 15.3589 4.88C15.7863 5.85333 16 6.89333 16 8C16 9.10667 15.79 10.1467 15.37 11.12C14.95 12.0933 14.38 12.94 13.66 13.66C12.94 14.38 12.0933 14.95 11.12 15.37C10.1467 15.79 9.10667 16 8 16ZM2.65764 3.62C2.37921 3.62 2.14333 3.52255 1.95 3.32764C1.75667 3.13275 1.66 2.89608 1.66 2.61764C1.66 2.33921 1.75745 2.10333 1.95236 1.91C2.14725 1.71667 2.38392 1.62 2.66236 1.62C2.94079 1.62 3.17667 1.71745 3.37 1.91236C3.56333 2.10725 3.66 2.34392 3.66 2.62236C3.66 2.90079 3.56255 3.13667 3.36764 3.33C3.17275 3.52333 2.93608 3.62 2.65764 3.62Z"
fill="#525252"
/>
</g>
<defs>
<clipPath id="clip0_4052_100275">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);

View File

@ -0,0 +1,24 @@
import React from "react";
type Props = {
width?: string;
height?: string;
className?: string;
color?: string;
};
export const ModulePlannedIcon: React.FC<Props> = ({ width = "20", height = "20", className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.57177 7.43329L11.3665 10.228C11.4883 10.3498 11.5441 10.4809 11.5339 10.6213C11.5238 10.7617 11.4578 10.8928 11.336 11.0146C11.2142 11.1364 11.0794 11.1973 10.9317 11.1973C10.784 11.1973 10.6492 11.1364 10.5274 11.0146L7.64476 8.12349C7.57709 8.05582 7.52408 7.98139 7.48574 7.90018C7.4474 7.81898 7.42823 7.72538 7.42823 7.61936V3.51362C7.42823 3.35574 7.48405 3.22097 7.5957 3.10932C7.70734 2.99768 7.84211 2.94185 8 2.94185C8.15789 2.94185 8.29266 2.99768 8.4043 3.10932C8.51595 3.22097 8.57177 3.35574 8.57177 3.51362V7.43329ZM0.806954 11.4933C0.573486 11.04 0.390212 10.5655 0.257131 10.0698C0.124064 9.57411 0.0383541 9.07477 0 8.57177H1.15709C1.18077 8.98793 1.24646 9.38746 1.35418 9.77036C1.46188 10.1533 1.60427 10.5297 1.78133 10.8996L0.806954 11.4933ZM0.021992 7.42823C0.0603462 6.92523 0.143806 6.4273 0.272371 5.93444C0.400937 5.4416 0.579131 4.96567 0.806954 4.50665L1.80333 5.07842C1.62062 5.44834 1.47315 5.82983 1.36093 6.22287C1.24872 6.61591 1.18077 7.0177 1.15709 7.42823H0.021992ZM3.52381 14.6128C3.10877 14.3286 2.71855 14.0103 2.35315 13.6578C1.98774 13.3054 1.66294 12.9217 1.37872 12.5067L2.3751 11.9044C2.5939 12.2507 2.84879 12.5656 3.13976 12.8492C3.43073 13.1329 3.74934 13.3914 4.09558 13.6249L3.52381 14.6128ZM2.3751 4.09558L1.37872 3.52381C1.66294 3.09411 1.98408 2.70163 2.34215 2.34637C2.70023 1.99112 3.09411 1.67139 3.52381 1.38719L4.09558 2.3751C3.75837 2.60856 3.44568 2.86571 3.15753 3.14653C2.86937 3.42736 2.60856 3.7437 2.3751 4.09558ZM7.42823 16C6.92523 15.9616 6.4273 15.8745 5.93444 15.7386C5.4416 15.6027 4.96567 15.4209 4.50665 15.193L5.07842 14.2187C5.44834 14.4014 5.82983 14.5452 6.22287 14.6501C6.61591 14.7549 7.0177 14.8192 7.42823 14.8429V16ZM5.10042 1.80333L4.50665 0.806954C4.96567 0.579131 5.4416 0.397272 5.93444 0.261376C6.4273 0.125479 6.92523 0.0383541 7.42823 0V1.15709C7.0177 1.18077 6.61957 1.24872 6.23386 1.36093C5.84815 1.47315 5.47034 1.62062 5.10042 1.80333ZM8.57177 16V14.8429C8.9823 14.8192 9.38409 14.7549 9.77713 14.6501C10.1702 14.5452 10.5517 14.4014 10.9216 14.2187L11.4933 15.193C11.0343 15.4209 10.5584 15.6027 10.0656 15.7386C9.5727 15.8745 9.07477 15.9616 8.57177 16ZM10.9216 1.78133C10.5517 1.59862 10.1702 1.45482 9.77713 1.34994C9.38409 1.24505 8.9823 1.18077 8.57177 1.15709V0C9.07477 0.0383541 9.5727 0.125479 10.0656 0.261376C10.5584 0.397272 11.0343 0.579131 11.4933 0.806954L10.9216 1.78133ZM12.4762 14.6128L11.9044 13.6469C12.2563 13.4134 12.5726 13.149 12.8535 12.8535C13.1343 12.558 13.3914 12.2416 13.6249 11.9044L14.6128 12.4982C14.3286 12.9132 14.0052 13.297 13.6426 13.6494C13.28 14.0018 12.8912 14.323 12.4762 14.6128ZM13.6249 4.08713C13.3914 3.74991 13.1306 3.43862 12.8425 3.15328C12.5543 2.86796 12.2416 2.60856 11.9044 2.3751L12.4762 1.38719C12.8912 1.67703 13.28 1.99817 13.6426 2.3506C14.0052 2.70304 14.3314 3.08678 14.6213 3.50181L13.6249 4.08713ZM14.8429 7.42823C14.8192 7.0177 14.7535 6.61957 14.6458 6.23386C14.5381 5.84815 14.3929 5.46752 14.2102 5.09197L15.193 4.50665C15.4265 4.96003 15.6098 5.43314 15.7429 5.926C15.8759 6.41884 15.9616 6.91958 16 7.42823H14.8429ZM15.193 11.4933L14.2187 10.9216C14.4014 10.5517 14.5452 10.1702 14.6501 9.77713C14.7549 9.38409 14.8192 8.9823 14.8429 8.57177H16C15.9616 9.07477 15.8745 9.5727 15.7386 10.0656C15.6027 10.5584 15.4209 11.0343 15.193 11.4933Z"
fill="#3f76ff"
/>
</svg>
);

View File

@ -21,6 +21,7 @@ export const getStateGroupIcon = (
width={width} width={width}
height={height} height={height}
color={color ?? STATE_GROUP_COLORS["backlog"]} color={color ?? STATE_GROUP_COLORS["backlog"]}
className="flex-shrink-0"
/> />
); );
case "unstarted": case "unstarted":
@ -29,6 +30,7 @@ export const getStateGroupIcon = (
width={width} width={width}
height={height} height={height}
color={color ?? STATE_GROUP_COLORS["unstarted"]} color={color ?? STATE_GROUP_COLORS["unstarted"]}
className="flex-shrink-0"
/> />
); );
case "started": case "started":
@ -37,6 +39,7 @@ export const getStateGroupIcon = (
width={width} width={width}
height={height} height={height}
color={color ?? STATE_GROUP_COLORS["started"]} color={color ?? STATE_GROUP_COLORS["started"]}
className="flex-shrink-0"
/> />
); );
case "completed": case "completed":
@ -45,6 +48,7 @@ export const getStateGroupIcon = (
width={width} width={width}
height={height} height={height}
color={color ?? STATE_GROUP_COLORS["completed"]} color={color ?? STATE_GROUP_COLORS["completed"]}
className="flex-shrink-0"
/> />
); );
case "cancelled": case "cancelled":
@ -53,6 +57,7 @@ export const getStateGroupIcon = (
width={width} width={width}
height={height} height={height}
color={color ?? STATE_GROUP_COLORS["cancelled"]} color={color ?? STATE_GROUP_COLORS["cancelled"]}
className="flex-shrink-0"
/> />
); );
default: default:

View File

@ -1,49 +1,57 @@
import React from "react"; import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// react-hook-form // react-hook-form
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
// components
import { TipTapEditor } from "components/tiptap";
// ui // ui
import { SecondaryButton } from "components/ui"; import { Icon, SecondaryButton, Tooltip } from "components/ui";
// types // types
import type { IIssueComment } from "types"; import type { IIssueComment } from "types";
// fetch-keys
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
(props, ref) => <Tiptap {...props} forwardedRef={ref} />
);
TiptapEditor.displayName = "TiptapEditor";
const defaultValues: Partial<IIssueComment> = { const defaultValues: Partial<IIssueComment> = {
comment_json: "", access: "INTERNAL",
comment_html: "", comment_html: "",
}; };
type Props = { type Props = {
disabled?: boolean; disabled?: boolean;
onSubmit: (data: IIssueComment) => Promise<void>; onSubmit: (data: IIssueComment) => Promise<void>;
showAccessSpecifier?: boolean;
}; };
export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit }) => { const commentAccess = [
const { {
control, icon: "lock",
formState: { isSubmitting }, key: "INTERNAL",
handleSubmit, label: "Private",
reset, },
setValue, {
watch, icon: "public",
} = useForm<IIssueComment>({ defaultValues }); key: "EXTERNAL",
label: "Public",
},
];
export const AddComment: React.FC<Props> = ({
disabled = false,
onSubmit,
showAccessSpecifier = false,
}) => {
const editorRef = React.useRef<any>(null); const editorRef = React.useRef<any>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const {
control,
formState: { isSubmitting },
handleSubmit,
reset,
} = useForm<IIssueComment>({ defaultValues });
const handleAddComment = async (formData: IIssueComment) => { const handleAddComment = async (formData: IIssueComment) => {
if (!formData.comment_html || !formData.comment_json || isSubmitting) return; if (!formData.comment_html || isSubmitting) return;
await onSubmit(formData).then(() => { await onSubmit(formData).then(() => {
reset(defaultValues); reset(defaultValues);
@ -54,30 +62,55 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit }) => {
return ( return (
<div> <div>
<form onSubmit={handleSubmit(handleAddComment)}> <form onSubmit={handleSubmit(handleAddComment)}>
<div className="issue-comments-section"> <div>
<Controller <div className="relative">
name="comment_html" {showAccessSpecifier && (
control={control} <div className="absolute bottom-2 left-3 z-[1]">
render={({ field: { value, onChange } }) => ( <Controller
<TiptapEditor control={control}
workspaceSlug={workspaceSlug as string} name="access"
ref={editorRef} render={({ field: { onChange, value } }) => (
value={ <div className="flex border border-custom-border-300 divide-x divide-custom-border-300 rounded overflow-hidden">
!value || {commentAccess.map((access) => (
value === "" || <Tooltip key={access.key} tooltipContent={access.label}>
(typeof value === "object" && Object.keys(value).length === 0) <button
? watch("comment_html") type="button"
: value onClick={() => onChange(access.key)}
} className={`grid place-items-center p-1 hover:bg-custom-background-80 ${
customClassName="p-3 min-h-[50px] shadow-sm" value === access.key ? "bg-custom-background-80" : ""
debouncedUpdatesEnabled={false} }`}
onChange={(comment_json: Object, comment_html: string) => { >
onChange(comment_html); <Icon
setValue("comment_json", comment_json); iconName={access.icon}
}} className={`w-4 h-4 -mt-1 ${
/> value === access.key
? "!text-custom-text-100"
: "!text-custom-text-400"
}`}
/>
</button>
</Tooltip>
))}
</div>
)}
/>
</div>
)} )}
/> <Controller
name="comment_html"
control={control}
render={({ field: { value, onChange } }) => (
<TipTapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
value={!value || value === "" ? "<p></p>" : value}
customClassName="p-3 min-h-[100px] shadow-sm"
debouncedUpdatesEnabled={false}
onChange={(comment_json: Object, comment_html: string) => onChange(comment_html)}
/>
)}
/>
</div>
<SecondaryButton type="submit" disabled={isSubmitting || disabled} className="mt-2"> <SecondaryButton type="submit" disabled={isSubmitting || disabled} className="mt-2">
{isSubmitting ? "Adding..." : "Comment"} {isSubmitting ? "Adding..." : "Comment"}

View File

@ -9,17 +9,11 @@ import useUser from "hooks/use-user";
// ui // ui
import { CustomMenu } from "components/ui"; import { CustomMenu } from "components/ui";
import { CommentReaction } from "components/issues"; import { CommentReaction } from "components/issues";
import { TipTapEditor } from "components/tiptap";
// helpers // helpers
import { timeAgo } from "helpers/date-time.helper"; import { timeAgo } from "helpers/date-time.helper";
// types // types
import type { IIssueComment } from "types"; import type { IIssueComment } from "types";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
(props, ref) => <Tiptap {...props} forwardedRef={ref} />
);
TiptapEditor.displayName = "TiptapEditor";
type Props = { type Props = {
workspaceSlug: string; workspaceSlug: string;
@ -28,7 +22,12 @@ type Props = {
handleCommentDeletion: (comment: string) => void; handleCommentDeletion: (comment: string) => void;
}; };
export const CommentCard: React.FC<Props> = ({ comment, workspaceSlug, onSubmit, handleCommentDeletion }) => { export const CommentCard: React.FC<Props> = ({
comment,
workspaceSlug,
onSubmit,
handleCommentDeletion,
}) => {
const { user } = useUser(); const { user } = useUser();
const editorRef = React.useRef<any>(null); const editorRef = React.useRef<any>(null);
@ -109,7 +108,7 @@ export const CommentCard: React.FC<Props> = ({ comment, workspaceSlug, onSubmit,
onSubmit={handleSubmit(onEnter)} onSubmit={handleSubmit(onEnter)}
> >
<div> <div>
<TiptapEditor <TipTapEditor
workspaceSlug={workspaceSlug as string} workspaceSlug={workspaceSlug as string}
ref={editorRef} ref={editorRef}
value={watch("comment_html")} value={watch("comment_html")}
@ -139,7 +138,7 @@ export const CommentCard: React.FC<Props> = ({ comment, workspaceSlug, onSubmit,
</div> </div>
</form> </form>
<div className={`${isEditing ? "hidden" : ""}`}> <div className={`${isEditing ? "hidden" : ""}`}>
<TiptapEditor <TipTapEditor
workspaceSlug={workspaceSlug as string} workspaceSlug={workspaceSlug as string}
ref={showEditorRef} ref={showEditorRef}
value={comment.comment_html} value={comment.comment_html}

View File

@ -7,7 +7,7 @@ import useReloadConfirmations from "hooks/use-reload-confirmation";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
// components // components
import { TextArea } from "components/ui"; import { TextArea } from "components/ui";
import Tiptap from "components/tiptap"; import { TipTapEditor } from "components/tiptap";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
@ -134,7 +134,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
if (!value) return <></>; if (!value) return <></>;
return ( return (
<Tiptap <TipTapEditor
value={ value={
!value || !value ||
value === "" || value === "" ||

View File

@ -31,18 +31,11 @@ import {
SecondaryButton, SecondaryButton,
ToggleSwitch, ToggleSwitch,
} from "components/ui"; } from "components/ui";
import { TipTapEditor } from "components/tiptap";
// icons // icons
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
// types // types
import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types"; import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
// rich-text-editor
const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
(props, ref) => <Tiptap {...props} forwardedRef={ref} />
);
TiptapEditor.displayName = "TiptapEditor";
const defaultValues: Partial<IIssue> = { const defaultValues: Partial<IIssue> = {
project: "", project: "",
@ -369,7 +362,7 @@ export const IssueForm: FC<IssueFormProps> = ({
if (!value && !watch("description_html")) return <></>; if (!value && !watch("description_html")) return <></>;
return ( return (
<TiptapEditor <TipTapEditor
workspaceSlug={workspaceSlug as string} workspaceSlug={workspaceSlug as string}
ref={editorRef} ref={editorRef}
debouncedUpdatesEnabled={false} debouncedUpdatesEnabled={false}

View File

@ -0,0 +1,67 @@
import { useRouter } from "next/router";
// ui
import { Tooltip } from "components/ui";
// icons
import { getStateGroupIcon } from "components/icons";
// helpers
import { findTotalDaysInRange, renderShortDate } from "helpers/date-time.helper";
// types
import { IIssue } from "types";
export const IssueGanttBlock = ({ data }: { data: IIssue }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<div
className="flex items-center relative h-full w-full rounded cursor-pointer"
style={{ backgroundColor: data?.state_detail?.color }}
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)}
>
<div className="absolute top-0 left-0 h-full w-full bg-custom-background-100/50" />
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{data?.name}</h5>
<div>
{renderShortDate(data?.start_date ?? "")} to{" "}
{renderShortDate(data?.target_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="relative text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{data?.name}
</div>
</Tooltip>
</div>
);
};
// rendering issues on gantt sidebar
export const IssueGanttSidebarBlock = ({ data }: { data: IIssue }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const duration = findTotalDaysInRange(data?.start_date ?? "", data?.target_date ?? "", true);
return (
<div
className="relative w-full flex items-center gap-2 h-full cursor-pointer"
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)}
>
{getStateGroupIcon(data?.state_detail?.group, "14", "14", data?.state_detail?.color)}
<div className="text-xs text-custom-text-300 flex-shrink-0">
{data?.project_detail?.identifier} {data?.sequence_id}
</div>
<div className="flex items-center justify-between gap-2 w-full flex-grow truncate">
<h6 className="text-sm font-medium flex-grow truncate">{data?.name}</h6>
<span className="flex-shrink-0 text-sm text-custom-text-200">
{duration} day{duration > 1 ? "s" : ""}
</span>
</div>
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./blocks";
export * from "./layout";

View File

@ -6,11 +6,8 @@ import useUser from "hooks/use-user";
import useGanttChartIssues from "hooks/gantt-chart/issue-view"; import useGanttChartIssues from "hooks/gantt-chart/issue-view";
import { updateGanttIssue } from "components/gantt-chart/hooks/block-update"; import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
// components // components
import { import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
GanttChartRoot, import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
IssueGanttBlock,
renderIssueBlocksStructure,
} from "components/gantt-chart";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
@ -27,17 +24,6 @@ export const IssueGanttChartView = () => {
projectId as string projectId as string
); );
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "#rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
return ( return (
<div className="w-full h-full"> <div className="w-full h-full">
<GanttChartRoot <GanttChartRoot
@ -48,9 +34,10 @@ export const IssueGanttChartView = () => {
blockUpdateHandler={(block, payload) => blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
} }
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />} BlockRender={IssueGanttBlock}
blockRender={(data: any) => <IssueGanttBlock issue={data as IIssue} />} SidebarBlockRender={IssueGanttSidebarBlock}
enableReorder={orderBy === "sort_order"} enableReorder={orderBy === "sort_order"}
bottomSpacing
/> />
</div> </div>
); );

View File

@ -8,6 +8,7 @@ import issuesService from "services/issues.service";
// hooks // hooks
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useProjectDetails from "hooks/use-project-details";
// contexts // contexts
import { useProjectMyMembership } from "contexts/project-member.context"; import { useProjectMyMembership } from "contexts/project-member.context";
// components // components
@ -49,6 +50,8 @@ export const IssueMainContent: React.FC<Props> = ({
const { user } = useUserAuth(); const { user } = useUserAuth();
const { memberRole } = useProjectMyMembership(); const { memberRole } = useProjectMyMembership();
const { projectDetails } = useProjectDetails();
const { data: siblingIssues } = useSWR( const { data: siblingIssues } = useSWR(
workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null, workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null,
workspaceSlug && projectId && issueDetails?.parent workspaceSlug && projectId && issueDetails?.parent
@ -220,7 +223,11 @@ export const IssueMainContent: React.FC<Props> = ({
handleCommentUpdate={handleCommentUpdate} handleCommentUpdate={handleCommentUpdate}
handleCommentDelete={handleCommentDelete} handleCommentDelete={handleCommentDelete}
/> />
<AddComment onSubmit={handleAddComment} disabled={uneditable} /> <AddComment
onSubmit={handleAddComment}
disabled={uneditable}
showAccessSpecifier={projectDetails && projectDetails.is_deployed}
/>
</div> </div>
</> </>
); );

View File

@ -1,3 +1,4 @@
// components
import { import {
PeekOverviewHeader, PeekOverviewHeader,
PeekOverviewIssueActivity, PeekOverviewIssueActivity,
@ -5,13 +6,16 @@ import {
PeekOverviewIssueProperties, PeekOverviewIssueProperties,
TPeekOverviewModes, TPeekOverviewModes,
} from "components/issues"; } from "components/issues";
// ui
import { Loader } from "components/ui";
// types
import { IIssue } from "types"; import { IIssue } from "types";
type Props = { type Props = {
handleClose: () => void; handleClose: () => void;
handleDeleteIssue: () => void; handleDeleteIssue: () => void;
handleUpdateIssue: (issue: Partial<IIssue>) => Promise<void>; handleUpdateIssue: (formData: Partial<IIssue>) => Promise<void>;
issue: IIssue; issue: IIssue | undefined;
mode: TPeekOverviewModes; mode: TPeekOverviewModes;
readOnly: boolean; readOnly: boolean;
setMode: (mode: TPeekOverviewModes) => void; setMode: (mode: TPeekOverviewModes) => void;
@ -40,39 +44,59 @@ export const FullScreenPeekView: React.FC<Props> = ({
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
/> />
</div> </div>
<div className="h-full w-full px-6 overflow-y-auto"> {issue ? (
{/* issue title and description */} <div className="h-full w-full px-6 overflow-y-auto">
<div className="w-full"> {/* issue title and description */}
<PeekOverviewIssueDetails <div className="w-full">
handleUpdateIssue={handleUpdateIssue} <PeekOverviewIssueDetails
issue={issue} handleUpdateIssue={handleUpdateIssue}
readOnly={readOnly} issue={issue}
workspaceSlug={workspaceSlug} readOnly={readOnly}
/> workspaceSlug={workspaceSlug}
/>
</div>
{/* divider */}
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
{/* issue activity/comments */}
<div className="w-full pb-5">
<PeekOverviewIssueActivity
workspaceSlug={workspaceSlug}
issue={issue}
readOnly={readOnly}
/>
</div>
</div> </div>
{/* divider */} ) : (
<div className="h-[1] w-full border-t border-custom-border-200 my-5" /> <Loader className="px-6">
{/* issue activity/comments */} <Loader.Item height="30px" />
<div className="w-full"> <div className="space-y-2 mt-3">
<PeekOverviewIssueActivity <Loader.Item height="20px" width="70%" />
workspaceSlug={workspaceSlug} <Loader.Item height="20px" width="60%" />
issue={issue} <Loader.Item height="20px" width="60%" />
readOnly={readOnly} </div>
/> </Loader>
</div> )}
</div>
</div> </div>
<div className="col-span-3 h-full w-full overflow-y-auto"> <div className="col-span-3 h-full w-full overflow-y-auto">
{/* issue properties */} {/* issue properties */}
<div className="w-full px-6 py-5"> <div className="w-full px-6 py-5">
<PeekOverviewIssueProperties {issue ? (
handleDeleteIssue={handleDeleteIssue} <PeekOverviewIssueProperties
issue={issue} handleDeleteIssue={handleDeleteIssue}
mode="full" handleUpdateIssue={handleUpdateIssue}
onChange={handleUpdateIssue} issue={issue}
readOnly={readOnly} mode="full"
workspaceSlug={workspaceSlug} readOnly={readOnly}
/> workspaceSlug={workspaceSlug}
/>
) : (
<Loader className="mt-11 space-y-4">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,18 +1,21 @@
import Link from "next/link";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// ui // ui
import { CustomSelect, Icon } from "components/ui"; import { CustomSelect, Icon } from "components/ui";
// icons
import { East, OpenInFull } from "@mui/icons-material";
// helpers // helpers
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
import { TPeekOverviewModes } from "./layout"; import { TPeekOverviewModes } from "./layout";
import { ArrowRightAlt, CloseFullscreen, East, OpenInFull } from "@mui/icons-material";
type Props = { type Props = {
handleClose: () => void; handleClose: () => void;
handleDeleteIssue: () => void; handleDeleteIssue: () => void;
issue: IIssue; issue: IIssue | undefined;
mode: TPeekOverviewModes; mode: TPeekOverviewModes;
setMode: (mode: TPeekOverviewModes) => void; setMode: (mode: TPeekOverviewModes) => void;
workspaceSlug: string; workspaceSlug: string;
@ -47,12 +50,9 @@ export const PeekOverviewHeader: React.FC<Props> = ({
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const handleCopyLink = () => { const handleCopyLink = () => {
const originURL = const urlToCopy = window.location.href;
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard( copyTextToClipboard(urlToCopy).then(() => {
`${originURL}/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`
).then(() => {
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Link copied!", title: "Link copied!",
@ -73,23 +73,15 @@ export const PeekOverviewHeader: React.FC<Props> = ({
/> />
</button> </button>
)} )}
{mode === "modal" || mode === "full" ? ( <Link href={`/${workspaceSlug}/projects/${issue?.project}/issues/${issue?.id}`}>
<button type="button" onClick={() => setMode("side")}> <a>
<CloseFullscreen
sx={{
fontSize: "14px",
}}
/>
</button>
) : (
<button type="button" onClick={() => setMode("modal")}>
<OpenInFull <OpenInFull
sx={{ sx={{
fontSize: "14px", fontSize: "14px",
}} }}
/> />
</button> </a>
)} </Link>
<CustomSelect <CustomSelect
value={mode} value={mode}
onChange={(val: TPeekOverviewModes) => setMode(val)} onChange={(val: TPeekOverviewModes) => setMode(val)}
@ -119,7 +111,7 @@ export const PeekOverviewHeader: React.FC<Props> = ({
</CustomSelect> </CustomSelect>
</div> </div>
{(mode === "side" || mode === "modal") && ( {(mode === "side" || mode === "modal") && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-shrink-0">
<button type="button" onClick={handleCopyLink} className="-rotate-45"> <button type="button" onClick={handleCopyLink} className="-rotate-45">
<Icon iconName="link" /> <Icon iconName="link" />
</button> </button>

View File

@ -1,6 +1,11 @@
// mobx
import { observer } from "mobx-react-lite";
// headless ui // headless ui
import { Disclosure } from "@headlessui/react"; import { Disclosure } from "@headlessui/react";
import { getStateGroupIcon } from "components/icons"; import { getStateGroupIcon } from "components/icons";
// hooks
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// components // components
import { import {
SidebarAssigneeSelect, SidebarAssigneeSelect,
@ -9,27 +14,27 @@ import {
SidebarStateSelect, SidebarStateSelect,
TPeekOverviewModes, TPeekOverviewModes,
} from "components/issues"; } from "components/issues";
// icons // ui
import { CustomDatePicker, Icon } from "components/ui"; import { CustomDatePicker, Icon } from "components/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
import useToast from "hooks/use-toast";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
type Props = { type Props = {
handleDeleteIssue: () => void; handleDeleteIssue: () => void;
handleUpdateIssue: (formData: Partial<IIssue>) => Promise<void>;
issue: IIssue; issue: IIssue;
mode: TPeekOverviewModes; mode: TPeekOverviewModes;
onChange: (issueProperty: Partial<IIssue>) => void;
readOnly: boolean; readOnly: boolean;
workspaceSlug: string; workspaceSlug: string;
}; };
export const PeekOverviewIssueProperties: React.FC<Props> = ({ export const PeekOverviewIssueProperties: React.FC<Props> = ({
handleDeleteIssue, handleDeleteIssue,
handleUpdateIssue,
issue, issue,
mode, mode,
onChange,
readOnly, readOnly,
workspaceSlug, workspaceSlug,
}) => { }) => {
@ -86,7 +91,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({
<div className="w-3/4"> <div className="w-3/4">
<SidebarStateSelect <SidebarStateSelect
value={issue.state} value={issue.state}
onChange={(val: string) => onChange({ state: val })} onChange={(val: string) => handleUpdateIssue({ state: val })}
disabled={readOnly} disabled={readOnly}
/> />
</div> </div>
@ -99,7 +104,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({
<div className="w-3/4"> <div className="w-3/4">
<SidebarAssigneeSelect <SidebarAssigneeSelect
value={issue.assignees_list} value={issue.assignees_list}
onChange={(val: string[]) => onChange({ assignees_list: val })} onChange={(val: string[]) => handleUpdateIssue({ assignees_list: val })}
disabled={readOnly} disabled={readOnly}
/> />
</div> </div>
@ -112,7 +117,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({
<div className="w-3/4"> <div className="w-3/4">
<SidebarPrioritySelect <SidebarPrioritySelect
value={issue.priority} value={issue.priority}
onChange={(val: string) => onChange({ priority: val })} onChange={(val: string) => handleUpdateIssue({ priority: val })}
disabled={readOnly} disabled={readOnly}
/> />
</div> </div>
@ -128,7 +133,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({
placeholder="Start date" placeholder="Start date"
value={issue.start_date} value={issue.start_date}
onChange={(val) => onChange={(val) =>
onChange({ handleUpdateIssue({
start_date: val, start_date: val,
}) })
} }
@ -153,7 +158,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({
placeholder="Due date" placeholder="Due date"
value={issue.target_date} value={issue.target_date}
onChange={(val) => onChange={(val) =>
onChange({ handleUpdateIssue({
target_date: val, target_date: val,
}) })
} }
@ -175,7 +180,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({
<div className="w-3/4"> <div className="w-3/4">
<SidebarEstimateSelect <SidebarEstimateSelect
value={issue.estimate_point} value={issue.estimate_point}
onChange={(val: number | null) => onChange({ estimate_point: val })} onChange={(val: number | null) =>handleUpdateIssue({ estimate_point: val })}
disabled={readOnly} disabled={readOnly}
/> />
</div> </div>

View File

@ -1,107 +1,184 @@
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// headless ui // headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// hooks
import useUser from "hooks/use-user";
// components
import { FullScreenPeekView, SidePeekView } from "components/issues"; import { FullScreenPeekView, SidePeekView } from "components/issues";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
type Props = { type Props = {
handleDeleteIssue: () => void; handleMutation: () => void;
handleUpdateIssue: (issue: Partial<IIssue>) => Promise<void>; projectId: string;
issue: IIssue | null;
isOpen: boolean;
onClose: () => void;
workspaceSlug: string;
readOnly: boolean; readOnly: boolean;
workspaceSlug: string;
}; };
export type TPeekOverviewModes = "side" | "modal" | "full"; export type TPeekOverviewModes = "side" | "modal" | "full";
export const IssuePeekOverview: React.FC<Props> = ({ export const IssuePeekOverview: React.FC<Props> = observer(
handleDeleteIssue, ({ handleMutation, projectId, readOnly, workspaceSlug }) => {
handleUpdateIssue, const [isSidePeekOpen, setIsSidePeekOpen] = useState(false);
issue, const [isModalPeekOpen, setIsModalPeekOpen] = useState(false);
isOpen, const [peekOverviewMode, setPeekOverviewMode] = useState<TPeekOverviewModes>("side");
onClose,
workspaceSlug,
readOnly,
}) => {
const [peekOverviewMode, setPeekOverviewMode] = useState<TPeekOverviewModes>("side");
const handleClose = () => { const router = useRouter();
onClose(); const { peekIssue } = router.query;
setPeekOverviewMode("side");
};
if (!issue || !isOpen) return null; const { issues: issuesStore } = useMobxStore();
const { deleteIssue, getIssueById, issues, updateIssue } = issuesStore;
return ( const issue = issues[peekIssue?.toString() ?? ""];
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}> const { user } = useUser();
{/* add backdrop conditionally */}
{(peekOverviewMode === "modal" || peekOverviewMode === "full") && ( const handleClose = () => {
<Transition.Child const { query } = router;
as={React.Fragment} delete query.peekIssue;
enter="ease-out duration-300"
enterFrom="opacity-0" router.push({
enterTo="opacity-100" pathname: router.pathname,
leave="ease-in duration-200" query: { ...query },
leaveFrom="opacity-100" });
leaveTo="opacity-0" };
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" /> const handleUpdateIssue = async (formData: Partial<IIssue>) => {
</Transition.Child> if (!issue || !user) return;
)}
<div className="fixed inset-0 z-20 overflow-y-auto"> await updateIssue(workspaceSlug, projectId, issue.id, formData, user);
<div className="relative h-full w-full"> handleMutation();
};
const handleDeleteIssue = async () => {
if (!issue || !user) return;
await deleteIssue(workspaceSlug, projectId, issue.id, user);
handleMutation();
handleClose();
};
useEffect(() => {
if (!peekIssue) return;
getIssueById(workspaceSlug, projectId, peekIssue.toString());
}, [getIssueById, peekIssue, projectId, workspaceSlug]);
useEffect(() => {
if (peekIssue) {
if (peekOverviewMode === "side") {
setIsSidePeekOpen(true);
setIsModalPeekOpen(false);
} else {
setIsModalPeekOpen(true);
setIsSidePeekOpen(false);
}
} else {
console.log("Triggered");
setIsSidePeekOpen(false);
setIsModalPeekOpen(false);
}
}, [peekIssue, peekOverviewMode]);
return (
<>
<Transition.Root appear show={isSidePeekOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="relative h-full w-full">
<Transition.Child
as={React.Fragment}
enter="transition-transform duration-300"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition-transform duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<Dialog.Panel className="absolute z-20 bg-custom-background-100 top-0 right-0 h-full w-1/2 shadow-custom-shadow-md">
<SidePeekView
handleClose={handleClose}
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode={peekOverviewMode}
readOnly={readOnly}
setMode={(mode) => setPeekOverviewMode(mode)}
workspaceSlug={workspaceSlug}
/>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
<Transition.Root appear show={isModalPeekOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child <Transition.Child
as={React.Fragment} as={React.Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" enterFrom="opacity-0"
enterTo="opacity-100 translate-y-0 sm:scale-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0"
> >
<Dialog.Panel <div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
className={`absolute z-20 bg-custom-background-100 ${
peekOverviewMode === "side"
? "top-0 right-0 h-full w-1/2 shadow-custom-shadow-md"
: peekOverviewMode === "modal"
? "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[70%] w-3/5 rounded-lg shadow-custom-shadow-xl"
: "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[95%] w-[95%] rounded-lg shadow-custom-shadow-xl"
}`}
>
{(peekOverviewMode === "side" || peekOverviewMode === "modal") && (
<SidePeekView
handleClose={handleClose}
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode={peekOverviewMode}
readOnly={readOnly}
setMode={(mode) => setPeekOverviewMode(mode)}
workspaceSlug={workspaceSlug}
/>
)}
{peekOverviewMode === "full" && (
<FullScreenPeekView
handleClose={handleClose}
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode={peekOverviewMode}
readOnly={readOnly}
setMode={(mode) => setPeekOverviewMode(mode)}
workspaceSlug={workspaceSlug}
/>
)}
</Dialog.Panel>
</Transition.Child> </Transition.Child>
</div> <div className="fixed inset-0 z-20 overflow-y-auto">
</div> <div className="relative h-full w-full">
</Dialog> <Transition.Child
</Transition.Root> as={React.Fragment}
); enter="ease-out duration-300"
}; enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Panel
className={`absolute z-20 bg-custom-background-100 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg shadow-custom-shadow-xl transition-all duration-300 ${
peekOverviewMode === "modal" ? "h-[70%] w-3/5" : "h-[95%] w-[95%]"
}`}
>
{peekOverviewMode === "modal" && (
<SidePeekView
handleClose={handleClose}
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode={peekOverviewMode}
readOnly={readOnly}
setMode={(mode) => setPeekOverviewMode(mode)}
workspaceSlug={workspaceSlug}
/>
)}
{peekOverviewMode === "full" && (
<FullScreenPeekView
handleClose={handleClose}
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode={peekOverviewMode}
readOnly={readOnly}
setMode={(mode) => setPeekOverviewMode(mode)}
workspaceSlug={workspaceSlug}
/>
)}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
</>
);
}
);

View File

@ -1,3 +1,4 @@
// components
import { import {
PeekOverviewHeader, PeekOverviewHeader,
PeekOverviewIssueActivity, PeekOverviewIssueActivity,
@ -5,13 +6,16 @@ import {
PeekOverviewIssueProperties, PeekOverviewIssueProperties,
TPeekOverviewModes, TPeekOverviewModes,
} from "components/issues"; } from "components/issues";
// ui
import { Loader } from "components/ui";
// types
import { IIssue } from "types"; import { IIssue } from "types";
type Props = { type Props = {
handleClose: () => void; handleClose: () => void;
handleDeleteIssue: () => void; handleDeleteIssue: () => void;
handleUpdateIssue: (issue: Partial<IIssue>) => Promise<void>; handleUpdateIssue: (formData: Partial<IIssue>) => Promise<void>;
issue: IIssue; issue: IIssue | undefined;
mode: TPeekOverviewModes; mode: TPeekOverviewModes;
readOnly: boolean; readOnly: boolean;
setMode: (mode: TPeekOverviewModes) => void; setMode: (mode: TPeekOverviewModes) => void;
@ -39,37 +43,50 @@ export const SidePeekView: React.FC<Props> = ({
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
/> />
</div> </div>
<div className="h-full w-full px-6 overflow-y-auto"> {issue ? (
{/* issue title and description */} <div className="h-full w-full px-6 overflow-y-auto">
<div className="w-full"> {/* issue title and description */}
<PeekOverviewIssueDetails <div className="w-full">
handleUpdateIssue={handleUpdateIssue} <PeekOverviewIssueDetails
issue={issue} handleUpdateIssue={handleUpdateIssue}
readOnly={readOnly} issue={issue}
workspaceSlug={workspaceSlug} readOnly={readOnly}
/> workspaceSlug={workspaceSlug}
/>
</div>
{/* issue properties */}
<div className="w-full mt-10">
<PeekOverviewIssueProperties
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode={mode}
readOnly={readOnly}
workspaceSlug={workspaceSlug}
/>
</div>
{/* divider */}
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
{/* issue activity/comments */}
<div className="w-full pb-5">
{issue && (
<PeekOverviewIssueActivity
workspaceSlug={workspaceSlug}
issue={issue}
readOnly={readOnly}
/>
)}
</div>
</div> </div>
{/* issue properties */} ) : (
<div className="w-full mt-10"> <Loader className="px-6">
<PeekOverviewIssueProperties <Loader.Item height="30px" />
handleDeleteIssue={handleDeleteIssue} <div className="space-y-2 mt-3">
issue={issue} <Loader.Item height="20px" width="70%" />
mode={mode} <Loader.Item height="20px" width="60%" />
onChange={handleUpdateIssue} <Loader.Item height="20px" width="60%" />
readOnly={readOnly} </div>
workspaceSlug={workspaceSlug} </Loader>
/> )}
</div>
{/* divider */}
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
{/* issue activity/comments */}
<div className="w-full pb-5">
<PeekOverviewIssueActivity
workspaceSlug={workspaceSlug}
issue={issue}
readOnly={readOnly}
/>
</div>
</div>
</div> </div>
); );

View File

@ -48,10 +48,10 @@ export const SidebarAssigneeSelect: React.FC<Props> = ({ value, onChange, disabl
{value && value.length > 0 && Array.isArray(value) ? ( {value && value.length > 0 && Array.isArray(value) ? (
<div className="-my-0.5 flex items-center gap-2"> <div className="-my-0.5 flex items-center gap-2">
<AssigneesList userIds={value} length={3} showLength={false} /> <AssigneesList userIds={value} length={3} showLength={false} />
<span className="text-custom-text-100 text-sm">{value.length} Assignees</span> <span className="text-custom-text-100 text-xs">{value.length} Assignees</span>
</div> </div>
) : ( ) : (
<button type="button" className="bg-custom-background-80 px-2.5 py-0.5 text-sm rounded"> <button type="button" className="bg-custom-background-80 px-2.5 py-0.5 text-xs rounded">
No assignees No assignees
</button> </button>
)} )}

View File

@ -18,7 +18,6 @@ type Props = {
issueId?: string; issueId?: string;
submitChanges: (formData: Partial<IIssue>) => void; submitChanges: (formData: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>; watch: UseFormWatch<IIssue>;
userAuth: UserAuth;
disabled?: boolean; disabled?: boolean;
}; };
@ -26,7 +25,6 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
issueId, issueId,
submitChanges, submitChanges,
watch, watch,
userAuth,
disabled = false, disabled = false,
}) => { }) => {
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
@ -73,8 +71,6 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
handleClose(); handleClose();
}; };
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return ( return (
<> <>
<ExistingIssuesListModal <ExistingIssuesListModal
@ -128,11 +124,11 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
</div> </div>
<button <button
type="button" type="button"
className={`flex w-full text-custom-text-200 ${ className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80" disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
} items-center justify-between gap-1 rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm duration-300 focus:outline-none`} }`}
onClick={() => setIsBlockedModalOpen(true)} onClick={() => setIsBlockedModalOpen(true)}
disabled={isNotAllowed} disabled={disabled}
> >
Select issues Select issues
</button> </button>

View File

@ -18,7 +18,6 @@ type Props = {
issueId?: string; issueId?: string;
submitChanges: (formData: Partial<IIssue>) => void; submitChanges: (formData: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>; watch: UseFormWatch<IIssue>;
userAuth: UserAuth;
disabled?: boolean; disabled?: boolean;
}; };
@ -26,7 +25,6 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
issueId, issueId,
submitChanges, submitChanges,
watch, watch,
userAuth,
disabled = false, disabled = false,
}) => { }) => {
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false); const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
@ -73,8 +71,6 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
handleClose(); handleClose();
}; };
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return ( return (
<> <>
<ExistingIssuesListModal <ExistingIssuesListModal
@ -130,11 +126,11 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
</div> </div>
<button <button
type="button" type="button"
className={`flex w-full text-custom-text-200 ${ className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80" disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
} items-center justify-between gap-1 rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm duration-300 focus:outline-none`} }`}
onClick={() => setIsBlockerModalOpen(true)} onClick={() => setIsBlockerModalOpen(true)}
disabled={isNotAllowed} disabled={disabled}
> >
Select issues Select issues
</button> </button>

View File

@ -11,24 +11,20 @@ import cyclesService from "services/cycles.service";
import { Spinner, CustomSelect, Tooltip } from "components/ui"; import { Spinner, CustomSelect, Tooltip } from "components/ui";
// helper // helper
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
// icons
import { ContrastIcon } from "components/icons";
// types // types
import { ICycle, IIssue, UserAuth } from "types"; import { ICycle, IIssue } from "types";
// fetch-keys // fetch-keys
import { CYCLE_ISSUES, INCOMPLETE_CYCLES_LIST, ISSUE_DETAILS } from "constants/fetch-keys"; import { CYCLE_ISSUES, INCOMPLETE_CYCLES_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
type Props = { type Props = {
issueDetail: IIssue | undefined; issueDetail: IIssue | undefined;
handleCycleChange: (cycle: ICycle) => void; handleCycleChange: (cycle: ICycle) => void;
userAuth: UserAuth;
disabled?: boolean; disabled?: boolean;
}; };
export const SidebarCycleSelect: React.FC<Props> = ({ export const SidebarCycleSelect: React.FC<Props> = ({
issueDetail, issueDetail,
handleCycleChange, handleCycleChange,
userAuth,
disabled = false, disabled = false,
}) => { }) => {
const router = useRouter(); const router = useRouter();
@ -63,59 +59,56 @@ export const SidebarCycleSelect: React.FC<Props> = ({
const issueCycle = issueDetail?.issue_cycle; const issueCycle = issueDetail?.issue_cycle;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return ( return (
<div className="flex flex-wrap items-center py-2"> <CustomSelect
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2"> customButton={
<ContrastIcon className="h-4 w-4 flex-shrink-0" /> <Tooltip
<p>Cycle</p> position="left"
</div> tooltipContent={`${issueCycle ? issueCycle.cycle_detail.name : "No cycle"}`}
<div className="space-y-1 sm:basis-1/2">
<CustomSelect
label={
<Tooltip
position="left"
tooltipContent={`${issueCycle ? issueCycle.cycle_detail.name : "No cycle"}`}
>
<span className="w-full max-w-[125px] truncate text-left sm:block">
<span className={`${issueCycle ? "text-custom-text-100" : "text-custom-text-200"}`}>
{issueCycle ? truncateText(issueCycle.cycle_detail.name, 15) : "No cycle"}
</span>
</span>
</Tooltip>
}
value={issueCycle ? issueCycle.cycle_detail.id : null}
onChange={(value: any) => {
!value
? removeIssueFromCycle(issueCycle?.id ?? "", issueCycle?.cycle ?? "")
: handleCycleChange(incompleteCycles?.find((c) => c.id === value) as ICycle);
}}
width="w-full"
position="right"
maxHeight="rg"
disabled={isNotAllowed}
> >
{incompleteCycles ? ( <button
incompleteCycles.length > 0 ? ( type="button"
<> className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 w-full flex ${
{incompleteCycles.map((option) => ( disabled ? "cursor-not-allowed" : ""
<CustomSelect.Option key={option.id} value={option.id}> }`}
<Tooltip position="left-bottom" tooltipContent={option.name}> >
<span className="w-full truncate">{truncateText(option.name, 25)}</span> <span
</Tooltip> className={`truncate ${issueCycle ? "text-custom-text-100" : "text-custom-text-200"}`}
</CustomSelect.Option> >
))} {issueCycle ? issueCycle.cycle_detail.name : "No cycle"}
<CustomSelect.Option value={null}>None</CustomSelect.Option> </span>
</> </button>
) : ( </Tooltip>
<div className="text-center">No cycles found</div> }
) value={issueCycle ? issueCycle.cycle_detail.id : null}
) : ( onChange={(value: any) => {
<Spinner /> !value
)} ? removeIssueFromCycle(issueCycle?.id ?? "", issueCycle?.cycle ?? "")
</CustomSelect> : handleCycleChange(incompleteCycles?.find((c) => c.id === value) as ICycle);
</div> }}
</div> width="w-full"
position="right"
maxHeight="rg"
disabled={disabled}
>
{incompleteCycles ? (
incompleteCycles.length > 0 ? (
<>
{incompleteCycles.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}>
<Tooltip position="left-bottom" tooltipContent={option.name}>
<span className="w-full truncate">{truncateText(option.name, 25)}</span>
</Tooltip>
</CustomSelect.Option>
))}
<CustomSelect.Option value={null}>None</CustomSelect.Option>
</>
) : (
<div className="text-center">No cycles found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
); );
}; };

View File

@ -14,9 +14,7 @@ type Props = {
}; };
export const SidebarEstimateSelect: React.FC<Props> = ({ value, onChange, disabled = false }) => { export const SidebarEstimateSelect: React.FC<Props> = ({ value, onChange, disabled = false }) => {
const { isEstimateActive, estimatePoints } = useEstimateOption(); const { estimatePoints } = useEstimateOption();
if (!isEstimateActive) return null;
return ( return (
<CustomSelect <CustomSelect

View File

@ -10,24 +10,20 @@ import modulesService from "services/modules.service";
import { Spinner, CustomSelect, Tooltip } from "components/ui"; import { Spinner, CustomSelect, Tooltip } from "components/ui";
// helper // helper
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
// icons
import { RectangleGroupIcon } from "@heroicons/react/24/outline";
// types // types
import { IIssue, IModule, UserAuth } from "types"; import { IIssue, IModule } from "types";
// fetch-keys // fetch-keys
import { ISSUE_DETAILS, MODULE_ISSUES, MODULE_LIST } from "constants/fetch-keys"; import { ISSUE_DETAILS, MODULE_ISSUES, MODULE_LIST } from "constants/fetch-keys";
type Props = { type Props = {
issueDetail: IIssue | undefined; issueDetail: IIssue | undefined;
handleModuleChange: (module: IModule) => void; handleModuleChange: (module: IModule) => void;
userAuth: UserAuth;
disabled?: boolean; disabled?: boolean;
}; };
export const SidebarModuleSelect: React.FC<Props> = ({ export const SidebarModuleSelect: React.FC<Props> = ({
issueDetail, issueDetail,
handleModuleChange, handleModuleChange,
userAuth,
disabled = false, disabled = false,
}) => { }) => {
const router = useRouter(); const router = useRouter();
@ -57,66 +53,60 @@ export const SidebarModuleSelect: React.FC<Props> = ({
const issueModule = issueDetail?.issue_module; const issueModule = issueDetail?.issue_module;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return ( return (
<div className="flex flex-wrap items-center py-2"> <CustomSelect
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2"> customButton={
<RectangleGroupIcon className="h-4 w-4 flex-shrink-0" /> <Tooltip
<p>Module</p> position="left"
</div> tooltipContent={`${
<div className="space-y-1 sm:basis-1/2"> modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module"
<CustomSelect }`}
label={ >
<Tooltip <button
position="left" type="button"
tooltipContent={`${ className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 w-full flex ${
modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module" disabled ? "cursor-not-allowed" : ""
}`}
>
<span
className={`truncate ${
issueModule ? "text-custom-text-100" : "text-custom-text-200"
}`} }`}
> >
<span className="w-full max-w-[125px] truncate text-left sm:block"> {modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module"}
<span </span>
className={`${issueModule ? "text-custom-text-100" : "text-custom-text-200"}`} </button>
> </Tooltip>
{truncateText( }
`${modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module"}`, value={issueModule ? issueModule.module_detail?.id : null}
15 onChange={(value: any) => {
)} !value
</span> ? removeIssueFromModule(issueModule?.id ?? "", issueModule?.module ?? "")
</span> : handleModuleChange(modules?.find((m) => m.id === value) as IModule);
</Tooltip> }}
} width="w-full"
value={issueModule ? issueModule.module_detail?.id : null} position="right"
onChange={(value: any) => { maxHeight="rg"
!value disabled={disabled}
? removeIssueFromModule(issueModule?.id ?? "", issueModule?.module ?? "") >
: handleModuleChange(modules?.find((m) => m.id === value) as IModule); {modules ? (
}} modules.length > 0 ? (
width="w-full" <>
position="right" {modules.map((option) => (
maxHeight="rg" <CustomSelect.Option key={option.id} value={option.id}>
disabled={isNotAllowed} <Tooltip position="left-bottom" tooltipContent={option.name}>
> <span className="w-full truncate">{truncateText(option.name, 25)}</span>
{modules ? ( </Tooltip>
modules.length > 0 ? ( </CustomSelect.Option>
<> ))}
{modules.map((option) => ( <CustomSelect.Option value={null}>None</CustomSelect.Option>
<CustomSelect.Option key={option.id} value={option.id}> </>
<Tooltip position="left-bottom" tooltipContent={option.name}> ) : (
<span className="w-full truncate">{truncateText(option.name, 25)}</span> <div className="text-center">No modules found</div>
</Tooltip> )
</CustomSelect.Option> ) : (
))} <Spinner />
<CustomSelect.Option value={null}>None</CustomSelect.Option> )}
</> </CustomSelect>
) : (
<div className="text-center">No modules found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
</div>
</div>
); );
}; };

View File

@ -2,8 +2,6 @@ import React, { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// icons
import { UserIcon } from "@heroicons/react/24/outline";
// components // components
import { ParentIssuesListModal } from "components/issues"; import { ParentIssuesListModal } from "components/issues";
// types // types
@ -12,14 +10,12 @@ import { IIssue, ISearchIssueResponse, UserAuth } from "types";
type Props = { type Props = {
onChange: (value: string) => void; onChange: (value: string) => void;
issueDetails: IIssue | undefined; issueDetails: IIssue | undefined;
userAuth: UserAuth;
disabled?: boolean; disabled?: boolean;
}; };
export const SidebarParentSelect: React.FC<Props> = ({ export const SidebarParentSelect: React.FC<Props> = ({
onChange, onChange,
issueDetails, issueDetails,
userAuth,
disabled = false, disabled = false,
}) => { }) => {
const [isParentModalOpen, setIsParentModalOpen] = useState(false); const [isParentModalOpen, setIsParentModalOpen] = useState(false);
@ -28,42 +24,34 @@ export const SidebarParentSelect: React.FC<Props> = ({
const router = useRouter(); const router = useRouter();
const { projectId, issueId } = router.query; const { projectId, issueId } = router.query;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return ( return (
<div className="flex flex-wrap items-center py-2"> <>
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2"> <ParentIssuesListModal
<UserIcon className="h-4 w-4 flex-shrink-0" /> isOpen={isParentModalOpen}
<p>Parent</p> handleClose={() => setIsParentModalOpen(false)}
</div> onChange={(issue) => {
<div className="sm:basis-1/2"> onChange(issue.id);
<ParentIssuesListModal setSelectedParentIssue(issue);
isOpen={isParentModalOpen} }}
handleClose={() => setIsParentModalOpen(false)} issueId={issueId as string}
onChange={(issue) => { projectId={projectId as string}
onChange(issue.id); />
setSelectedParentIssue(issue); <button
}} type="button"
issueId={issueId as string} className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 ${
projectId={projectId as string} disabled ? "cursor-not-allowed" : "cursor-pointer "
/> }`}
<button onClick={() => setIsParentModalOpen(true)}
type="button" disabled={disabled}
className={`flex w-full ${ >
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80" {selectedParentIssue && issueDetails?.parent ? (
} items-center justify-between gap-1 rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm duration-300 focus:outline-none`} `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
onClick={() => setIsParentModalOpen(true)} ) : !selectedParentIssue && issueDetails?.parent ? (
disabled={isNotAllowed} `${issueDetails.parent_detail?.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}`
> ) : (
{selectedParentIssue && issueDetails?.parent ? ( <span className="text-custom-text-200">Select issue</span>
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}` )}
) : !selectedParentIssue && issueDetails?.parent ? ( </button>
`${issueDetails.parent_detail?.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}` </>
) : (
<span className="text-custom-text-200">Select issue</span>
)}
</button>
</div>
</div>
); );
}; };

View File

@ -18,7 +18,7 @@ export const SidebarPrioritySelect: React.FC<Props> = ({ value, onChange, disabl
customButton={ customButton={
<button <button
type="button" type="button"
className={`flex items-center gap-1.5 text-left text-sm capitalize rounded px-2.5 py-0.5 ${ className={`flex items-center gap-1.5 text-left text-xs capitalize rounded px-2.5 py-0.5 ${
value === "urgent" value === "urgent"
? "border-red-500/20 bg-red-500/20 text-red-500" ? "border-red-500/20 bg-red-500/20 text-red-500"
: value === "high" : value === "high"

View File

@ -39,7 +39,7 @@ export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, disabled
return ( return (
<CustomSelect <CustomSelect
customButton={ customButton={
<button type="button" className="bg-custom-background-80 text-sm rounded px-2.5 py-0.5"> <button type="button" className="bg-custom-background-80 text-xs rounded px-2.5 py-0.5">
{selectedState ? ( {selectedState ? (
<div className="flex items-center gap-1.5 text-left text-custom-text-100"> <div className="flex items-center gap-1.5 text-left text-custom-text-100">
{getStateGroupIcon( {getStateGroupIcon(

View File

@ -10,6 +10,7 @@ import { Controller, UseFormWatch } from "react-hook-form";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
import useUserIssueNotificationSubscription from "hooks/use-issue-notification-subscription"; import useUserIssueNotificationSubscription from "hooks/use-issue-notification-subscription";
import useEstimateOption from "hooks/use-estimate-option";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
import modulesService from "services/modules.service"; import modulesService from "services/modules.service";
@ -42,6 +43,8 @@ import {
ChartBarIcon, ChartBarIcon,
UserGroupIcon, UserGroupIcon,
PlayIcon, PlayIcon,
UserIcon,
RectangleGroupIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// helpers // helpers
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
@ -49,6 +52,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types"; import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types";
// fetch-keys // fetch-keys
import { ISSUE_DETAILS } from "constants/fetch-keys"; import { ISSUE_DETAILS } from "constants/fetch-keys";
import { ContrastIcon } from "components/icons";
type Props = { type Props = {
control: any; control: any;
@ -93,6 +97,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
const { user } = useUserAuth(); const { user } = useUserAuth();
const { isEstimateActive } = useEstimateOption();
const { loading, handleSubscribe, handleUnsubscribe, subscribed } = const { loading, handleSubscribe, handleUnsubscribe, subscribed } =
useUserIssueNotificationSubscription(workspaceSlug, projectId, issueId); useUserIssueNotificationSubscription(workspaceSlug, projectId, issueId);
@ -403,22 +409,51 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
</div> </div>
</div> </div>
)} )}
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) &&
isEstimateActive && (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<PlayIcon className="h-4 w-4 flex-shrink-0 -rotate-90" />
<p>Estimate</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="estimate_point"
render={({ field: { value } }) => (
<SidebarEstimateSelect
value={value}
onChange={(val: number | null) =>
submitChanges({ estimate_point: val })
}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
/>
)}
/>
</div>
</div>
)}
</div>
)}
{showSecondSection && (
<div className="py-1">
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
<div className="flex flex-wrap items-center py-2"> <div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2"> <div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<PlayIcon className="h-4 w-4 flex-shrink-0 -rotate-90" /> <UserIcon className="h-4 w-4 flex-shrink-0" />
<p>Estimate</p> <p>Parent</p>
</div> </div>
<div className="sm:basis-1/2"> <div className="sm:basis-1/2">
<Controller <Controller
control={control} control={control}
name="estimate_point" name="parent"
render={({ field: { value } }) => ( render={({ field: { onChange } }) => (
<SidebarEstimateSelect <SidebarParentSelect
value={value} onChange={(val: string) => {
onChange={(val: number | null) => submitChanges({ parent: val });
submitChanges({ estimate_point: val }) onChange(val);
} }}
issueDetails={issueDetail}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable} disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
/> />
)} )}
@ -426,34 +461,12 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
</div> </div>
</div> </div>
)} )}
</div>
)}
{showSecondSection && (
<div className="py-1">
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
<Controller
control={control}
name="parent"
render={({ field: { onChange } }) => (
<SidebarParentSelect
onChange={(val: string) => {
submitChanges({ parent: val });
onChange(val);
}}
issueDetails={issueDetail}
userAuth={memberRole}
disabled={uneditable}
/>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
<SidebarBlockerSelect <SidebarBlockerSelect
issueId={issueId as string} issueId={issueId as string}
submitChanges={submitChanges} submitChanges={submitChanges}
watch={watchIssue} watch={watchIssue}
userAuth={memberRole} disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
disabled={uneditable}
/> />
)} )}
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && (
@ -461,8 +474,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
issueId={issueId as string} issueId={issueId as string}
submitChanges={submitChanges} submitChanges={submitChanges}
watch={watchIssue} watch={watchIssue}
userAuth={memberRole} disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
disabled={uneditable}
/> />
)} )}
{(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && (
@ -484,8 +496,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
start_date: val, start_date: val,
}) })
} }
className="bg-custom-background-100" className="bg-custom-background-80 border-none"
wrapperClassName="w-full"
maxDate={maxDate ?? undefined} maxDate={maxDate ?? undefined}
disabled={isNotAllowed || uneditable} disabled={isNotAllowed || uneditable}
/> />
@ -513,8 +524,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
target_date: val, target_date: val,
}) })
} }
className="bg-custom-background-100" className="bg-custom-background-80 border-none"
wrapperClassName="w-full"
minDate={minDate ?? undefined} minDate={minDate ?? undefined}
disabled={isNotAllowed || uneditable} disabled={isNotAllowed || uneditable}
/> />
@ -528,20 +538,34 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
{showThirdSection && ( {showThirdSection && (
<div className="py-1"> <div className="py-1">
{(fieldsToShow.includes("all") || fieldsToShow.includes("cycle")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("cycle")) && (
<SidebarCycleSelect <div className="flex flex-wrap items-center py-2">
issueDetail={issueDetail} <div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
handleCycleChange={handleCycleChange} <ContrastIcon className="h-4 w-4 flex-shrink-0" />
userAuth={memberRole} <p>Cycle</p>
disabled={uneditable} </div>
/> <div className="space-y-1 sm:w-1/2">
<SidebarCycleSelect
issueDetail={issueDetail}
handleCycleChange={handleCycleChange}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
/>
</div>
</div>
)} )}
{(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && (
<SidebarModuleSelect <div className="flex flex-wrap items-center py-2">
issueDetail={issueDetail} <div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
handleModuleChange={handleModuleChange} <RectangleGroupIcon className="h-4 w-4 flex-shrink-0" />
userAuth={memberRole} <p>Module</p>
disabled={uneditable} </div>
/> <div className="space-y-1 sm:w-1/2">
<SidebarModuleSelect
issueDetail={issueDetail}
handleModuleChange={handleModuleChange}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
/>
</div>
</div>
)} )}
</div> </div>
)} )}

View File

@ -0,0 +1,58 @@
import { useRouter } from "next/router";
// ui
import { Tooltip } from "components/ui";
// icons
import { ModuleStatusIcon } from "components/icons";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
// types
import { IModule } from "types";
// constants
import { MODULE_STATUS } from "constants/module";
export const ModuleGanttBlock = ({ data }: { data: IModule }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<div
className="relative flex items-center w-full h-full rounded"
style={{ backgroundColor: MODULE_STATUS.find((s) => s.value === data?.status)?.color }}
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/modules/${data?.id}`)}
>
<div className="absolute top-0 left-0 h-full w-full bg-custom-background-100/50" />
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{data?.name}</h5>
<div>
{renderShortDate(data?.start_date ?? "")} to{" "}
{renderShortDate(data?.target_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="relative text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{data?.name}
</div>
</Tooltip>
</div>
);
};
export const ModuleGanttSidebarBlock = ({ data }: { data: IModule }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<div
className="relative w-full flex items-center gap-2 h-full"
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/modules/${data.id}`)}
>
<ModuleStatusIcon status={data?.status ?? "backlog"} height="16px" width="16px" />
<h6 className="text-sm font-medium flex-grow truncate">{data.name}</h6>
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from "./blocks";
export * from "./module-issues-layout";
export * from "./modules-list-layout";

View File

@ -8,11 +8,8 @@ import useUser from "hooks/use-user";
import useGanttChartModuleIssues from "hooks/gantt-chart/module-issues-view"; import useGanttChartModuleIssues from "hooks/gantt-chart/module-issues-view";
import { updateGanttIssue } from "components/gantt-chart/hooks/block-update"; import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
// components // components
import { import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
GanttChartRoot, import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
IssueGanttBlock,
renderIssueBlocksStructure,
} from "components/gantt-chart";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
@ -32,29 +29,20 @@ export const ModuleIssuesGanttChartView: FC<Props> = ({}) => {
moduleId as string moduleId as string
); );
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
return ( return (
<div className="w-full h-full p-3"> <div className="w-full h-full">
<GanttChartRoot <GanttChartRoot
title="Modules" border={false}
loaderTitle="Modules" title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null} blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={(block, payload) => blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
} }
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />} SidebarBlockRender={IssueGanttSidebarBlock}
blockRender={(data: any) => <IssueGanttBlock issue={data as IIssue} />} BlockRender={IssueGanttBlock}
enableReorder={orderBy === "sort_order"} enableReorder={orderBy === "sort_order"}
bottomSpacing
/> />
</div> </div>
); );

View File

@ -1,6 +1,7 @@
import { FC } from "react"; import { FC } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Link from "next/link";
import { KeyedMutator } from "swr"; import { KeyedMutator } from "swr";
@ -9,11 +10,10 @@ import modulesService from "services/modules.service";
// hooks // hooks
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
// components // components
import { GanttChartRoot, IBlockUpdateData, ModuleGanttBlock } from "components/gantt-chart"; import { GanttChartRoot, IBlockUpdateData } from "components/gantt-chart";
import { ModuleGanttBlock, ModuleGanttSidebarBlock } from "components/modules";
// types // types
import { IModule } from "types"; import { IModule } from "types";
// constants
import { MODULE_STATUS } from "constants/module";
type Props = { type Props = {
modules: IModule[]; modules: IModule[];
@ -26,19 +26,6 @@ export const ModulesListGanttChartView: FC<Props> = ({ modules, mutateModules })
const { user } = useUser(); const { user } = useUser();
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{
backgroundColor: MODULE_STATUS.find((s) => s.value === data.status)?.color,
}}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => { const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => {
if (!workspaceSlug || !user) return; if (!workspaceSlug || !user) return;
@ -98,8 +85,8 @@ export const ModulesListGanttChartView: FC<Props> = ({ modules, mutateModules })
loaderTitle="Modules" loaderTitle="Modules"
blocks={modules ? blockFormat(modules) : null} blocks={modules ? blockFormat(modules) : null}
blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)} blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />} SidebarBlockRender={ModuleGanttSidebarBlock}
blockRender={(data: any) => <ModuleGanttBlock module={data as IModule} />} BlockRender={ModuleGanttBlock}
/> />
</div> </div>
); );

View File

@ -4,6 +4,5 @@ export * from "./delete-module-modal";
export * from "./form"; export * from "./form";
export * from "./gantt-chart"; export * from "./gantt-chart";
export * from "./modal"; export * from "./modal";
export * from "./modules-list-gantt-chart";
export * from "./sidebar"; export * from "./sidebar";
export * from "./single-module-card"; export * from "./single-module-card";

View File

@ -6,6 +6,7 @@ import { Controller, FieldError, Control } from "react-hook-form";
import { CustomSelect } from "components/ui"; import { CustomSelect } from "components/ui";
// icons // icons
import { Squares2X2Icon } from "@heroicons/react/24/outline"; import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { ModuleStatusIcon } from "components/icons";
// types // types
import type { IModule } from "types"; import type { IModule } from "types";
// constants // constants
@ -31,12 +32,7 @@ export const ModuleStatusSelect: React.FC<Props> = ({ control, error }) => (
}`} }`}
> >
{value ? ( {value ? (
<span <ModuleStatusIcon status={value} />
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: MODULE_STATUS.find((s) => s.value === value)?.color,
}}
/>
) : ( ) : (
<Squares2X2Icon <Squares2X2Icon
className={`h-3 w-3 ${error ? "text-red-500" : "text-custom-text-200"}`} className={`h-3 w-3 ${error ? "text-red-500" : "text-custom-text-200"}`}
@ -53,12 +49,7 @@ export const ModuleStatusSelect: React.FC<Props> = ({ control, error }) => (
{MODULE_STATUS.map((status) => ( {MODULE_STATUS.map((status) => (
<CustomSelect.Option key={status.value} value={status.value}> <CustomSelect.Option key={status.value} value={status.value}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <ModuleStatusIcon status={status.value} />
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: status.color,
}}
/>
{status.label} {status.label}
</div> </div>
</CustomSelect.Option> </CustomSelect.Option>

View File

@ -48,7 +48,7 @@ const defaultValues: Partial<IModule> = {
members_list: [], members_list: [],
start_date: null, start_date: null,
target_date: null, target_date: null,
status: null, status: "backlog",
}; };
type Props = { type Props = {

View File

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
// components // components
import { Icon, Tooltip } from "components/ui"; import { CustomMenu, Icon, Tooltip } from "components/ui";
// helpers // helpers
import { getNumberCount } from "helpers/string.helper"; import { getNumberCount } from "helpers/string.helper";
@ -21,6 +21,7 @@ type NotificationHeaderProps = {
setArchived: React.Dispatch<React.SetStateAction<boolean>>; setArchived: React.Dispatch<React.SetStateAction<boolean>>;
setReadNotification: React.Dispatch<React.SetStateAction<boolean>>; setReadNotification: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedTab: React.Dispatch<React.SetStateAction<NotificationType>>; setSelectedTab: React.Dispatch<React.SetStateAction<NotificationType>>;
markAllNotificationsAsRead: () => Promise<void>;
}; };
export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) => { export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) => {
@ -37,6 +38,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
setArchived, setArchived,
setReadNotification, setReadNotification,
setSelectedTab, setSelectedTab,
markAllNotificationsAsRead,
} = props; } = props;
const notificationTabs: Array<{ const notificationTabs: Array<{
@ -88,33 +90,51 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
<Icon iconName="filter_list" /> <Icon iconName="filter_list" />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip tooltipContent="Snoozed notifications"> <CustomMenu
<button customButton={
type="button" <div className="grid place-items-center ">
<Icon iconName="more_vert" />
</div>
}
>
<CustomMenu.MenuItem renderAs="button" onClick={markAllNotificationsAsRead}>
<div className="flex items-center gap-2">
<Icon iconName="done_all" />
Mark all as read
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
renderAs="button"
onClick={() => { onClick={() => {
setArchived(false); setArchived(false);
setReadNotification(false); setReadNotification(false);
setSnoozed((prev) => !prev); setSnoozed((prev) => !prev);
}} }}
> >
<Icon iconName="schedule" /> <div className="flex items-center gap-2">
</button> <Icon iconName="schedule" />
</Tooltip> Show snoozed
<Tooltip tooltipContent="Archived notifications"> </div>
<button </CustomMenu.MenuItem>
type="button" <CustomMenu.MenuItem
renderAs="button"
onClick={() => { onClick={() => {
setSnoozed(false); setSnoozed(false);
setReadNotification(false); setReadNotification(false);
setArchived((prev) => !prev); setArchived((prev) => !prev);
}} }}
> >
<Icon iconName="archive" /> <div className="flex items-center gap-2">
<Icon iconName="archive" />
Show archived
</div>
</CustomMenu.MenuItem>
</CustomMenu>
<Tooltip tooltipContent="Close">
<button type="button" onClick={() => closePopover()}>
<Icon iconName="close" />
</button> </button>
</Tooltip> </Tooltip>
<button type="button" onClick={() => closePopover()}>
<Icon iconName="close" />
</button>
</div> </div>
</div> </div>
<div className="border-b border-custom-border-300 w-full px-5 mt-5"> <div className="border-b border-custom-border-300 w-full px-5 mt-5">

View File

@ -51,6 +51,7 @@ export const NotificationPopover = () => {
hasMore, hasMore,
isRefreshing, isRefreshing,
setFetchNotifications, setFetchNotifications,
markAllNotificationsAsRead,
} = useUserNotification(); } = useUserNotification();
// theme context // theme context
@ -112,7 +113,7 @@ export const NotificationPopover = () => {
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<Popover.Panel className="absolute bg-custom-background-100 flex flex-col left-0 md:left-full ml-8 z-10 -top-44 md:w-[36rem] w-[20rem] h-[75vh] border border-custom-border-300 shadow-lg rounded-xl"> <Popover.Panel className="absolute bg-custom-background-100 flex flex-col left-0 md:left-full ml-8 z-10 -top-36 md:w-[36rem] w-[20rem] h-[50vh] border border-custom-border-300 shadow-lg rounded-xl">
<NotificationHeader <NotificationHeader
notificationCount={notificationCount} notificationCount={notificationCount}
notificationMutate={notificationMutate} notificationMutate={notificationMutate}
@ -126,6 +127,7 @@ export const NotificationPopover = () => {
setArchived={setArchived} setArchived={setArchived}
setReadNotification={setReadNotification} setReadNotification={setReadNotification}
setSelectedTab={setSelectedTab} setSelectedTab={setSelectedTab}
markAllNotificationsAsRead={markAllNotificationsAsRead}
/> />
{notifications ? ( {notifications ? (

View File

@ -1,12 +1,7 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
import { SparklesIcon } from "@heroicons/react/24/outline"; import { SparklesIcon } from "@heroicons/react/24/outline";
// react-hook-form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// services // services
import pagesService from "services/pages.service"; import pagesService from "services/pages.service";
@ -16,13 +11,12 @@ import aiService from "services/ai.service";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { GptAssistantModal } from "components/core"; import { GptAssistantModal } from "components/core";
// ui import { TipTapEditor } from "components/tiptap";
import { PrimaryButton, SecondaryButton, TextArea } from "components/ui"; import { PrimaryButton, SecondaryButton, TextArea } from "components/ui";
// types // types
import { ICurrentUserResponse, IPageBlock } from "types"; import { ICurrentUserResponse, IPageBlock } from "types";
// fetch-keys // fetch-keys
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys"; import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
type Props = { type Props = {
handleClose: () => void; handleClose: () => void;
@ -39,12 +33,6 @@ const defaultValues = {
description_html: null, description_html: null,
}; };
const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
(props, ref) => <Tiptap {...props} forwardedRef={ref} />
);
TiptapEditor.displayName = "TiptapEditor";
export const CreateUpdateBlockInline: React.FC<Props> = ({ export const CreateUpdateBlockInline: React.FC<Props> = ({
handleClose, handleClose,
data, data,
@ -231,9 +219,9 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
description: description:
!data.description || data.description === "" !data.description || data.description === ""
? { ? {
type: "doc", type: "doc",
content: [{ type: "paragraph" }], content: [{ type: "paragraph" }],
} }
: data.description, : data.description,
description_html: data.description_html ?? "<p></p>", description_html: data.description_html ?? "<p></p>",
}); });
@ -291,7 +279,7 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
render={({ field: { value, onChange } }) => { render={({ field: { value, onChange } }) => {
if (!data) if (!data)
return ( return (
<TiptapEditor <TipTapEditor
workspaceSlug={workspaceSlug as string} workspaceSlug={workspaceSlug as string}
ref={editorRef} ref={editorRef}
value={"<p></p>"} value={"<p></p>"}
@ -311,7 +299,7 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
); );
return ( return (
<TiptapEditor <TipTapEditor
workspaceSlug={workspaceSlug as string} workspaceSlug={workspaceSlug as string}
ref={editorRef} ref={editorRef}
value={ value={
@ -334,8 +322,9 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
<div className="m-2 mt-6 flex"> <div className="m-2 mt-6 flex">
<button <button
type="button" type="button"
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-80 ${iAmFeelingLucky ? "cursor-wait bg-custom-background-90" : "" className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-80 ${
}`} iAmFeelingLucky ? "cursor-wait bg-custom-background-90" : ""
}`}
onClick={handleAutoGenerateDescription} onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky} disabled={iAmFeelingLucky}
> >
@ -367,8 +356,8 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
? "Updating..." ? "Updating..."
: "Update block" : "Update block"
: isSubmitting : isSubmitting
? "Adding..." ? "Adding..."
: "Add block"} : "Add block"}
</PrimaryButton> </PrimaryButton>
</div> </div>
</form> </form>

View File

@ -19,6 +19,7 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components // components
import { GptAssistantModal } from "components/core"; import { GptAssistantModal } from "components/core";
import { CreateUpdateBlockInline } from "components/pages"; import { CreateUpdateBlockInline } from "components/pages";
import { TipTapEditor } from "components/tiptap";
// ui // ui
import { CustomMenu, TextArea } from "components/ui"; import { CustomMenu, TextArea } from "components/ui";
// icons // icons
@ -38,7 +39,6 @@ import { copyTextToClipboard } from "helpers/string.helper";
import { ICurrentUserResponse, IIssue, IPageBlock, IProject } from "types"; import { ICurrentUserResponse, IIssue, IPageBlock, IProject } from "types";
// fetch-keys // fetch-keys
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys"; import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
type Props = { type Props = {
block: IPageBlock; block: IPageBlock;
@ -48,13 +48,6 @@ type Props = {
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
}; };
const TiptapEditor = React.forwardRef<
ITiptapRichTextEditor,
ITiptapRichTextEditor
>((props, ref) => <Tiptap {...props} forwardedRef={ref} />);
TiptapEditor.displayName = "TiptapEditor";
export const SinglePageBlock: React.FC<Props> = ({ export const SinglePageBlock: React.FC<Props> = ({
block, block,
projectDetails, projectDetails,
@ -328,8 +321,9 @@ export const SinglePageBlock: React.FC<Props> = ({
</div> </div>
) : ( ) : (
<div <div
className={`group relative w-full rounded bg-custom-background-80 text-custom-text-200 ${snapshot.isDragging ? "bg-custom-background-100 p-4 shadow" : "" className={`group relative w-full rounded bg-custom-background-80 text-custom-text-200 ${
}`} snapshot.isDragging ? "bg-custom-background-100 p-4 shadow" : ""
}`}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
> >
@ -343,8 +337,9 @@ export const SinglePageBlock: React.FC<Props> = ({
</button> </button>
<div <div
ref={actionSectionRef} ref={actionSectionRef}
className={`absolute top-4 right-2 hidden items-center gap-2 bg-custom-background-80 pl-4 group-hover:!flex ${isMenuActive ? "!flex" : "" className={`absolute top-4 right-2 hidden items-center gap-2 bg-custom-background-80 pl-4 group-hover:!flex ${
}`} isMenuActive ? "!flex" : ""
}`}
> >
{block.issue && block.sync && ( {block.issue && block.sync && (
<div className="flex flex-shrink-0 cursor-default items-center gap-1 rounded py-1 px-1.5 text-xs"> <div className="flex flex-shrink-0 cursor-default items-center gap-1 rounded py-1 px-1.5 text-xs">
@ -358,8 +353,9 @@ export const SinglePageBlock: React.FC<Props> = ({
)} )}
<button <button
type="button" type="button"
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${iAmFeelingLucky ? "cursor-wait" : "" className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${
}`} iAmFeelingLucky ? "cursor-wait" : ""
}`}
onClick={handleAutoGenerateDescription} onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky} disabled={iAmFeelingLucky}
> >
@ -455,18 +451,19 @@ export const SinglePageBlock: React.FC<Props> = ({
{showBlockDetails {showBlockDetails
? block.description_html.length > 7 && ( ? block.description_html.length > 7 && (
<TiptapEditor <TipTapEditor
workspaceSlug={workspaceSlug as string} workspaceSlug={workspaceSlug as string}
value={block.description_html} value={block.description_html}
customClassName="text-sm min-h-[150px]" customClassName="text-sm min-h-[150px]"
noBorder noBorder
borderOnFocus={false} borderOnFocus={false}
/> />
) : block.description_stripped.length > 0 && ( )
<p className="mt-3 text-sm font-normal text-custom-text-200 h-5 truncate"> : block.description_stripped.length > 0 && (
{block.description_stripped} <p className="mt-3 text-sm font-normal text-custom-text-200 h-5 truncate">
</p> {block.description_stripped}
)} </p>
)}
</div> </div>
</div> </div>
<GptAssistantModal <GptAssistantModal

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