mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
promote: develop to stage-release (#2045)
promote: develop to stage-release
This commit is contained in:
commit
650c0c3b78
55
.github/workflows/Build_Test_Pull_Request.yml
vendored
Normal file
55
.github/workflows/Build_Test_Pull_Request.yml
vendored
Normal 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
|
||||||
|
|
||||||
|
|
111
.github/workflows/Update_Docker_Images.yml
vendored
Normal file
111
.github/workflows/Update_Docker_Images.yml
vendored
Normal 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 }}
|
77
.github/workflows/push-image-backend.yml
vendored
77
.github/workflows/push-image-backend.yml
vendored
@ -1,77 +0,0 @@
|
|||||||
name: Build and Push Backend Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'develop'
|
|
||||||
- 'master'
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
|
|
||||||
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 QEMU
|
|
||||||
uses: docker/setup-qemu-action@v2.1.0
|
|
||||||
with:
|
|
||||||
platforms: linux/arm64,linux/amd64
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2.5.0
|
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v2.1.0
|
|
||||||
with:
|
|
||||||
registry: "ghcr.io"
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@v2.1.0
|
|
||||||
with:
|
|
||||||
registry: "registry.hub.docker.com"
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker (Docker Hub)
|
|
||||||
id: ghmeta
|
|
||||||
uses: docker/metadata-action@v4.3.0
|
|
||||||
with:
|
|
||||||
images: makeplane/plane-backend
|
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker (Github)
|
|
||||||
id: dkrmeta
|
|
||||||
uses: docker/metadata-action@v4.3.0
|
|
||||||
with:
|
|
||||||
images: ghcr.io/${{ github.repository }}-backend
|
|
||||||
|
|
||||||
- name: Build and Push to GitHub Container Registry
|
|
||||||
uses: docker/build-push-action@v4.0.0
|
|
||||||
with:
|
|
||||||
context: ./apiserver
|
|
||||||
file: ./apiserver/Dockerfile.api
|
|
||||||
platforms: linux/arm64,linux/amd64
|
|
||||||
push: true
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha
|
|
||||||
tags: ${{ steps.ghmeta.outputs.tags }}
|
|
||||||
labels: ${{ steps.ghmeta.outputs.labels }}
|
|
||||||
|
|
||||||
- name: Build and Push to Docker Hub
|
|
||||||
uses: docker/build-push-action@v4.0.0
|
|
||||||
with:
|
|
||||||
context: ./apiserver
|
|
||||||
file: ./apiserver/Dockerfile.api
|
|
||||||
platforms: linux/arm64,linux/amd64
|
|
||||||
push: true
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha
|
|
||||||
tags: ${{ steps.dkrmeta.outputs.tags }}
|
|
||||||
labels: ${{ steps.dkrmeta.outputs.labels }}
|
|
||||||
|
|
77
.github/workflows/push-image-frontend.yml
vendored
77
.github/workflows/push-image-frontend.yml
vendored
@ -1,77 +0,0 @@
|
|||||||
name: Build and Push Frontend Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'develop'
|
|
||||||
- 'master'
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
|
|
||||||
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 QEMU
|
|
||||||
uses: docker/setup-qemu-action@v2.1.0
|
|
||||||
with:
|
|
||||||
platforms: linux/arm64,linux/amd64
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2.5.0
|
|
||||||
|
|
||||||
- name: Login to Github Container Registry
|
|
||||||
uses: docker/login-action@v2.1.0
|
|
||||||
with:
|
|
||||||
registry: "ghcr.io"
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@v2.1.0
|
|
||||||
with:
|
|
||||||
registry: "registry.hub.docker.com"
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker (Docker Hub)
|
|
||||||
id: ghmeta
|
|
||||||
uses: docker/metadata-action@v4.3.0
|
|
||||||
with:
|
|
||||||
images: makeplane/plane-frontend
|
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker (Github)
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v4.3.0
|
|
||||||
with:
|
|
||||||
images: ghcr.io/${{ github.repository }}-frontend
|
|
||||||
|
|
||||||
- name: Build and Push to GitHub Container Registry
|
|
||||||
uses: docker/build-push-action@v4.0.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./apps/app/Dockerfile.web
|
|
||||||
platforms: linux/arm64,linux/amd64
|
|
||||||
push: true
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha
|
|
||||||
tags: ${{ steps.ghmeta.outputs.tags }}
|
|
||||||
labels: ${{ steps.ghmeta.outputs.labels }}
|
|
||||||
|
|
||||||
- name: Build and Push to Docker Container Registry
|
|
||||||
uses: docker/build-push-action@v4.0.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./apps/app/Dockerfile.web
|
|
||||||
platforms: linux/arm64,linux/amd64
|
|
||||||
push: true
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha
|
|
||||||
tags: ${{ steps.dkrmeta.outputs.tags }}
|
|
||||||
labels: ${{ steps.dkrmeta.outputs.labels }}
|
|
||||||
|
|
@ -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
|
@ -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 (
|
|||||||
IssueReactionSerializer,
|
IssueReactionSerializer,
|
||||||
CommentReactionSerializer,
|
CommentReactionSerializer,
|
||||||
IssueVoteSerializer,
|
IssueVoteSerializer,
|
||||||
|
IssuePublicSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .module import (
|
from .module import (
|
||||||
|
@ -113,7 +113,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
|
||||||
|
|
||||||
@ -510,6 +514,9 @@ class IssueAttachmentSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class IssueReactionSerializer(BaseSerializer):
|
class IssueReactionSerializer(BaseSerializer):
|
||||||
|
|
||||||
|
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IssueReaction
|
model = IssueReaction
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
@ -521,19 +528,6 @@ class IssueReactionSerializer(BaseSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class IssueReactionLiteSerializer(BaseSerializer):
|
|
||||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = IssueReaction
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"reaction",
|
|
||||||
"issue",
|
|
||||||
"actor_detail",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class CommentReactionLiteSerializer(BaseSerializer):
|
class CommentReactionLiteSerializer(BaseSerializer):
|
||||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||||
|
|
||||||
@ -554,12 +548,13 @@ class CommentReactionSerializer(BaseSerializer):
|
|||||||
read_only_fields = ["workspace", "project", "comment", "actor"]
|
read_only_fields = ["workspace", "project", "comment", "actor"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class IssueVoteSerializer(BaseSerializer):
|
class IssueVoteSerializer(BaseSerializer):
|
||||||
|
|
||||||
|
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IssueVote
|
model = IssueVote
|
||||||
fields = ["issue", "vote", "workspace_id", "project_id", "actor"]
|
fields = ["issue", "vote", "workspace", "project", "actor", "actor_detail"]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
@ -569,7 +564,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
|
||||||
@ -582,7 +577,6 @@ class IssueCommentSerializer(BaseSerializer):
|
|||||||
"updated_by",
|
"updated_by",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"access",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -632,7 +626,7 @@ class IssueSerializer(BaseSerializer):
|
|||||||
issue_link = IssueLinkSerializer(read_only=True, many=True)
|
issue_link = IssueLinkSerializer(read_only=True, many=True)
|
||||||
issue_attachment = IssueAttachmentSerializer(read_only=True, many=True)
|
issue_attachment = IssueAttachmentSerializer(read_only=True, many=True)
|
||||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||||
issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True)
|
issue_reactions = IssueReactionSerializer(read_only=True, many=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Issue
|
model = Issue
|
||||||
@ -658,7 +652,7 @@ class IssueLiteSerializer(BaseSerializer):
|
|||||||
module_id = serializers.UUIDField(read_only=True)
|
module_id = serializers.UUIDField(read_only=True)
|
||||||
attachment_count = serializers.IntegerField(read_only=True)
|
attachment_count = serializers.IntegerField(read_only=True)
|
||||||
link_count = serializers.IntegerField(read_only=True)
|
link_count = serializers.IntegerField(read_only=True)
|
||||||
issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True)
|
issue_reactions = IssueReactionSerializer(read_only=True, many=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Issue
|
model = Issue
|
||||||
@ -676,6 +670,33 @@ class IssueLiteSerializer(BaseSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class IssuePublicSerializer(BaseSerializer):
|
||||||
|
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||||
|
state_detail = StateLiteSerializer(read_only=True, source="state")
|
||||||
|
reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions")
|
||||||
|
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",
|
||||||
|
"reactions",
|
||||||
|
"votes",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class IssueSubscriberSerializer(BaseSerializer):
|
class IssueSubscriberSerializer(BaseSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IssueSubscriber
|
model = IssueSubscriber
|
||||||
|
@ -15,6 +15,7 @@ from plane.db.models import (
|
|||||||
ProjectIdentifier,
|
ProjectIdentifier,
|
||||||
ProjectFavorite,
|
ProjectFavorite,
|
||||||
ProjectDeployBoard,
|
ProjectDeployBoard,
|
||||||
|
ProjectPublicMember,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -112,7 +113,7 @@ class ProjectDetailSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ProjectMemberSerializer(BaseSerializer):
|
class ProjectMemberSerializer(BaseSerializer):
|
||||||
workspace = WorkSpaceSerializer(read_only=True)
|
workspace = WorkspaceLiteSerializer(read_only=True)
|
||||||
project = ProjectLiteSerializer(read_only=True)
|
project = ProjectLiteSerializer(read_only=True)
|
||||||
member = UserLiteSerializer(read_only=True)
|
member = UserLiteSerializer(read_only=True)
|
||||||
|
|
||||||
@ -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",
|
||||||
]
|
]
|
||||||
|
@ -51,6 +51,7 @@ from plane.api.views import (
|
|||||||
WorkspaceUserProfileEndpoint,
|
WorkspaceUserProfileEndpoint,
|
||||||
WorkspaceUserProfileIssuesEndpoint,
|
WorkspaceUserProfileIssuesEndpoint,
|
||||||
WorkspaceLabelsEndpoint,
|
WorkspaceLabelsEndpoint,
|
||||||
|
LeaveWorkspaceEndpoint,
|
||||||
## End Workspaces
|
## End Workspaces
|
||||||
# File Assets
|
# File Assets
|
||||||
FileAssetEndpoint,
|
FileAssetEndpoint,
|
||||||
@ -68,6 +69,7 @@ from plane.api.views import (
|
|||||||
UserProjectInvitationsViewset,
|
UserProjectInvitationsViewset,
|
||||||
ProjectIdentifierEndpoint,
|
ProjectIdentifierEndpoint,
|
||||||
ProjectFavoritesViewSet,
|
ProjectFavoritesViewSet,
|
||||||
|
LeaveProjectEndpoint,
|
||||||
## End Projects
|
## End Projects
|
||||||
# Issues
|
# Issues
|
||||||
IssueViewSet,
|
IssueViewSet,
|
||||||
@ -89,7 +91,6 @@ from plane.api.views import (
|
|||||||
IssueCommentPublicViewSet,
|
IssueCommentPublicViewSet,
|
||||||
IssueReactionViewSet,
|
IssueReactionViewSet,
|
||||||
CommentReactionViewSet,
|
CommentReactionViewSet,
|
||||||
ExportIssuesEndpoint,
|
|
||||||
## End Issues
|
## End Issues
|
||||||
# States
|
# States
|
||||||
StateViewSet,
|
StateViewSet,
|
||||||
@ -165,16 +166,23 @@ 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,
|
||||||
|
IssueRetrievePublicEndpoint,
|
||||||
## End Public Boards
|
## End Public Boards
|
||||||
|
## Exporter
|
||||||
|
ExportIssuesEndpoint,
|
||||||
|
## End Exporter
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -231,7 +239,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/",
|
||||||
@ -435,6 +443,11 @@ urlpatterns = [
|
|||||||
WorkspaceLabelsEndpoint.as_view(),
|
WorkspaceLabelsEndpoint.as_view(),
|
||||||
name="workspace-labels",
|
name="workspace-labels",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/members/leave/",
|
||||||
|
LeaveWorkspaceEndpoint.as_view(),
|
||||||
|
name="workspace-labels",
|
||||||
|
),
|
||||||
## End Workspaces ##
|
## End Workspaces ##
|
||||||
# Projects
|
# Projects
|
||||||
path(
|
path(
|
||||||
@ -548,6 +561,11 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
name="project",
|
name="project",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/members/leave/",
|
||||||
|
LeaveProjectEndpoint.as_view(),
|
||||||
|
name="project",
|
||||||
|
),
|
||||||
# End Projects
|
# End Projects
|
||||||
# States
|
# States
|
||||||
path(
|
path(
|
||||||
@ -1490,6 +1508,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(
|
||||||
@ -1520,9 +1547,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(
|
||||||
@ -1614,5 +1646,10 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
name="issue-vote-project-board",
|
name="issue-vote-project-board",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"public/workspaces/<str:slug>/project-boards/",
|
||||||
|
WorkspaceProjectDeployBoardEndpoint.as_view(),
|
||||||
|
name="workspace-project-boards",
|
||||||
|
),
|
||||||
## End Public Boards
|
## End Public Boards
|
||||||
]
|
]
|
||||||
|
@ -12,10 +12,11 @@ from .project import (
|
|||||||
ProjectUserViewsEndpoint,
|
ProjectUserViewsEndpoint,
|
||||||
ProjectMemberUserEndpoint,
|
ProjectMemberUserEndpoint,
|
||||||
ProjectFavoritesViewSet,
|
ProjectFavoritesViewSet,
|
||||||
ProjectDeployBoardIssuesPublicEndpoint,
|
|
||||||
ProjectDeployBoardViewSet,
|
ProjectDeployBoardViewSet,
|
||||||
ProjectDeployBoardPublicSettingsEndpoint,
|
ProjectDeployBoardPublicSettingsEndpoint,
|
||||||
ProjectMemberEndpoint,
|
ProjectMemberEndpoint,
|
||||||
|
WorkspaceProjectDeployBoardEndpoint,
|
||||||
|
LeaveProjectEndpoint,
|
||||||
)
|
)
|
||||||
from .user import (
|
from .user import (
|
||||||
UserEndpoint,
|
UserEndpoint,
|
||||||
@ -52,6 +53,7 @@ from .workspace import (
|
|||||||
WorkspaceUserProfileIssuesEndpoint,
|
WorkspaceUserProfileIssuesEndpoint,
|
||||||
WorkspaceLabelsEndpoint,
|
WorkspaceLabelsEndpoint,
|
||||||
WorkspaceMembersEndpoint,
|
WorkspaceMembersEndpoint,
|
||||||
|
LeaveWorkspaceEndpoint,
|
||||||
)
|
)
|
||||||
from .state import StateViewSet
|
from .state import StateViewSet
|
||||||
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
|
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
|
||||||
@ -84,6 +86,8 @@ from .issue import (
|
|||||||
IssueReactionPublicViewSet,
|
IssueReactionPublicViewSet,
|
||||||
CommentReactionPublicViewSet,
|
CommentReactionPublicViewSet,
|
||||||
IssueVotePublicViewSet,
|
IssueVotePublicViewSet,
|
||||||
|
IssueRetrievePublicEndpoint,
|
||||||
|
ProjectIssuesPublicEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .auth_extended import (
|
from .auth_extended import (
|
||||||
@ -161,7 +165,7 @@ from .analytic import (
|
|||||||
DefaultAnalyticsEndpoint,
|
DefaultAnalyticsEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .notification import NotificationViewSet, UnreadNotificationEndpoint
|
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
|
||||||
|
|
||||||
from .exporter import (
|
from .exporter import (
|
||||||
ExportIssuesEndpoint,
|
ExportIssuesEndpoint,
|
||||||
|
@ -18,10 +18,21 @@ class FileAssetEndpoint(BaseAPIView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def get(self, request, workspace_id, asset_key):
|
def get(self, request, workspace_id, asset_key):
|
||||||
|
try:
|
||||||
asset_key = str(workspace_id) + "/" + asset_key
|
asset_key = str(workspace_id) + "/" + asset_key
|
||||||
files = FileAsset.objects.filter(asset=asset_key)
|
files = FileAsset.objects.filter(asset=asset_key)
|
||||||
|
if files.exists():
|
||||||
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
|
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
|
||||||
return Response(serializer.data)
|
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)
|
||||||
|
if files.exists():
|
||||||
serializer = FileAssetSerializer(files, context={"request": request})
|
serializer = FileAssetSerializer(files, context={"request": request})
|
||||||
return Response(serializer.data)
|
return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK)
|
||||||
except FileAsset.DoesNotExist:
|
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):
|
||||||
|
@ -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,
|
||||||
|
@ -191,11 +191,10 @@ class CycleViewSet(BaseViewSet):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
)
|
)
|
||||||
.annotate(first_name=F("assignees__first_name"))
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
.annotate(last_name=F("assignees__last_name"))
|
|
||||||
.annotate(assignee_id=F("assignees__id"))
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
.annotate(avatar=F("assignees__avatar"))
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
.values("first_name", "last_name", "assignee_id", "avatar")
|
.values("display_name", "assignee_id", "avatar")
|
||||||
.annotate(total_issues=Count("assignee_id"))
|
.annotate(total_issues=Count("assignee_id"))
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
@ -209,7 +208,7 @@ class CycleViewSet(BaseViewSet):
|
|||||||
filter=Q(completed_at__isnull=True),
|
filter=Q(completed_at__isnull=True),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by("first_name", "last_name")
|
.order_by("display_name")
|
||||||
)
|
)
|
||||||
|
|
||||||
label_distribution = (
|
label_distribution = (
|
||||||
|
@ -28,6 +28,7 @@ from django.conf import settings
|
|||||||
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, IsAuthenticated
|
||||||
from sentry_sdk import capture_exception
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
@ -49,6 +50,7 @@ from plane.api.serializers import (
|
|||||||
IssueReactionSerializer,
|
IssueReactionSerializer,
|
||||||
CommentReactionSerializer,
|
CommentReactionSerializer,
|
||||||
IssueVoteSerializer,
|
IssueVoteSerializer,
|
||||||
|
IssuePublicSerializer,
|
||||||
)
|
)
|
||||||
from plane.api.permissions import (
|
from plane.api.permissions import (
|
||||||
WorkspaceEntityPermission,
|
WorkspaceEntityPermission,
|
||||||
@ -73,10 +75,12 @@ from plane.db.models import (
|
|||||||
CommentReaction,
|
CommentReaction,
|
||||||
ProjectDeployBoard,
|
ProjectDeployBoard,
|
||||||
IssueVote,
|
IssueVote,
|
||||||
|
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):
|
||||||
@ -333,7 +337,7 @@ class UserWorkSpaceIssues(BaseAPIView):
|
|||||||
|
|
||||||
issue_queryset = (
|
issue_queryset = (
|
||||||
Issue.issue_objects.filter(
|
Issue.issue_objects.filter(
|
||||||
(Q(assignees__in=[request.user]) | Q(created_by=request.user)),
|
(Q(assignees__in=[request.user]) | Q(created_by=request.user) | Q(issue_subscribers__subscriber=request.user)),
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
@ -482,7 +486,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")
|
||||||
@ -492,6 +496,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
|
||||||
@ -588,6 +598,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()
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -769,7 +788,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,
|
||||||
@ -1384,6 +1405,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:
|
||||||
@ -1394,6 +1423,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:
|
||||||
@ -1434,6 +1476,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:
|
||||||
@ -1444,6 +1494,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:
|
||||||
@ -1468,6 +1532,18 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
|||||||
"workspace__id",
|
"workspace__id",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
if self.action in ["list", "retrieve"]:
|
||||||
|
self.permission_classes = [
|
||||||
|
AllowAny,
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
self.permission_classes = [
|
||||||
|
IsAuthenticated,
|
||||||
|
]
|
||||||
|
|
||||||
|
return super(IssueCommentPublicViewSet, self).get_permissions()
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
@ -1479,9 +1555,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:
|
||||||
@ -1499,21 +1585,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",
|
||||||
@ -1523,6 +1601,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:
|
||||||
@ -1567,7 +1655,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:
|
||||||
@ -1648,6 +1737,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:
|
||||||
@ -1679,6 +1785,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:
|
||||||
@ -1733,8 +1852,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"},
|
||||||
@ -1765,6 +1905,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:
|
||||||
@ -1799,7 +1953,24 @@ 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)
|
||||||
@ -1818,6 +1989,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:
|
||||||
@ -1828,24 +2012,170 @@ class IssueVotePublicViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExportIssuesEndpoint(BaseAPIView):
|
class IssueRetrievePublicEndpoint(BaseAPIView):
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
WorkSpaceAdminPermission,
|
AllowAny,
|
||||||
]
|
]
|
||||||
|
|
||||||
def post(self, request, slug):
|
def get(self, request, slug, project_id, issue_id):
|
||||||
try:
|
try:
|
||||||
|
issue = Issue.objects.get(
|
||||||
issue_export_task.delay(
|
workspace__slug=slug, project_id=project_id, pk=issue_id
|
||||||
email=request.user.email, data=request.data, slug=slug ,exporter_name=request.user.first_name
|
|
||||||
)
|
)
|
||||||
|
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(
|
return Response(
|
||||||
{
|
{
|
||||||
"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"
|
"issues": issues,
|
||||||
|
"states": states,
|
||||||
|
"labels": labels,
|
||||||
},
|
},
|
||||||
status=status.HTTP_200_OK,
|
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:
|
except Exception as e:
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
return Response(
|
return Response(
|
||||||
|
@ -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,
|
||||||
|
)
|
||||||
|
@ -11,14 +11,8 @@ from django.db.models import (
|
|||||||
OuterRef,
|
OuterRef,
|
||||||
Func,
|
Func,
|
||||||
F,
|
F,
|
||||||
Max,
|
|
||||||
CharField,
|
|
||||||
Func,
|
Func,
|
||||||
Subquery,
|
Subquery,
|
||||||
Prefetch,
|
|
||||||
When,
|
|
||||||
Case,
|
|
||||||
Value,
|
|
||||||
)
|
)
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -47,6 +41,7 @@ from plane.api.permissions import (
|
|||||||
ProjectBasePermission,
|
ProjectBasePermission,
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
ProjectMemberPermission,
|
ProjectMemberPermission,
|
||||||
|
ProjectLitePermission,
|
||||||
)
|
)
|
||||||
|
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
@ -71,16 +66,9 @@ from plane.db.models import (
|
|||||||
ModuleMember,
|
ModuleMember,
|
||||||
Inbox,
|
Inbox,
|
||||||
ProjectDeployBoard,
|
ProjectDeployBoard,
|
||||||
Issue,
|
|
||||||
IssueReaction,
|
|
||||||
IssueLink,
|
|
||||||
IssueAttachment,
|
|
||||||
Label,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from plane.bgtasks.project_invitation_task import project_invitation
|
from plane.bgtasks.project_invitation_task import project_invitation
|
||||||
from plane.utils.grouper import group_results
|
|
||||||
from plane.utils.issue_filters import issue_filters
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectViewSet(BaseViewSet):
|
class ProjectViewSet(BaseViewSet):
|
||||||
@ -287,7 +275,10 @@ class ProjectViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
data = serializer.data
|
data = serializer.data
|
||||||
|
# Additional fields of the member
|
||||||
data["sort_order"] = project_member.sort_order
|
data["sort_order"] = project_member.sort_order
|
||||||
|
data["member_role"] = project_member.role
|
||||||
|
data["is_member"] = True
|
||||||
return Response(data, status=status.HTTP_201_CREATED)
|
return Response(data, status=status.HTTP_201_CREATED)
|
||||||
return Response(
|
return Response(
|
||||||
serializer.errors,
|
serializer.errors,
|
||||||
@ -626,7 +617,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
except ProjectMember.DoesNotExist:
|
except ProjectMember.DoesNotExist:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Project Member does not exist"}, status=status.HTTP_400
|
{"error": "Project Member does not exist"}, status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
@ -1140,145 +1131,78 @@ class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProjectDeployBoardIssuesPublicEndpoint(BaseAPIView):
|
class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
AllowAny,
|
AllowAny,
|
||||||
]
|
]
|
||||||
|
|
||||||
def get(self, request, slug, project_id):
|
def get(self, request, slug):
|
||||||
try:
|
try:
|
||||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
projects = (
|
||||||
workspace__slug=slug, project_id=project_id
|
Project.objects.filter(workspace__slug=slug)
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
.annotate(
|
||||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
is_public=Exists(
|
||||||
.order_by()
|
ProjectDeployBoard.objects.filter(
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
workspace__slug=slug, project_id=OuterRef("pk")
|
||||||
.values("count")
|
|
||||||
)
|
)
|
||||||
.annotate(
|
|
||||||
attachment_count=IssueAttachment.objects.filter(
|
|
||||||
issue=OuterRef("id")
|
|
||||||
)
|
)
|
||||||
.order_by()
|
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
|
||||||
.values("count")
|
|
||||||
)
|
)
|
||||||
|
.filter(is_public=True)
|
||||||
|
).values(
|
||||||
|
"id",
|
||||||
|
"identifier",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"emoji",
|
||||||
|
"icon_prop",
|
||||||
|
"cover_image",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Priority Ordering
|
return Response(projects, status=status.HTTP_200_OK)
|
||||||
if order_by_param == "priority" or order_by_param == "-priority":
|
except Exception as e:
|
||||||
priority_order = (
|
capture_exception(e)
|
||||||
priority_order
|
return Response(
|
||||||
if order_by_param == "priority"
|
{"error": "Something went wrong please try again later"},
|
||||||
else priority_order[::-1]
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
issue_queryset = issue_queryset.annotate(
|
|
||||||
priority_order=Case(
|
|
||||||
*[
|
class LeaveProjectEndpoint(BaseAPIView):
|
||||||
When(priority=p, then=Value(i))
|
permission_classes = [
|
||||||
for i, p in enumerate(priority_order)
|
ProjectLitePermission,
|
||||||
],
|
]
|
||||||
output_field=CharField(),
|
|
||||||
|
def delete(self, request, slug, project_id):
|
||||||
|
try:
|
||||||
|
project_member = ProjectMember.objects.get(
|
||||||
|
workspace__slug=slug,
|
||||||
|
member=request.user,
|
||||||
|
project_id=project_id,
|
||||||
)
|
)
|
||||||
).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)
|
|
||||||
|
|
||||||
|
# Only Admin case
|
||||||
|
if (
|
||||||
|
project_member.role == 20
|
||||||
|
and ProjectMember.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
role=20,
|
||||||
|
project_id=project_id,
|
||||||
|
).count()
|
||||||
|
== 1
|
||||||
|
):
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"issues": issues,
|
"error": "You cannot leave the project since you are the only admin of the project you should delete the project"
|
||||||
"states": states,
|
|
||||||
"labels": labels,
|
|
||||||
},
|
},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
except ProjectDeployBoard.DoesNotExist:
|
# Delete the member from workspace
|
||||||
|
project_member.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
except ProjectMember.DoesNotExist:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND
|
{"error": "Workspace member does not exists"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
@ -1473,3 +1473,44 @@ class WorkspaceMembersEndpoint(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 LeaveWorkspaceEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
WorkspaceEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def delete(self, request, slug):
|
||||||
|
try:
|
||||||
|
workspace_member = WorkspaceMember.objects.get(
|
||||||
|
workspace__slug=slug, member=request.user
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only Admin case
|
||||||
|
if (
|
||||||
|
workspace_member.role == 20
|
||||||
|
and WorkspaceMember.objects.filter(
|
||||||
|
workspace__slug=slug, role=20
|
||||||
|
).count()
|
||||||
|
== 1
|
||||||
|
):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "You cannot leave the workspace since you are the only admin of the workspace you should delete the workspace"
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
# Delete the member from workspace
|
||||||
|
workspace_member.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
except WorkspaceMember.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Workspace member does not exists"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
@ -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,10 +24,12 @@ 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()
|
||||||
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||||
@ -66,6 +69,34 @@ 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):
|
||||||
|
file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
|
||||||
|
expires_in = 7 * 24 * 60 * 60
|
||||||
|
|
||||||
|
if settings.DOCKERIZED and settings.USE_MINIO:
|
||||||
|
s3 = boto3.client(
|
||||||
|
"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 = boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
region_name=settings.AWS_REGION,
|
region_name=settings.AWS_REGION,
|
||||||
@ -73,8 +104,6 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
|
|||||||
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"),
|
||||||
)
|
)
|
||||||
file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
|
|
||||||
|
|
||||||
s3.upload_fileobj(
|
s3.upload_fileobj(
|
||||||
zip_file,
|
zip_file,
|
||||||
settings.AWS_S3_BUCKET_NAME,
|
settings.AWS_S3_BUCKET_NAME,
|
||||||
@ -82,7 +111,6 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
|
|||||||
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
|
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
|
||||||
)
|
)
|
||||||
|
|
||||||
expires_in = 7 * 24 * 60 * 60
|
|
||||||
presigned_url = s3.generate_presigned_url(
|
presigned_url = s3.generate_presigned_url(
|
||||||
"get_object",
|
"get_object",
|
||||||
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
|
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
|
||||||
@ -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(
|
||||||
@ -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)
|
||||||
|
@ -21,7 +21,15 @@ 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",
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
s3 = boto3.client(
|
s3 = boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
region_name="ap-south-1",
|
region_name="ap-south-1",
|
||||||
@ -33,6 +41,9 @@ def delete_old_s3_link():
|
|||||||
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:
|
||||||
|
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)
|
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)
|
||||||
|
@ -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
|
||||||
|
|
||||||
@ -629,7 +632,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,
|
||||||
@ -1022,6 +1025,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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Receive message from room group
|
# Receive message from room group
|
||||||
@shared_task
|
@shared_task
|
||||||
@ -1045,6 +1192,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()
|
||||||
|
|
||||||
@ -1080,6 +1233,12 @@ def issue_activity(
|
|||||||
"link.activity.deleted": delete_link_activity,
|
"link.activity.deleted": delete_link_activity,
|
||||||
"attachment.activity.created": create_attachment_activity,
|
"attachment.activity.created": create_attachment_activity,
|
||||||
"attachment.activity.deleted": delete_attachment_activity,
|
"attachment.activity.deleted": delete_attachment_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)
|
||||||
@ -1119,6 +1278,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 = []
|
||||||
|
@ -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
@ -19,6 +19,7 @@ from .project import (
|
|||||||
ProjectIdentifier,
|
ProjectIdentifier,
|
||||||
ProjectFavorite,
|
ProjectFavorite,
|
||||||
ProjectDeployBoard,
|
ProjectDeployBoard,
|
||||||
|
ProjectPublicMember,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .issue import (
|
from .issue import (
|
||||||
|
@ -293,7 +293,7 @@ class IssueComment(ProjectBaseModel):
|
|||||||
comment_json = models.JSONField(blank=True, default=dict)
|
comment_json = models.JSONField(blank=True, default=dict)
|
||||||
comment_html = models.TextField(blank=True, default="<p></p>")
|
comment_html = models.TextField(blank=True, default="<p></p>")
|
||||||
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
|
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
|
||||||
issue = models.ForeignKey(Issue, on_delete=models.CASCADE)
|
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_comments")
|
||||||
# System can also create comment
|
# System can also create comment
|
||||||
actor = models.ForeignKey(
|
actor = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
@ -476,10 +476,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"
|
||||||
|
@ -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",)
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -1 +1 @@
|
|||||||
python-3.11.4
|
python-3.11.5
|
@ -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>
|
||||||
|
@ -22,8 +22,6 @@ const shortcuts = [
|
|||||||
{ keys: "↓", description: "Move down" },
|
{ keys: "↓", description: "Move down" },
|
||||||
{ keys: "←", description: "Move left" },
|
{ keys: "←", description: "Move left" },
|
||||||
{ keys: "→", description: "Move right" },
|
{ keys: "→", description: "Move right" },
|
||||||
{ keys: "Enter", description: "Select" },
|
|
||||||
{ keys: "Esc", description: "Close" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -53,7 +53,11 @@ const UserLink = ({ activity }: { activity: IIssueActivity }) => {
|
|||||||
|
|
||||||
const activityDetails: {
|
const activityDetails: {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
message: (activity: IIssueActivity, showIssue: boolean) => React.ReactNode;
|
message: (
|
||||||
|
activity: IIssueActivity,
|
||||||
|
showIssue: boolean,
|
||||||
|
workspaceSlug: string
|
||||||
|
) => React.ReactNode;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
};
|
};
|
||||||
} = {
|
} = {
|
||||||
@ -173,26 +177,50 @@ const activityDetails: {
|
|||||||
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
|
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
|
||||||
},
|
},
|
||||||
cycles: {
|
cycles: {
|
||||||
message: (activity) => {
|
message: (activity, showIssue, workspaceSlug) => {
|
||||||
if (activity.verb === "created")
|
if (activity.verb === "created")
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
added this issue to the cycle{" "}
|
added this issue to the cycle{" "}
|
||||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
|
<a
|
||||||
|
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
|
{activity.new_value}
|
||||||
|
<Icon iconName="launch" className="!text-xs" />
|
||||||
|
</a>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
else if (activity.verb === "updated")
|
else if (activity.verb === "updated")
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
set the cycle to{" "}
|
set the cycle to{" "}
|
||||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
|
<a
|
||||||
|
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
|
{activity.new_value}
|
||||||
|
<Icon iconName="launch" className="!text-xs" />
|
||||||
|
</a>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
else
|
else
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
removed the issue from the cycle{" "}
|
removed the issue from the cycle{" "}
|
||||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
<a
|
||||||
|
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.old_identifier}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
|
{activity.old_value}
|
||||||
|
<Icon iconName="launch" className="!text-xs" />
|
||||||
|
</a>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -325,6 +353,28 @@ const activityDetails: {
|
|||||||
.
|
.
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
else if (activity.verb === "updated")
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
updated the{" "}
|
||||||
|
<a
|
||||||
|
href={`${activity.old_value}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
|
link
|
||||||
|
<Icon iconName="launch" className="!text-xs" />
|
||||||
|
</a>
|
||||||
|
{showIssue && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
from <IssueLink activity={activity} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
);
|
||||||
else
|
else
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -351,26 +401,50 @@ const activityDetails: {
|
|||||||
icon: <Icon iconName="link" className="!text-sm" aria-hidden="true" />,
|
icon: <Icon iconName="link" className="!text-sm" aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
message: (activity) => {
|
message: (activity, showIssue, workspaceSlug) => {
|
||||||
if (activity.verb === "created")
|
if (activity.verb === "created")
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
added this issue to the module{" "}
|
added this issue to the module{" "}
|
||||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
|
<a
|
||||||
|
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.new_identifier}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
|
{activity.new_value}
|
||||||
|
<Icon iconName="launch" className="!text-xs" />
|
||||||
|
</a>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
else if (activity.verb === "updated")
|
else if (activity.verb === "updated")
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
set the module to{" "}
|
set the module to{" "}
|
||||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
|
<a
|
||||||
|
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.new_identifier}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
|
{activity.new_value}
|
||||||
|
<Icon iconName="launch" className="!text-xs" />
|
||||||
|
</a>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
else
|
else
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
removed the issue from the module{" "}
|
removed the issue from the module{" "}
|
||||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
<a
|
||||||
|
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.old_identifier}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
|
{activity.old_value}
|
||||||
|
<Icon iconName="launch" className="!text-xs" />
|
||||||
|
</a>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -538,8 +612,17 @@ export const ActivityMessage = ({
|
|||||||
}: {
|
}: {
|
||||||
activity: IIssueActivity;
|
activity: IIssueActivity;
|
||||||
showIssue?: boolean;
|
showIssue?: boolean;
|
||||||
}) => (
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
{activityDetails[activity.field as keyof typeof activityDetails]?.message(activity, showIssue)}
|
{activityDetails[activity.field as keyof typeof activityDetails]?.message(
|
||||||
|
activity,
|
||||||
|
showIssue,
|
||||||
|
workspaceSlug?.toString() ?? ""
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
@ -11,15 +11,18 @@ import { Dialog, Transition } from "@headlessui/react";
|
|||||||
// hooks
|
// hooks
|
||||||
import useIssuesView from "hooks/use-issues-view";
|
import useIssuesView from "hooks/use-issues-view";
|
||||||
// components
|
// components
|
||||||
import { DueDateFilterSelect } from "./due-date-filter-select";
|
import { DateFilterSelect } from "./date-filter-select";
|
||||||
// ui
|
// ui
|
||||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||||
// helpers
|
// helpers
|
||||||
import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||||
|
import { IIssueFilterOptions } from "types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
field: keyof IIssueFilterOptions;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
};
|
};
|
||||||
@ -36,7 +39,7 @@ const defaultValues: TFormValues = {
|
|||||||
date2: new Date(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()),
|
date2: new Date(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DueDateFilterModal: React.FC<Props> = ({ isOpen, handleClose }) => {
|
export const DateFilterModal: React.FC<Props> = ({ title, field, isOpen, handleClose }) => {
|
||||||
const { filters, setFilters } = useIssuesView();
|
const { filters, setFilters } = useIssuesView();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -51,11 +54,11 @@ export const DueDateFilterModal: React.FC<Props> = ({ isOpen, handleClose }) =>
|
|||||||
|
|
||||||
if (filterType === "range") {
|
if (filterType === "range") {
|
||||||
setFilters(
|
setFilters(
|
||||||
{ target_date: [`${renderDateFormat(date1)};after`, `${renderDateFormat(date2)};before`] },
|
{ [field]: [`${renderDateFormat(date1)};after`, `${renderDateFormat(date2)};before`] },
|
||||||
!Boolean(viewId)
|
!Boolean(viewId)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const filteredArray = filters?.target_date?.filter((item) => {
|
const filteredArray = (filters?.[field] as string[])?.filter((item) => {
|
||||||
if (item?.includes(filterType)) return false;
|
if (item?.includes(filterType)) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@ -64,13 +67,13 @@ export const DueDateFilterModal: React.FC<Props> = ({ isOpen, handleClose }) =>
|
|||||||
const filterOne = filteredArray && filteredArray?.length > 0 ? filteredArray[0] : null;
|
const filterOne = filteredArray && filteredArray?.length > 0 ? filteredArray[0] : null;
|
||||||
if (filterOne)
|
if (filterOne)
|
||||||
setFilters(
|
setFilters(
|
||||||
{ target_date: [filterOne, `${renderDateFormat(date1)};${filterType}`] },
|
{ [field]: [filterOne, `${renderDateFormat(date1)};${filterType}`] },
|
||||||
!Boolean(viewId)
|
!Boolean(viewId)
|
||||||
);
|
);
|
||||||
else
|
else
|
||||||
setFilters(
|
setFilters(
|
||||||
{
|
{
|
||||||
target_date: [`${renderDateFormat(date1)};${filterType}`],
|
[field]: [`${renderDateFormat(date1)};${filterType}`],
|
||||||
},
|
},
|
||||||
!Boolean(viewId)
|
!Boolean(viewId)
|
||||||
);
|
);
|
||||||
@ -116,7 +119,7 @@ export const DueDateFilterModal: React.FC<Props> = ({ isOpen, handleClose }) =>
|
|||||||
control={control}
|
control={control}
|
||||||
name="filterType"
|
name="filterType"
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<DueDateFilterSelect value={value} onChange={onChange} />
|
<DateFilterSelect title={title} value={value} onChange={onChange} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<XMarkIcon
|
<XMarkIcon
|
@ -7,6 +7,7 @@ import { CalendarBeforeIcon, CalendarAfterIcon, CalendarMonthIcon } from "compon
|
|||||||
// fetch-keys
|
// fetch-keys
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
title: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
};
|
};
|
||||||
@ -19,29 +20,31 @@ type DueDate = {
|
|||||||
|
|
||||||
const dueDateRange: DueDate[] = [
|
const dueDateRange: DueDate[] = [
|
||||||
{
|
{
|
||||||
name: "Due date before",
|
name: "before",
|
||||||
value: "before",
|
value: "before",
|
||||||
icon: <CalendarBeforeIcon className="h-4 w-4 " />,
|
icon: <CalendarBeforeIcon className="h-4 w-4 " />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Due date after",
|
name: "after",
|
||||||
value: "after",
|
value: "after",
|
||||||
icon: <CalendarAfterIcon className="h-4 w-4 " />,
|
icon: <CalendarAfterIcon className="h-4 w-4 " />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Due date range",
|
name: "range",
|
||||||
value: "range",
|
value: "range",
|
||||||
icon: <CalendarMonthIcon className="h-4 w-4 " />,
|
icon: <CalendarMonthIcon className="h-4 w-4 " />,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DueDateFilterSelect: React.FC<Props> = ({ value, onChange }) => (
|
export const DateFilterSelect: React.FC<Props> = ({ title, value, onChange }) => (
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={value}
|
value={value}
|
||||||
label={
|
label={
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
{dueDateRange.find((item) => item.value === value)?.icon}
|
{dueDateRange.find((item) => item.value === value)?.icon}
|
||||||
<span>{dueDateRange.find((item) => item.value === value)?.name}</span>
|
<span>
|
||||||
|
{title} {dueDateRange.find((item) => item.value === value)?.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@ -50,7 +53,7 @@ export const DueDateFilterSelect: React.FC<Props> = ({ value, onChange }) => (
|
|||||||
<CustomSelect.Option key={index} value={option.value}>
|
<CustomSelect.Option key={index} value={option.value}>
|
||||||
<>
|
<>
|
||||||
<span>{option.icon}</span>
|
<span>{option.icon}</span>
|
||||||
{option.name}
|
{title} {option.name}
|
||||||
</>
|
</>
|
||||||
</CustomSelect.Option>
|
</CustomSelect.Option>
|
||||||
))}
|
))}
|
@ -240,6 +240,34 @@ export const FiltersList: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
: key === "start_date"
|
||||||
|
? filters.start_date?.map((date: string) => {
|
||||||
|
if (filters.start_date && filters.start_date.length <= 0) return null;
|
||||||
|
|
||||||
|
const splitDate = date.split(";");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={date}
|
||||||
|
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
|
||||||
|
>
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full" />
|
||||||
|
<span className="capitalize">
|
||||||
|
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
start_date: filters.start_date?.filter((d: any) => d !== date),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
: key === "target_date"
|
: key === "target_date"
|
||||||
? filters.target_date?.map((date: string) => {
|
? filters.target_date?.map((date: string) => {
|
||||||
if (filters.target_date && filters.target_date.length <= 0) return null;
|
if (filters.target_date && filters.target_date.length <= 0) return null;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
export * from "./due-date-filter-modal";
|
export * from "./date-filter-modal";
|
||||||
export * from "./due-date-filter-select";
|
export * from "./date-filter-select";
|
||||||
export * from "./filters-list";
|
export * from "./filters-list";
|
||||||
export * from "./issues-view-filter";
|
export * from "./issues-view-filter";
|
||||||
|
@ -113,20 +113,16 @@ 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 === "target_date") {
|
if (key === "start_date" || key === "target_date") {
|
||||||
const valueExists = checkIfArraysHaveSameElements(
|
const valueExists = checkIfArraysHaveSameElements(filters[key] ?? [], option.value);
|
||||||
filters.target_date ?? [],
|
|
||||||
option.value
|
|
||||||
);
|
|
||||||
|
|
||||||
setFilters({
|
setFilters({
|
||||||
target_date: 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);
|
||||||
@ -152,7 +148,6 @@ export const IssuesFilterView: React.FC = () => {
|
|||||||
direction="left"
|
direction="left"
|
||||||
height="rg"
|
height="rg"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<Popover className="relative">
|
<Popover className="relative">
|
||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<>
|
<>
|
||||||
@ -163,7 +158,7 @@ export const IssuesFilterView: React.FC = () => {
|
|||||||
: "text-custom-sidebar-text-200"
|
: "text-custom-sidebar-text-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
View
|
Display
|
||||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
|
|
||||||
|
@ -40,9 +40,15 @@ type Props = {
|
|||||||
label: string | React.ReactNode;
|
label: string | React.ReactNode;
|
||||||
value: string | null;
|
value: string | null;
|
||||||
onChange: (data: string) => void;
|
onChange: (data: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange }) => {
|
export const ImagePickerPopover: React.FC<Props> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -117,6 +123,7 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
|
|||||||
<Popover.Button
|
<Popover.Button
|
||||||
className="rounded-md border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
|
className="rounded-md border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
|
||||||
onClick={() => setIsOpen((prev) => !prev)}
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
|
@ -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,7 +174,8 @@ 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..."
|
||||||
}`}
|
}`}
|
||||||
|
@ -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
|
||||||
|
@ -12,7 +12,7 @@ import useProjects from "hooks/use-projects";
|
|||||||
// component
|
// component
|
||||||
import { Avatar, Icon } from "components/ui";
|
import { Avatar, Icon } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicons/react/24/outline";
|
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||||
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
|
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
|
||||||
// helpers
|
// helpers
|
||||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||||
@ -29,6 +29,7 @@ type Props = {
|
|||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
disableUserActions: boolean;
|
disableUserActions: boolean;
|
||||||
|
disableAddIssue: boolean;
|
||||||
viewProps: IIssueViewProps;
|
viewProps: IIssueViewProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ export const BoardHeader: React.FC<Props> = ({
|
|||||||
isCollapsed,
|
isCollapsed,
|
||||||
setIsCollapsed,
|
setIsCollapsed,
|
||||||
disableUserActions,
|
disableUserActions,
|
||||||
|
disableAddIssue,
|
||||||
viewProps,
|
viewProps,
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -56,10 +58,10 @@ export const BoardHeader: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { data: members } = useSWR(
|
const { data: members } = useSWR(
|
||||||
workspaceSlug && projectId && selectedGroup === "created_by"
|
workspaceSlug && projectId && (selectedGroup === "created_by" || selectedGroup === "assignees")
|
||||||
? PROJECT_MEMBERS(projectId.toString())
|
? PROJECT_MEMBERS(projectId.toString())
|
||||||
: null,
|
: null,
|
||||||
workspaceSlug && projectId && selectedGroup === "created_by"
|
workspaceSlug && projectId && (selectedGroup === "created_by" || selectedGroup === "assignees")
|
||||||
? () => projectService.projectMembers(workspaceSlug.toString(), projectId.toString())
|
? () => projectService.projectMembers(workspaceSlug.toString(), projectId.toString())
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
@ -79,9 +81,11 @@ export const BoardHeader: React.FC<Props> = ({
|
|||||||
case "project":
|
case "project":
|
||||||
title = projects?.find((p) => p.id === groupTitle)?.name ?? "None";
|
title = projects?.find((p) => p.id === groupTitle)?.name ?? "None";
|
||||||
break;
|
break;
|
||||||
|
case "assignees":
|
||||||
case "created_by":
|
case "created_by":
|
||||||
const member = members?.find((member) => member.member.id === groupTitle)?.member;
|
const member = members?.find((member) => member.member.id === groupTitle)?.member;
|
||||||
title = member?.display_name ?? "";
|
title = member ? member.display_name : "None";
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,9 +126,10 @@ export const BoardHeader: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "assignees":
|
||||||
case "created_by":
|
case "created_by":
|
||||||
const member = members?.find((member) => member.member.id === groupTitle)?.member;
|
const member = members?.find((member) => member.member.id === groupTitle)?.member;
|
||||||
icon = <Avatar user={member} height="24px" width="24px" fontSize="12px" />;
|
icon = member ? <Avatar user={member} height="24px" width="24px" fontSize="12px" /> : <></>;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -178,7 +183,7 @@ export const BoardHeader: React.FC<Props> = ({
|
|||||||
<Icon iconName="open_in_full" className="text-base font-medium text-custom-text-900" />
|
<Icon iconName="open_in_full" className="text-base font-medium text-custom-text-900" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{!disableUserActions && selectedGroup !== "created_by" && (
|
{!disableAddIssue && !disableUserActions && selectedGroup !== "created_by" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="grid h-7 w-7 place-items-center rounded p-1 text-custom-text-200 outline-none duration-300 hover:bg-custom-background-80"
|
className="grid h-7 w-7 place-items-center rounded p-1 text-custom-text-200 outline-none duration-300 hover:bg-custom-background-80"
|
||||||
|
@ -53,6 +53,8 @@ export const SingleBoard: React.FC<Props> = ({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { cycleId, moduleId } = router.query;
|
const { cycleId, moduleId } = router.query;
|
||||||
|
|
||||||
|
const isSubscribedIssues = router.pathname.includes("subscribed");
|
||||||
|
|
||||||
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
|
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
|
||||||
|
|
||||||
// Check if it has at least 4 tickets since it is enough to accommodate the Calendar height
|
// Check if it has at least 4 tickets since it is enough to accommodate the Calendar height
|
||||||
@ -70,6 +72,7 @@ export const SingleBoard: React.FC<Props> = ({
|
|||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
setIsCollapsed={setIsCollapsed}
|
setIsCollapsed={setIsCollapsed}
|
||||||
disableUserActions={disableUserActions}
|
disableUserActions={disableUserActions}
|
||||||
|
disableAddIssue={isSubscribedIssues}
|
||||||
viewProps={viewProps}
|
viewProps={viewProps}
|
||||||
/>
|
/>
|
||||||
{isCollapsed && (
|
{isCollapsed && (
|
||||||
@ -150,7 +153,8 @@ export const SingleBoard: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
{selectedGroup !== "created_by" && (
|
{selectedGroup !== "created_by" && (
|
||||||
<div>
|
<div>
|
||||||
{type === "issue" ? (
|
{type === "issue"
|
||||||
|
? !isSubscribedIssues && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1"
|
className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1"
|
||||||
@ -159,8 +163,8 @@ export const SingleBoard: React.FC<Props> = ({
|
|||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
Add Issue
|
Add Issue
|
||||||
</button>
|
</button>
|
||||||
) : (
|
)
|
||||||
!disableUserActions && (
|
: !disableUserActions && (
|
||||||
<CustomMenu
|
<CustomMenu
|
||||||
customButton={
|
customButton={
|
||||||
<button
|
<button
|
||||||
@ -183,7 +187,6 @@ export const SingleBoard: React.FC<Props> = ({
|
|||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
)}
|
)}
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -350,7 +350,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{properties.labels && issue.labels.length > 0 && (
|
{properties.labels && issue.labels.length > 0 && (
|
||||||
<ViewIssueLabel issue={issue} maxRender={2} />
|
<ViewIssueLabel labelDetails={issue.label_details} maxRender={2} />
|
||||||
)}
|
)}
|
||||||
{properties.assignee && (
|
{properties.assignee && (
|
||||||
<ViewAssigneeSelect
|
<ViewAssigneeSelect
|
||||||
|
@ -478,6 +478,7 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
labels: null,
|
labels: null,
|
||||||
priority: null,
|
priority: null,
|
||||||
state: null,
|
state: null,
|
||||||
|
start_date: null,
|
||||||
target_date: null,
|
target_date: null,
|
||||||
type: null,
|
type: null,
|
||||||
})
|
})
|
||||||
@ -513,7 +514,8 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
dragDisabled={
|
dragDisabled={
|
||||||
selectedGroup === "created_by" ||
|
selectedGroup === "created_by" ||
|
||||||
selectedGroup === "labels" ||
|
selectedGroup === "labels" ||
|
||||||
selectedGroup === "state_detail.group"
|
selectedGroup === "state_detail.group" ||
|
||||||
|
selectedGroup === "assignees"
|
||||||
}
|
}
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: cycleId
|
title: cycleId
|
||||||
@ -546,7 +548,7 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
handleOnDragEnd={handleOnDragEnd}
|
handleOnDragEnd={handleOnDragEnd}
|
||||||
handleIssueAction={handleIssueAction}
|
handleIssueAction={handleIssueAction}
|
||||||
openIssuesListModal={openIssuesListModal ? openIssuesListModal : null}
|
openIssuesListModal={openIssuesListModal ?? null}
|
||||||
removeIssue={cycleId ? removeIssueFromCycle : moduleId ? removeIssueFromModule : null}
|
removeIssue={cycleId ? removeIssueFromCycle : moduleId ? removeIssueFromModule : null}
|
||||||
trashBox={trashBox}
|
trashBox={trashBox}
|
||||||
setTrashBox={setTrashBox}
|
setTrashBox={setTrashBox}
|
||||||
|
@ -36,9 +36,21 @@ import { LayerDiagonalIcon } from "components/icons";
|
|||||||
import { copyTextToClipboard } from "helpers/string.helper";
|
import { copyTextToClipboard } from "helpers/string.helper";
|
||||||
import { handleIssuesMutation } from "constants/issue";
|
import { handleIssuesMutation } from "constants/issue";
|
||||||
// types
|
// types
|
||||||
import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types";
|
import {
|
||||||
|
ICurrentUserResponse,
|
||||||
|
IIssue,
|
||||||
|
IIssueViewProps,
|
||||||
|
ISubIssueResponse,
|
||||||
|
IUserProfileProjectSegregation,
|
||||||
|
UserAuth,
|
||||||
|
} from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys";
|
import {
|
||||||
|
CYCLE_DETAILS,
|
||||||
|
MODULE_DETAILS,
|
||||||
|
SUB_ISSUES,
|
||||||
|
USER_PROFILE_PROJECT_SEGREGATION,
|
||||||
|
} from "constants/fetch-keys";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
type?: string;
|
type?: string;
|
||||||
@ -74,7 +86,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
const [contextMenuPosition, setContextMenuPosition] = useState<React.MouseEvent | null>(null);
|
const [contextMenuPosition, setContextMenuPosition] = useState<React.MouseEvent | null>(null);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
const { workspaceSlug, projectId, cycleId, moduleId, userId } = router.query;
|
||||||
const isArchivedIssues = router.pathname.includes("archived-issues");
|
const isArchivedIssues = router.pathname.includes("archived-issues");
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
@ -126,6 +138,11 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
mutateIssues();
|
mutateIssues();
|
||||||
|
|
||||||
|
if (userId)
|
||||||
|
mutate<IUserProfileProjectSegregation>(
|
||||||
|
USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString())
|
||||||
|
);
|
||||||
|
|
||||||
if (cycleId) mutate(CYCLE_DETAILS(cycleId as string));
|
if (cycleId) mutate(CYCLE_DETAILS(cycleId as string));
|
||||||
if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
|
if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
|
||||||
});
|
});
|
||||||
@ -134,6 +151,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
cycleId,
|
cycleId,
|
||||||
moduleId,
|
moduleId,
|
||||||
|
userId,
|
||||||
groupTitle,
|
groupTitle,
|
||||||
index,
|
index,
|
||||||
selectedGroup,
|
selectedGroup,
|
||||||
@ -261,7 +279,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
isNotAllowed={isNotAllowed}
|
isNotAllowed={isNotAllowed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{properties.labels && <ViewIssueLabel issue={issue} maxRender={3} />}
|
{properties.labels && <ViewIssueLabel labelDetails={issue.label_details} maxRender={3} />}
|
||||||
{properties.assignee && (
|
{properties.assignee && (
|
||||||
<ViewAssigneeSelect
|
<ViewAssigneeSelect
|
||||||
issue={issue}
|
issue={issue}
|
||||||
|
@ -60,6 +60,7 @@ export const SingleList: React.FC<Props> = ({
|
|||||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||||
|
|
||||||
const isArchivedIssues = router.pathname.includes("archived-issues");
|
const isArchivedIssues = router.pathname.includes("archived-issues");
|
||||||
|
const isSubscribedIssues = router.pathname.includes("subscribed");
|
||||||
|
|
||||||
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
|
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
|
||||||
|
|
||||||
@ -94,9 +95,10 @@ export const SingleList: React.FC<Props> = ({
|
|||||||
case "project":
|
case "project":
|
||||||
title = projects?.find((p) => p.id === groupTitle)?.name ?? "None";
|
title = projects?.find((p) => p.id === groupTitle)?.name ?? "None";
|
||||||
break;
|
break;
|
||||||
|
case "assignees":
|
||||||
case "created_by":
|
case "created_by":
|
||||||
const member = members?.find((member) => member.member.id === groupTitle)?.member;
|
const member = members?.find((member) => member.member.id === groupTitle)?.member;
|
||||||
title = member?.display_name ?? "";
|
title = member ? member.display_name : "None";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,9 +139,10 @@ export const SingleList: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "assignees":
|
||||||
case "created_by":
|
case "created_by":
|
||||||
const member = members?.find((member) => member.member.id === groupTitle)?.member;
|
const member = members?.find((member) => member.member.id === groupTitle)?.member;
|
||||||
icon = <Avatar user={member} height="24px" width="24px" fontSize="12px" />;
|
icon = member ? <Avatar user={member} height="24px" width="24px" fontSize="12px" /> : <></>;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -178,6 +181,7 @@ export const SingleList: React.FC<Props> = ({
|
|||||||
{isArchivedIssues ? (
|
{isArchivedIssues ? (
|
||||||
""
|
""
|
||||||
) : type === "issue" ? (
|
) : type === "issue" ? (
|
||||||
|
!isSubscribedIssues && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="p-1 text-custom-text-200 hover:bg-custom-background-80"
|
className="p-1 text-custom-text-200 hover:bg-custom-background-80"
|
||||||
@ -185,6 +189,7 @@ export const SingleList: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
)
|
||||||
) : disableUserActions ? (
|
) : disableUserActions ? (
|
||||||
""
|
""
|
||||||
) : (
|
) : (
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { mutate } from "swr";
|
import { mutate } from "swr";
|
||||||
@ -75,6 +74,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
|
|||||||
nestingLevel,
|
nestingLevel,
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||||
@ -95,7 +95,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
|
|||||||
? VIEW_ISSUES(viewId.toString(), params)
|
? VIEW_ISSUES(viewId.toString(), params)
|
||||||
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
|
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
|
||||||
|
|
||||||
if (issue.parent) {
|
if (issue.parent)
|
||||||
mutate<ISubIssueResponse>(
|
mutate<ISubIssueResponse>(
|
||||||
SUB_ISSUES(issue.parent.toString()),
|
SUB_ISSUES(issue.parent.toString()),
|
||||||
(prevData) => {
|
(prevData) => {
|
||||||
@ -116,7 +116,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
} else {
|
else
|
||||||
mutate<IIssue[]>(
|
mutate<IIssue[]>(
|
||||||
fetchKey,
|
fetchKey,
|
||||||
(prevData) =>
|
(prevData) =>
|
||||||
@ -131,7 +131,6 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
|
|||||||
}),
|
}),
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
issuesService
|
issuesService
|
||||||
.patchIssue(
|
.patchIssue(
|
||||||
@ -158,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 : "";
|
||||||
@ -179,6 +187,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
|
|||||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<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 }}
|
||||||
@ -264,11 +273,13 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
<button
|
||||||
<a className="truncate text-custom-text-100 cursor-pointer w-full text-[0.825rem]">
|
type="button"
|
||||||
|
className="truncate text-custom-text-100 text-left cursor-pointer w-full text-[0.825rem]"
|
||||||
|
onClick={openPeekOverview}
|
||||||
|
>
|
||||||
{issue.name}
|
{issue.name}
|
||||||
</a>
|
</button>
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
{properties.state && (
|
{properties.state && (
|
||||||
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
|
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
|
||||||
@ -312,7 +323,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
{properties.labels && (
|
{properties.labels && (
|
||||||
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
|
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
|
||||||
<ViewIssueLabel issue={issue} maxRender={1} />
|
<ViewIssueLabel labelDetails={issue.label_details} maxRender={1} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -364,5 +375,6 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -5,7 +5,8 @@ import { useRouter } from "next/router";
|
|||||||
|
|
||||||
// components
|
// components
|
||||||
import { SpreadsheetColumns, SpreadsheetIssues } from "components/core";
|
import { SpreadsheetColumns, SpreadsheetIssues } from "components/core";
|
||||||
import { CustomMenu, Icon, 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,6 +60,13 @@ export const SpreadsheetView: React.FC<Props> = ({
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<IssuePeekOverview
|
||||||
|
handleMutation={() => mutateIssues()}
|
||||||
|
projectId={projectId?.toString() ?? ""}
|
||||||
|
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||||
|
readOnly={disableUserActions}
|
||||||
|
/>
|
||||||
<div className="h-full rounded-lg text-custom-text-200 overflow-x-auto whitespace-nowrap bg-custom-background-100">
|
<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">
|
<div className="sticky z-[2] top-0 border-b border-custom-border-200 bg-custom-background-90 w-full min-w-max">
|
||||||
<SpreadsheetColumns columnData={columnData} gridTemplateColumns={gridTemplateColumns} />
|
<SpreadsheetColumns columnData={columnData} gridTemplateColumns={gridTemplateColumns} />
|
||||||
@ -134,5 +142,6 @@ export const SpreadsheetView: React.FC<Props> = ({
|
|||||||
<Spinner />
|
<Spinner />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -31,6 +31,8 @@ import {
|
|||||||
CompletedStateIcon,
|
CompletedStateIcon,
|
||||||
} from "components/icons";
|
} from "components/icons";
|
||||||
import { StarIcon } from "@heroicons/react/24/outline";
|
import { StarIcon } from "@heroicons/react/24/outline";
|
||||||
|
// components
|
||||||
|
import { ViewIssueLabel } from "components/issues";
|
||||||
// helpers
|
// helpers
|
||||||
import {
|
import {
|
||||||
getDateRangeStatus,
|
getDateRangeStatus,
|
||||||
@ -441,7 +443,10 @@ export const ActiveCycleDetails: React.FC = () => {
|
|||||||
issues.map((issue) => (
|
issues.map((issue) => (
|
||||||
<div
|
<div
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
className="flex flex-wrap rounded-md items-center justify-between gap-2 border border-custom-border-200 bg-custom-background-90 px-3 py-1.5"
|
onClick={() =>
|
||||||
|
router.push(`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`)
|
||||||
|
}
|
||||||
|
className="flex flex-wrap cursor-pointer rounded-md items-center justify-between gap-2 border border-custom-border-200 bg-custom-background-90 px-3 py-1.5"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div>
|
<div>
|
||||||
@ -474,27 +479,7 @@ export const ActiveCycleDetails: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{getPriorityIcon(issue.priority, "text-sm")}
|
{getPriorityIcon(issue.priority, "text-sm")}
|
||||||
</div>
|
</div>
|
||||||
{issue.label_details.length > 0 ? (
|
<ViewIssueLabel labelDetails={issue.label_details} maxRender={2} />
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{issue.label_details.map((label) => (
|
|
||||||
<span
|
|
||||||
key={label.id}
|
|
||||||
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs text-custom-text-200"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="h-1.5 w-1.5 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
label?.color && label.color !== "" ? label.color : "#000",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{label.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
<div className={`flex items-center gap-2 text-custom-text-200`}>
|
<div className={`flex items-center gap-2 text-custom-text-200`}>
|
||||||
{issue.assignees &&
|
{issue.assignees &&
|
||||||
issue.assignees.length > 0 &&
|
issue.assignees.length > 0 &&
|
||||||
|
@ -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}
|
||||||
|
83
apps/app/components/cycles/gantt-chart/blocks.tsx
Normal file
83
apps/app/components/cycles/gantt-chart/blocks.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
);
|
);
|
@ -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>
|
||||||
);
|
);
|
3
apps/app/components/cycles/gantt-chart/index.ts
Normal file
3
apps/app/components/cycles/gantt-chart/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./blocks";
|
||||||
|
export * from "./cycle-issues-layout";
|
||||||
|
export * from "./cycles-list-layout";
|
@ -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";
|
||||||
|
@ -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,
|
||||||
|
@ -23,7 +23,13 @@ const tabOptions = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange, onIconColorChange }) => {
|
const EmojiIconPicker: React.FC<Props> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onIconColorChange,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [openColorPicker, setOpenColorPicker] = useState(false);
|
const [openColorPicker, setOpenColorPicker] = useState(false);
|
||||||
const [activeColor, setActiveColor] = useState<string>("rgb(var(--color-text-200))");
|
const [activeColor, setActiveColor] = useState<string>("rgb(var(--color-text-200))");
|
||||||
@ -40,7 +46,11 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange, onIconColorC
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="relative z-[1]">
|
<Popover className="relative z-[1]">
|
||||||
<Popover.Button onClick={() => setIsOpen((prev) => !prev)} className="outline-none">
|
<Popover.Button
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
className="outline-none"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
<Transition
|
<Transition
|
||||||
|
@ -10,4 +10,5 @@ export type Props = {
|
|||||||
}
|
}
|
||||||
) => void;
|
) => void;
|
||||||
onIconColorChange?: (data: any) => void;
|
onIconColorChange?: (data: any) => void;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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}>
|
|
||||||
<StrictModeDroppable droppableId="gantt">
|
|
||||||
{(droppableProvided, droppableSnapshot) => (
|
|
||||||
<div
|
|
||||||
className="w-full space-y-2"
|
|
||||||
ref={droppableProvided.innerRef}
|
|
||||||
{...droppableProvided.droppableProps}
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
{blocks &&
|
{blocks &&
|
||||||
blocks.length > 0 &&
|
blocks.length > 0 &&
|
||||||
blocks.map(
|
blocks.map(
|
||||||
(block, index: number) =>
|
(block) =>
|
||||||
block.start_date &&
|
block.start_date &&
|
||||||
block.target_date && (
|
block.target_date && (
|
||||||
<Draggable
|
|
||||||
key={`block-${block.id}`}
|
|
||||||
draggableId={`block-${block.id}`}
|
|
||||||
index={index}
|
|
||||||
isDragDisabled={!enableReorder}
|
|
||||||
>
|
|
||||||
{(provided) => (
|
|
||||||
<div
|
<div
|
||||||
className={
|
key={`block-${block.id}`}
|
||||||
droppableSnapshot.isDraggingOver ? "bg-custom-border-100/10" : ""
|
className={`h-11 ${activeBlock?.id === block.id ? "bg-custom-background-80" : ""}`}
|
||||||
}
|
onMouseEnter={() => updateActiveBlock(block)}
|
||||||
ref={provided.innerRef}
|
onMouseLeave={() => updateActiveBlock(null)}
|
||||||
{...provided.draggableProps}
|
|
||||||
>
|
>
|
||||||
<ChartDraggable
|
<ChartDraggable
|
||||||
block={block}
|
block={block}
|
||||||
|
BlockRender={BlockRender}
|
||||||
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
|
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
|
||||||
enableLeftDrag={enableLeftDrag}
|
enableBlockLeftResize={enableBlockLeftResize}
|
||||||
enableRightDrag={enableRightDrag}
|
enableBlockRightResize={enableBlockRightResize}
|
||||||
provided={provided}
|
enableBlockMove={enableBlockMove}
|
||||||
>
|
/>
|
||||||
<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>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,2 +1 @@
|
|||||||
export * from "./block";
|
|
||||||
export * from "./blocks-display";
|
export * from "./blocks-display";
|
||||||
|
@ -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
|
<div
|
||||||
className="relative flex h-full w-full flex-1 flex-col overflow-hidden overflow-x-auto"
|
id="gantt-container"
|
||||||
id="scroll-container"
|
className={`relative flex h-full w-full flex-1 overflow-hidden border-t border-custom-border-200 ${
|
||||||
|
bottomSpacing ? "mb-8" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* blocks components */}
|
<div
|
||||||
{currentView && currentViewData && (
|
id="gantt-sidebar"
|
||||||
<GanttChartBlocks
|
className="h-full w-1/4 flex flex-col border-r border-custom-border-200 space-y-3"
|
||||||
itemsContainerWidth={itemsContainerWidth}
|
>
|
||||||
blocks={chartBlocks}
|
<div className="h-[60px] border-b border-custom-border-200 box-border flex-shrink-0" />
|
||||||
sidebarBlockRender={sidebarBlockRender}
|
<GanttSidebar
|
||||||
blockRender={blockRender}
|
title={title}
|
||||||
blockUpdateHandler={blockUpdateHandler}
|
blockUpdateHandler={blockUpdateHandler}
|
||||||
enableLeftDrag={enableLeftDrag}
|
blocks={chartBlocks}
|
||||||
enableRightDrag={enableRightDrag}
|
SidebarBlockRender={SidebarBlockRender}
|
||||||
enableReorder={enableReorder}
|
enableReorder={enableReorder}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
<div
|
||||||
{/* chart */}
|
className="relative flex h-full w-full flex-1 flex-col overflow-hidden overflow-x-auto horizontal-scroll-enable"
|
||||||
|
id="scroll-container"
|
||||||
|
onScroll={onScroll}
|
||||||
|
>
|
||||||
{/* {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>
|
||||||
|
@ -17,30 +17,50 @@ 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]">
|
||||||
|
<div className="sticky left-0 inline-flex whitespace-nowrap px-3 py-2 text-xs font-medium capitalize">
|
||||||
{block?.title}
|
{block?.title}
|
||||||
</div>
|
</div>
|
||||||
</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 className="flex h-full w-full divide-x divide-custom-border-100/50">
|
<div className="flex h-full w-full divide-x divide-custom-border-100/50">
|
||||||
{block?.children &&
|
{block?.children &&
|
||||||
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>
|
||||||
))}
|
))}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
|
@ -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,13 @@ 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 = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
return;
|
if (!currentViewData || !resizableRef.current || !block.position) return;
|
||||||
|
|
||||||
|
if (e.button !== 0) 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 +86,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 +103,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 +111,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 +127,11 @@ 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 = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
return;
|
if (!currentViewData || !resizableRef.current || !block.position) return;
|
||||||
|
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
|
||||||
const resizableDiv = resizableRef.current;
|
const resizableDiv = resizableRef.current;
|
||||||
|
|
||||||
@ -129,8 +143,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 +157,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 +173,150 @@ 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;
|
||||||
|
|
||||||
|
if (e.button !== 0) 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 (
|
||||||
|
<>
|
||||||
|
{/* move to left side hidden block button */}
|
||||||
|
{isBlockHiddenOnLeft && (
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
onClick={handleScrollToBlock}
|
||||||
|
>
|
||||||
|
<Icon iconName="arrow_back" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* move to right side hidden block button */}
|
||||||
|
{isBlockHiddenOnRight && (
|
||||||
|
<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"
|
||||||
|
onClick={handleScrollToBlock}
|
||||||
|
>
|
||||||
|
<Icon iconName="arrow_forward" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
id={`block-${block.id}`}
|
id={`block-${block.id}`}
|
||||||
ref={parentDivRef}
|
ref={resizableRef}
|
||||||
className="relative group inline-flex cursor-pointer items-center font-medium transition-all"
|
className="relative group cursor-pointer font-medium h-full inline-flex items-center transition-all"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: `${block.position?.marginLeft}px`,
|
marginLeft: `${block.position?.marginLeft}px`,
|
||||||
|
width: `${block.position?.width}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{enableLeftDrag && (
|
{/* left resize drag handle */}
|
||||||
|
{enableBlockLeftResize && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleLeftDrag}
|
onMouseDown={handleBlockLeftResize}
|
||||||
onMouseEnter={() => setIsLeftResizing(true)}
|
onMouseEnter={() => setIsLeftResizing(true)}
|
||||||
onMouseLeave={() => setIsLeftResizing(false)}
|
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"
|
className="absolute top-1/2 -left-2.5 -translate-y-1/2 z-[3] w-6 h-full 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 ${
|
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"
|
isLeftResizing ? "-left-2.5" : "left-1"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{React.cloneElement(children, { ref: resizableRef, ...provided.dragHandleProps })}
|
<div
|
||||||
{enableRightDrag && (
|
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
|
<div
|
||||||
onMouseDown={handleRightDrag}
|
onMouseDown={handleBlockRightResize}
|
||||||
onMouseEnter={() => setIsRightResizing(true)}
|
onMouseEnter={() => setIsRightResizing(true)}
|
||||||
onMouseLeave={() => setIsRightResizing(false)}
|
onMouseLeave={() => setIsRightResizing(false)}
|
||||||
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"
|
className="absolute top-1/2 -right-2.5 -translate-y-1/2 z-[2] w-6 h-full 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 ${
|
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"
|
isRightResizing ? "-right-2.5" : "right-1"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
156
apps/app/components/gantt-chart/sidebar.tsx
Normal file
156
apps/app/components/gantt-chart/sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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";
|
||||||
|
57
apps/app/components/icons/module/backlog.tsx
Normal file
57
apps/app/components/icons/module/backlog.tsx
Normal 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>
|
||||||
|
);
|
35
apps/app/components/icons/module/cancelled.tsx
Normal file
35
apps/app/components/icons/module/cancelled.tsx
Normal 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>
|
||||||
|
);
|
28
apps/app/components/icons/module/completed.tsx
Normal file
28
apps/app/components/icons/module/completed.tsx
Normal 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>
|
||||||
|
);
|
71
apps/app/components/icons/module/in-progress.tsx
Normal file
71
apps/app/components/icons/module/in-progress.tsx
Normal 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>
|
||||||
|
);
|
7
apps/app/components/icons/module/index.ts
Normal file
7
apps/app/components/icons/module/index.ts
Normal 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";
|
37
apps/app/components/icons/module/module-status-icon.tsx
Normal file
37
apps/app/components/icons/module/module-status-icon.tsx
Normal 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} />;
|
||||||
|
};
|
31
apps/app/components/icons/module/paused.tsx
Normal file
31
apps/app/components/icons/module/paused.tsx
Normal 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>
|
||||||
|
);
|
24
apps/app/components/icons/module/planned.tsx
Normal file
24
apps/app/components/icons/module/planned.tsx
Normal 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>
|
||||||
|
);
|
@ -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:
|
||||||
|
106
apps/app/components/inbox/inbox-issue-activity.tsx
Normal file
106
apps/app/components/inbox/inbox-issue-activity.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import useSWR, { mutate } from "swr";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { AddComment, IssueActivitySection } from "components/issues";
|
||||||
|
// services
|
||||||
|
import issuesService from "services/issues.service";
|
||||||
|
// hooks
|
||||||
|
import useUser from "hooks/use-user";
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
// types
|
||||||
|
import { IIssue, IIssueComment } from "types";
|
||||||
|
// fetch-keys
|
||||||
|
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||||
|
|
||||||
|
type Props = { issueDetails: IIssue };
|
||||||
|
|
||||||
|
export const InboxIssueActivity: React.FC<Props> = ({ issueDetails }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId, inboxIssueId } = router.query;
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
|
const { data: issueActivity, mutate: mutateIssueActivity } = useSWR(
|
||||||
|
workspaceSlug && projectId && inboxIssueId
|
||||||
|
? PROJECT_ISSUES_ACTIVITY(inboxIssueId.toString())
|
||||||
|
: null,
|
||||||
|
workspaceSlug && projectId && inboxIssueId
|
||||||
|
? () =>
|
||||||
|
issuesService.getIssueActivities(
|
||||||
|
workspaceSlug.toString(),
|
||||||
|
projectId.toString(),
|
||||||
|
inboxIssueId.toString()
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCommentUpdate = async (comment: IIssueComment) => {
|
||||||
|
if (!workspaceSlug || !projectId || !inboxIssueId) return;
|
||||||
|
|
||||||
|
await issuesService
|
||||||
|
.patchIssueComment(
|
||||||
|
workspaceSlug as string,
|
||||||
|
projectId as string,
|
||||||
|
inboxIssueId as string,
|
||||||
|
comment.id,
|
||||||
|
comment,
|
||||||
|
user
|
||||||
|
)
|
||||||
|
.then(() => mutateIssueActivity());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommentDelete = async (commentId: string) => {
|
||||||
|
if (!workspaceSlug || !projectId || !inboxIssueId) return;
|
||||||
|
|
||||||
|
mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false);
|
||||||
|
|
||||||
|
await issuesService
|
||||||
|
.deleteIssueComment(
|
||||||
|
workspaceSlug as string,
|
||||||
|
projectId as string,
|
||||||
|
inboxIssueId as string,
|
||||||
|
commentId,
|
||||||
|
user
|
||||||
|
)
|
||||||
|
.then(() => mutateIssueActivity());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddComment = async (formData: IIssueComment) => {
|
||||||
|
if (!workspaceSlug || !issueDetails) return;
|
||||||
|
|
||||||
|
await issuesService
|
||||||
|
.createIssueComment(
|
||||||
|
workspaceSlug.toString(),
|
||||||
|
issueDetails.project,
|
||||||
|
issueDetails.id,
|
||||||
|
formData,
|
||||||
|
user
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
mutate(PROJECT_ISSUES_ACTIVITY(issueDetails.id));
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Comment could not be posted. Please try again.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<h3 className="text-lg text-custom-text-100">Comments/Activity</h3>
|
||||||
|
<IssueActivitySection
|
||||||
|
activity={issueActivity}
|
||||||
|
handleCommentUpdate={handleCommentUpdate}
|
||||||
|
handleCommentDelete={handleCommentDelete}
|
||||||
|
/>
|
||||||
|
<AddComment onSubmit={handleAddComment} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -14,13 +14,8 @@ import inboxServices from "services/inbox.service";
|
|||||||
import useInboxView from "hooks/use-inbox-view";
|
import useInboxView from "hooks/use-inbox-view";
|
||||||
import useUserAuth from "hooks/use-user-auth";
|
import useUserAuth from "hooks/use-user-auth";
|
||||||
// components
|
// components
|
||||||
import {
|
import { IssueDescriptionForm, IssueDetailsSidebar, IssueReaction } from "components/issues";
|
||||||
AddComment,
|
import { InboxIssueActivity } from "components/inbox";
|
||||||
IssueActivitySection,
|
|
||||||
IssueDescriptionForm,
|
|
||||||
IssueDetailsSidebar,
|
|
||||||
IssueReaction,
|
|
||||||
} from "components/issues";
|
|
||||||
// ui
|
// ui
|
||||||
import { Loader } from "components/ui";
|
import { Loader } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
@ -42,7 +37,6 @@ import { INBOX_ISSUES, INBOX_ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "cons
|
|||||||
|
|
||||||
const defaultValues = {
|
const defaultValues = {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
|
||||||
description_html: "",
|
description_html: "",
|
||||||
estimate_point: null,
|
estimate_point: null,
|
||||||
assignees_list: [],
|
assignees_list: [],
|
||||||
@ -296,7 +290,6 @@ export const InboxMainContent: React.FC = () => {
|
|||||||
workspaceSlug={workspaceSlug as string}
|
workspaceSlug={workspaceSlug as string}
|
||||||
issue={{
|
issue={{
|
||||||
name: issueDetails.name,
|
name: issueDetails.name,
|
||||||
description: issueDetails.description,
|
|
||||||
description_html: issueDetails.description_html,
|
description_html: issueDetails.description_html,
|
||||||
}}
|
}}
|
||||||
handleFormSubmit={submitChanges}
|
handleFormSubmit={submitChanges}
|
||||||
@ -312,11 +305,7 @@ export const InboxMainContent: React.FC = () => {
|
|||||||
issueId={issueDetails.id}
|
issueId={issueDetails.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<InboxIssueActivity issueDetails={issueDetails} />
|
||||||
<h3 className="text-lg text-custom-text-100">Comments/Activity</h3>
|
|
||||||
<IssueActivitySection issueId={issueDetails.id} user={user} />
|
|
||||||
<AddComment issueId={issueDetails.id} user={user} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="basis-1/3 space-y-5 border-custom-border-200 p-5">
|
<div className="basis-1/3 space-y-5 border-custom-border-200 p-5">
|
||||||
|
@ -4,6 +4,7 @@ export * from "./delete-issue-modal";
|
|||||||
export * from "./filters-dropdown";
|
export * from "./filters-dropdown";
|
||||||
export * from "./filters-list";
|
export * from "./filters-list";
|
||||||
export * from "./inbox-action-headers";
|
export * from "./inbox-action-headers";
|
||||||
|
export * from "./inbox-issue-activity";
|
||||||
export * from "./inbox-issue-card";
|
export * from "./inbox-issue-card";
|
||||||
export * from "./inbox-main-content";
|
export * from "./inbox-main-content";
|
||||||
export * from "./issues-list-sidebar";
|
export * from "./issues-list-sidebar";
|
||||||
|
@ -3,10 +3,6 @@ import React from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import useSWR from "swr";
|
|
||||||
|
|
||||||
// services
|
|
||||||
import issuesService from "services/issues.service";
|
|
||||||
// components
|
// components
|
||||||
import { ActivityIcon, ActivityMessage } from "components/core";
|
import { ActivityIcon, ActivityMessage } from "components/core";
|
||||||
import { CommentCard } from "components/issues/comment";
|
import { CommentCard } from "components/issues/comment";
|
||||||
@ -15,62 +11,23 @@ import { Icon, Loader } from "components/ui";
|
|||||||
// helpers
|
// helpers
|
||||||
import { timeAgo } from "helpers/date-time.helper";
|
import { timeAgo } from "helpers/date-time.helper";
|
||||||
// types
|
// types
|
||||||
import { ICurrentUserResponse, IIssueComment } from "types";
|
import { IIssueActivity, IIssueComment } from "types";
|
||||||
// fetch-keys
|
|
||||||
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
issueId: string;
|
activity: IIssueActivity[] | undefined;
|
||||||
user: ICurrentUserResponse | undefined;
|
handleCommentUpdate: (comment: IIssueComment) => Promise<void>;
|
||||||
|
handleCommentDelete: (commentId: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
|
export const IssueActivitySection: React.FC<Props> = ({
|
||||||
|
activity,
|
||||||
|
handleCommentUpdate,
|
||||||
|
handleCommentDelete,
|
||||||
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
const { data: issueActivities, mutate: mutateIssueActivities } = useSWR(
|
if (!activity)
|
||||||
workspaceSlug && projectId ? PROJECT_ISSUES_ACTIVITY(issueId) : null,
|
|
||||||
workspaceSlug && projectId
|
|
||||||
? () =>
|
|
||||||
issuesService.getIssueActivities(workspaceSlug as string, projectId as string, issueId)
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCommentUpdate = async (comment: IIssueComment) => {
|
|
||||||
if (!workspaceSlug || !projectId || !issueId) return;
|
|
||||||
|
|
||||||
await issuesService
|
|
||||||
.patchIssueComment(
|
|
||||||
workspaceSlug as string,
|
|
||||||
projectId as string,
|
|
||||||
issueId as string,
|
|
||||||
comment.id,
|
|
||||||
comment,
|
|
||||||
user
|
|
||||||
)
|
|
||||||
.then((res) => mutateIssueActivities());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCommentDelete = async (commentId: string) => {
|
|
||||||
if (!workspaceSlug || !projectId || !issueId) return;
|
|
||||||
|
|
||||||
mutateIssueActivities(
|
|
||||||
(prevData: any) => prevData?.filter((p: any) => p.id !== commentId),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
await issuesService
|
|
||||||
.deleteIssueComment(
|
|
||||||
workspaceSlug as string,
|
|
||||||
projectId as string,
|
|
||||||
issueId as string,
|
|
||||||
commentId,
|
|
||||||
user
|
|
||||||
)
|
|
||||||
.then(() => mutateIssueActivities());
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!issueActivities) {
|
|
||||||
return (
|
return (
|
||||||
<Loader className="space-y-4">
|
<Loader className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -87,12 +44,11 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</Loader>
|
</Loader>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flow-root">
|
<div className="flow-root">
|
||||||
<ul role="list" className="-mb-4">
|
<ul role="list" className="-mb-4">
|
||||||
{issueActivities.map((activityItem, index) => {
|
{activity.map((activityItem, index) => {
|
||||||
// determines what type of action is performed
|
// determines what type of action is performed
|
||||||
const message = activityItem.field ? (
|
const message = activityItem.field ? (
|
||||||
<ActivityMessage activity={activityItem} />
|
<ActivityMessage activity={activityItem} />
|
||||||
@ -104,7 +60,7 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
|
|||||||
return (
|
return (
|
||||||
<li key={activityItem.id}>
|
<li key={activityItem.id}>
|
||||||
<div className="relative pb-1">
|
<div className="relative pb-1">
|
||||||
{issueActivities.length > 1 && index !== issueActivities.length - 1 ? (
|
{activity.length > 1 && index !== activity.length - 1 ? (
|
||||||
<span
|
<span
|
||||||
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80"
|
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
@ -1,116 +1,116 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { mutate } from "swr";
|
|
||||||
|
|
||||||
// react-hook-form
|
// react-hook-form
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
// services
|
// components
|
||||||
import issuesServices from "services/issues.service";
|
import { TipTapEditor } from "components/tiptap";
|
||||||
// hooks
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
// ui
|
// ui
|
||||||
import { SecondaryButton } from "components/ui";
|
import { Icon, SecondaryButton, Tooltip } from "components/ui";
|
||||||
// types
|
// types
|
||||||
import type { ICurrentUserResponse, IIssueComment } from "types";
|
import type { IIssueComment } from "types";
|
||||||
// fetch-keys
|
|
||||||
import { PROJECT_ISSUES_ACTIVITY } from "constants/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 = {
|
||||||
issueId: string;
|
|
||||||
user: ICurrentUserResponse | undefined;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
onSubmit: (data: IIssueComment) => Promise<void>;
|
||||||
|
showAccessSpecifier?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddComment: React.FC<Props> = ({ issueId, user, disabled = false }) => {
|
const commentAccess = [
|
||||||
const {
|
{
|
||||||
handleSubmit,
|
icon: "lock",
|
||||||
control,
|
key: "INTERNAL",
|
||||||
setValue,
|
label: "Private",
|
||||||
watch,
|
},
|
||||||
formState: { isSubmitting },
|
{
|
||||||
reset,
|
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, projectId } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const {
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
} = useForm<IIssueComment>({ defaultValues });
|
||||||
|
|
||||||
const onSubmit = async (formData: IIssueComment) => {
|
const handleAddComment = async (formData: IIssueComment) => {
|
||||||
if (
|
if (!formData.comment_html || isSubmitting) return;
|
||||||
!workspaceSlug ||
|
|
||||||
!projectId ||
|
await onSubmit(formData).then(() => {
|
||||||
!issueId ||
|
|
||||||
isSubmitting ||
|
|
||||||
!formData.comment_html ||
|
|
||||||
!formData.comment_json
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
await issuesServices
|
|
||||||
.createIssueComment(
|
|
||||||
workspaceSlug as string,
|
|
||||||
projectId as string,
|
|
||||||
issueId as string,
|
|
||||||
formData,
|
|
||||||
user
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
editorRef.current?.clearEditor();
|
editorRef.current?.clearEditor();
|
||||||
})
|
});
|
||||||
.catch(() =>
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Comment could not be posted. Please try again.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(handleAddComment)}>
|
||||||
<div className="issue-comments-section">
|
<div>
|
||||||
|
<div className="relative">
|
||||||
|
{showAccessSpecifier && (
|
||||||
|
<div className="absolute bottom-2 left-3 z-[1]">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="access"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div className="flex border border-custom-border-300 divide-x divide-custom-border-300 rounded overflow-hidden">
|
||||||
|
{commentAccess.map((access) => (
|
||||||
|
<Tooltip key={access.key} tooltipContent={access.label}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(access.key)}
|
||||||
|
className={`grid place-items-center p-1 hover:bg-custom-background-80 ${
|
||||||
|
value === access.key ? "bg-custom-background-80" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
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
|
<Controller
|
||||||
name="comment_html"
|
name="comment_html"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<TiptapEditor
|
<TipTapEditor
|
||||||
workspaceSlug={workspaceSlug as string}
|
workspaceSlug={workspaceSlug as string}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={
|
value={!value || value === "" ? "<p></p>" : value}
|
||||||
!value ||
|
customClassName="p-3 min-h-[100px] shadow-sm"
|
||||||
value === "" ||
|
|
||||||
(typeof value === "object" && Object.keys(value).length === 0)
|
|
||||||
? watch("comment_html")
|
|
||||||
: value
|
|
||||||
}
|
|
||||||
customClassName="p-3 min-h-[50px] shadow-sm"
|
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
onChange={(comment_json: Object, comment_html: string) => {
|
onChange={(comment_json: Object, comment_html: string) => onChange(comment_html)}
|
||||||
onChange(comment_html);
|
|
||||||
setValue("comment_json", comment_json);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</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"}
|
||||||
|
@ -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}
|
||||||
|
@ -34,9 +34,16 @@ type Props = {
|
|||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
data: IIssue | null;
|
data: IIssue | null;
|
||||||
user: ICurrentUserResponse | undefined;
|
user: ICurrentUserResponse | undefined;
|
||||||
|
onSubmit?: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, user }) => {
|
export const DeleteIssueModal: React.FC<Props> = ({
|
||||||
|
isOpen,
|
||||||
|
handleClose,
|
||||||
|
data,
|
||||||
|
user,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -116,6 +123,8 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
|
|||||||
else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(data.project, params));
|
else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(data.project, params));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onSubmit) onSubmit();
|
||||||
|
|
||||||
handleClose();
|
handleClose();
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
title: "Success",
|
title: "Success",
|
||||||
@ -129,6 +138,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
|
|||||||
console.log(error);
|
console.log(error);
|
||||||
setIsDeleteLoading(false);
|
setIsDeleteLoading(false);
|
||||||
});
|
});
|
||||||
|
if (onSubmit) await onSubmit();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchivedIssueDeletion = async () => {
|
const handleArchivedIssueDeletion = async () => {
|
||||||
|
@ -4,24 +4,21 @@ import { FC, useCallback, useEffect, useState } from "react";
|
|||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
// hooks
|
// hooks
|
||||||
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
// components
|
// components
|
||||||
import { TextArea } from "components/ui";
|
import { TextArea } from "components/ui";
|
||||||
|
import { TipTapEditor } from "components/tiptap";
|
||||||
// types
|
// types
|
||||||
import { IIssue } from "types";
|
import { IIssue } from "types";
|
||||||
import Tiptap from "components/tiptap";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
|
|
||||||
export interface IssueDescriptionFormValues {
|
export interface IssueDescriptionFormValues {
|
||||||
name: string;
|
name: string;
|
||||||
description: any;
|
|
||||||
description_html: string;
|
description_html: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IssueDetailsProps {
|
export interface IssueDetailsProps {
|
||||||
issue: {
|
issue: {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
|
||||||
description_html: string;
|
description_html: string;
|
||||||
};
|
};
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
@ -43,7 +40,6 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
|
||||||
reset,
|
reset,
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
@ -51,7 +47,6 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
} = useForm<IIssue>({
|
} = useForm<IIssue>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
|
||||||
description_html: "",
|
description_html: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -62,7 +57,6 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
|
|
||||||
await handleFormSubmit({
|
await handleFormSubmit({
|
||||||
name: formData.name ?? "",
|
name: formData.name ?? "",
|
||||||
description: formData.description ?? "",
|
|
||||||
description_html: formData.description_html ?? "<p></p>",
|
description_html: formData.description_html ?? "<p></p>",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -80,7 +74,6 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
}
|
}
|
||||||
}, [isSubmitting, setShowAlert]);
|
}, [isSubmitting, setShowAlert]);
|
||||||
|
|
||||||
|
|
||||||
// reset form values
|
// reset form values
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!issue) return;
|
if (!issue) return;
|
||||||
@ -99,6 +92,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
{isAllowed ? (
|
||||||
<TextArea
|
<TextArea
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
@ -115,10 +109,14 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
role="textbox"
|
role="textbox"
|
||||||
disabled={!isAllowed}
|
disabled={!isAllowed}
|
||||||
/>
|
/>
|
||||||
{characterLimit && (
|
) : (
|
||||||
|
<h4 className="break-words text-2xl font-semibold">{issue.name}</h4>
|
||||||
|
)}
|
||||||
|
{characterLimit && isAllowed && (
|
||||||
<div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 text-custom-text-200 p-0.5 text-xs">
|
<div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 text-custom-text-200 p-0.5 text-xs">
|
||||||
<span
|
<span
|
||||||
className={`${watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
|
className={`${
|
||||||
|
watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{watch("name").length}
|
{watch("name").length}
|
||||||
@ -133,38 +131,41 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
name="description_html"
|
name="description_html"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => {
|
render={({ field: { value, onChange } }) => {
|
||||||
if (!value && !watch("description_html")) return <></>;
|
if (!value) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tiptap
|
<TipTapEditor
|
||||||
value={
|
value={
|
||||||
!value ||
|
!value ||
|
||||||
value === "" ||
|
value === "" ||
|
||||||
(typeof value === "object" && Object.keys(value).length === 0)
|
(typeof value === "object" && Object.keys(value).length === 0)
|
||||||
? watch("description_html")
|
? "<p></p>"
|
||||||
: value
|
: value
|
||||||
}
|
}
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
debouncedUpdatesEnabled={true}
|
debouncedUpdatesEnabled={true}
|
||||||
setShouldShowAlert={setShowAlert}
|
setShouldShowAlert={setShowAlert}
|
||||||
setIsSubmitting={setIsSubmitting}
|
setIsSubmitting={setIsSubmitting}
|
||||||
customClassName="min-h-[150px] shadow-sm"
|
customClassName={
|
||||||
editorContentCustomClassNames="pb-9"
|
isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"
|
||||||
|
}
|
||||||
|
noBorder={!isAllowed}
|
||||||
onChange={(description: Object, description_html: string) => {
|
onChange={(description: Object, description_html: string) => {
|
||||||
setShowAlert(true);
|
setShowAlert(true);
|
||||||
setIsSubmitting("submitting");
|
setIsSubmitting("submitting");
|
||||||
onChange(description_html);
|
onChange(description_html);
|
||||||
setValue("description", description);
|
handleSubmit(handleDescriptionFormSubmit)().finally(() =>
|
||||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => {
|
setIsSubmitting("submitted")
|
||||||
setIsSubmitting("submitted");
|
);
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
|
editable={isAllowed}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
|
||||||
|
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
|
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
|
||||||
|
@ -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}
|
||||||
|
67
apps/app/components/issues/gantt-chart/blocks.tsx
Normal file
67
apps/app/components/issues/gantt-chart/blocks.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
2
apps/app/components/issues/gantt-chart/index.ts
Normal file
2
apps/app/components/issues/gantt-chart/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./blocks";
|
||||||
|
export * from "./layout";
|
@ -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>
|
||||||
);
|
);
|
@ -15,3 +15,4 @@ export * from "./sidebar";
|
|||||||
export * from "./sub-issues-list";
|
export * from "./sub-issues-list";
|
||||||
export * from "./label";
|
export * from "./label";
|
||||||
export * from "./issue-reaction";
|
export * from "./issue-reaction";
|
||||||
|
export * from "./peek-overview";
|
||||||
|
@ -6,16 +6,16 @@ import { Tooltip } from "components/ui";
|
|||||||
import { IIssue } from "types";
|
import { IIssue } from "types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
issue: IIssue;
|
labelDetails: any[];
|
||||||
maxRender?: number;
|
maxRender?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ViewIssueLabel: React.FC<Props> = ({ issue, maxRender = 1 }) => (
|
export const ViewIssueLabel: React.FC<Props> = ({ labelDetails, maxRender = 1 }) => (
|
||||||
<>
|
<>
|
||||||
{issue.label_details.length > 0 ? (
|
{labelDetails.length > 0 ? (
|
||||||
issue.label_details.length <= maxRender ? (
|
labelDetails.length <= maxRender ? (
|
||||||
<>
|
<>
|
||||||
{issue.label_details.map((label, index) => (
|
{labelDetails.map((label) => (
|
||||||
<div
|
<div
|
||||||
key={label.id}
|
key={label.id}
|
||||||
className="flex cursor-default items-center flex-shrink-0 rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
|
className="flex cursor-default items-center flex-shrink-0 rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
|
||||||
@ -39,11 +39,11 @@ export const ViewIssueLabel: React.FC<Props> = ({ issue, maxRender = 1 }) => (
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
position="top"
|
position="top"
|
||||||
tooltipHeading="Labels"
|
tooltipHeading="Labels"
|
||||||
tooltipContent={issue.label_details.map((l) => l.name).join(", ")}
|
tooltipContent={labelDetails.map((l) => l.name).join(", ")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||||
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" />
|
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" />
|
||||||
{`${issue.label_details.length} Labels`}
|
{`${labelDetails.length} Labels`}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import useSWR from "swr";
|
import useSWR, { mutate } from "swr";
|
||||||
|
|
||||||
// services
|
// services
|
||||||
import issuesService from "services/issues.service";
|
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 useProjectDetails from "hooks/use-project-details";
|
||||||
// contexts
|
// contexts
|
||||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||||
// components
|
// components
|
||||||
@ -25,9 +27,9 @@ import { CustomMenu } from "components/ui";
|
|||||||
import { LayerDiagonalIcon } from "components/icons";
|
import { LayerDiagonalIcon } from "components/icons";
|
||||||
import { MinusCircleIcon } from "@heroicons/react/24/outline";
|
import { MinusCircleIcon } from "@heroicons/react/24/outline";
|
||||||
// types
|
// types
|
||||||
import { IIssue } from "types";
|
import { IIssue, IIssueComment } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { SUB_ISSUES } from "constants/fetch-keys";
|
import { PROJECT_ISSUES_ACTIVITY, SUB_ISSUES } from "constants/fetch-keys";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
issueDetails: IIssue;
|
issueDetails: IIssue;
|
||||||
@ -43,9 +45,13 @@ export const IssueMainContent: React.FC<Props> = ({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId, issueId, archivedIssueId } = router.query;
|
const { workspaceSlug, projectId, issueId, archivedIssueId } = router.query;
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
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
|
||||||
@ -59,6 +65,72 @@ export const IssueMainContent: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
const siblingIssuesList = siblingIssues?.sub_issues.filter((i) => i.id !== issueDetails.id);
|
const siblingIssuesList = siblingIssues?.sub_issues.filter((i) => i.id !== issueDetails.id);
|
||||||
|
|
||||||
|
const { data: issueActivity, mutate: mutateIssueActivity } = useSWR(
|
||||||
|
workspaceSlug && projectId && issueId ? PROJECT_ISSUES_ACTIVITY(issueId.toString()) : null,
|
||||||
|
workspaceSlug && projectId && issueId
|
||||||
|
? () =>
|
||||||
|
issuesService.getIssueActivities(
|
||||||
|
workspaceSlug.toString(),
|
||||||
|
projectId.toString(),
|
||||||
|
issueId.toString()
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCommentUpdate = async (comment: IIssueComment) => {
|
||||||
|
if (!workspaceSlug || !projectId || !issueId) return;
|
||||||
|
|
||||||
|
await issuesService
|
||||||
|
.patchIssueComment(
|
||||||
|
workspaceSlug as string,
|
||||||
|
projectId as string,
|
||||||
|
issueId as string,
|
||||||
|
comment.id,
|
||||||
|
comment,
|
||||||
|
user
|
||||||
|
)
|
||||||
|
.then(() => mutateIssueActivity());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommentDelete = async (commentId: string) => {
|
||||||
|
if (!workspaceSlug || !projectId || !issueId) return;
|
||||||
|
|
||||||
|
mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false);
|
||||||
|
|
||||||
|
await issuesService
|
||||||
|
.deleteIssueComment(
|
||||||
|
workspaceSlug as string,
|
||||||
|
projectId as string,
|
||||||
|
issueId as string,
|
||||||
|
commentId,
|
||||||
|
user
|
||||||
|
)
|
||||||
|
.then(() => mutateIssueActivity());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddComment = async (formData: IIssueComment) => {
|
||||||
|
if (!workspaceSlug || !issueDetails) return;
|
||||||
|
|
||||||
|
await issuesService
|
||||||
|
.createIssueComment(
|
||||||
|
workspaceSlug.toString(),
|
||||||
|
issueDetails.project,
|
||||||
|
issueDetails.id,
|
||||||
|
formData,
|
||||||
|
user
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
mutate(PROJECT_ISSUES_ACTIVITY(issueDetails.id));
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Comment could not be posted. Please try again.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-lg">
|
<div className="rounded-lg">
|
||||||
@ -97,7 +169,8 @@ export const IssueMainContent: React.FC<Props> = ({
|
|||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
renderAs="a"
|
renderAs="a"
|
||||||
href={`/${workspaceSlug}/projects/${projectId as string}/issues/${issue.id
|
href={`/${workspaceSlug}/projects/${projectId as string}/issues/${
|
||||||
|
issue.id
|
||||||
}`}
|
}`}
|
||||||
className="flex items-center gap-2 py-2"
|
className="flex items-center gap-2 py-2"
|
||||||
>
|
>
|
||||||
@ -146,13 +219,14 @@ export const IssueMainContent: React.FC<Props> = ({
|
|||||||
<div className="space-y-5 pt-3">
|
<div className="space-y-5 pt-3">
|
||||||
<h3 className="text-lg text-custom-text-100">Comments/Activity</h3>
|
<h3 className="text-lg text-custom-text-100">Comments/Activity</h3>
|
||||||
<IssueActivitySection
|
<IssueActivitySection
|
||||||
issueId={(archivedIssueId as string) ?? (issueId as string)}
|
activity={issueActivity}
|
||||||
user={user}
|
handleCommentUpdate={handleCommentUpdate}
|
||||||
|
handleCommentDelete={handleCommentDelete}
|
||||||
/>
|
/>
|
||||||
<AddComment
|
<AddComment
|
||||||
issueId={(archivedIssueId as string) ?? (issueId as string)}
|
onSubmit={handleAddComment}
|
||||||
user={user}
|
|
||||||
disabled={uneditable}
|
disabled={uneditable}
|
||||||
|
showAccessSpecifier={projectDetails && projectDetails.is_deployed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -93,7 +93,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
|||||||
|
|
||||||
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
|
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
|
||||||
if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string };
|
if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string };
|
||||||
if (router.asPath.includes("my-issues"))
|
if (router.asPath.includes("my-issues") || router.asPath.includes("assigned"))
|
||||||
prePopulateData = {
|
prePopulateData = {
|
||||||
...prePopulateData,
|
...prePopulateData,
|
||||||
assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""],
|
assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""],
|
||||||
|
@ -7,7 +7,7 @@ import useSWR from "swr";
|
|||||||
// services
|
// services
|
||||||
import issuesService from "services/issues.service";
|
import issuesService from "services/issues.service";
|
||||||
// components
|
// components
|
||||||
import { DueDateFilterModal } from "components/core";
|
import { DateFilterModal } from "components/core";
|
||||||
// ui
|
// ui
|
||||||
import { MultiLevelDropdown } from "components/ui";
|
import { MultiLevelDropdown } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
@ -20,7 +20,7 @@ import { IIssueFilterOptions, IQuery } from "types";
|
|||||||
import { WORKSPACE_LABELS } from "constants/fetch-keys";
|
import { WORKSPACE_LABELS } from "constants/fetch-keys";
|
||||||
// constants
|
// constants
|
||||||
import { GROUP_CHOICES, PRIORITIES } from "constants/project";
|
import { GROUP_CHOICES, PRIORITIES } from "constants/project";
|
||||||
import { DUE_DATES } from "constants/due-dates";
|
import { DATE_FILTER_OPTIONS } from "constants/filters";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
filters: Partial<IIssueFilterOptions> | IQuery;
|
filters: Partial<IIssueFilterOptions> | IQuery;
|
||||||
@ -35,7 +35,14 @@ export const MyIssuesSelectFilters: React.FC<Props> = ({
|
|||||||
direction = "right",
|
direction = "right",
|
||||||
height = "md",
|
height = "md",
|
||||||
}) => {
|
}) => {
|
||||||
const [isDueDateFilterModalOpen, setIsDueDateFilterModalOpen] = useState(false);
|
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
|
||||||
|
const [dateFilterType, setDateFilterType] = useState<{
|
||||||
|
title: string;
|
||||||
|
type: "start_date" | "target_date";
|
||||||
|
}>({
|
||||||
|
title: "",
|
||||||
|
type: "start_date",
|
||||||
|
});
|
||||||
const [fetchLabels, setFetchLabels] = useState(false);
|
const [fetchLabels, setFetchLabels] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -50,10 +57,12 @@ export const MyIssuesSelectFilters: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isDueDateFilterModalOpen && (
|
{isDateFilterModalOpen && (
|
||||||
<DueDateFilterModal
|
<DateFilterModal
|
||||||
isOpen={isDueDateFilterModalOpen}
|
title={dateFilterType.title}
|
||||||
handleClose={() => setIsDueDateFilterModalOpen(false)}
|
field={dateFilterType.type}
|
||||||
|
isOpen={isDateFilterModalOpen}
|
||||||
|
handleClose={() => setIsDateFilterModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MultiLevelDropdown
|
<MultiLevelDropdown
|
||||||
@ -132,12 +141,48 @@ export const MyIssuesSelectFilters: React.FC<Props> = ({
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "target_date",
|
id: "start_date",
|
||||||
label: "Due date",
|
label: "Start date",
|
||||||
value: DUE_DATES,
|
value: DATE_FILTER_OPTIONS,
|
||||||
hasChildren: true,
|
hasChildren: true,
|
||||||
children: [
|
children: [
|
||||||
...(DUE_DATES?.map((option) => ({
|
...(DATE_FILTER_OPTIONS?.map((option) => ({
|
||||||
|
id: option.name,
|
||||||
|
label: option.name,
|
||||||
|
value: {
|
||||||
|
key: "start_date",
|
||||||
|
value: option.value,
|
||||||
|
},
|
||||||
|
selected: checkIfArraysHaveSameElements(filters?.start_date ?? [], option.value),
|
||||||
|
})) ?? []),
|
||||||
|
{
|
||||||
|
id: "custom",
|
||||||
|
label: "Custom",
|
||||||
|
value: "custom",
|
||||||
|
element: (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDateFilterModalOpen(true);
|
||||||
|
setDateFilterType({
|
||||||
|
title: "Start date",
|
||||||
|
type: "start_date",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "target_date",
|
||||||
|
label: "Due date",
|
||||||
|
value: DATE_FILTER_OPTIONS,
|
||||||
|
hasChildren: true,
|
||||||
|
children: [
|
||||||
|
...(DATE_FILTER_OPTIONS?.map((option) => ({
|
||||||
id: option.name,
|
id: option.name,
|
||||||
label: option.name,
|
label: option.name,
|
||||||
value: {
|
value: {
|
||||||
@ -152,7 +197,13 @@ export const MyIssuesSelectFilters: React.FC<Props> = ({
|
|||||||
value: "custom",
|
value: "custom",
|
||||||
element: (
|
element: (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsDueDateFilterModalOpen(true)}
|
onClick={() => {
|
||||||
|
setIsDateFilterModalOpen(true);
|
||||||
|
setDateFilterType({
|
||||||
|
title: "Due date",
|
||||||
|
type: "target_date",
|
||||||
|
});
|
||||||
|
}}
|
||||||
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
||||||
>
|
>
|
||||||
Custom
|
Custom
|
||||||
|
@ -89,14 +89,11 @@ export const MyIssuesViewOptions: React.FC = () => {
|
|||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
const key = option.key as keyof typeof filters;
|
const key = option.key as keyof typeof filters;
|
||||||
|
|
||||||
if (key === "target_date") {
|
if (key === "start_date" || key === "target_date") {
|
||||||
const valueExists = checkIfArraysHaveSameElements(
|
const valueExists = checkIfArraysHaveSameElements(filters?.[key] ?? [], option.value);
|
||||||
filters?.target_date ?? [],
|
|
||||||
option.value
|
|
||||||
);
|
|
||||||
|
|
||||||
setFilters({
|
setFilters({
|
||||||
target_date: 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);
|
||||||
@ -126,7 +123,7 @@ export const MyIssuesViewOptions: React.FC = () => {
|
|||||||
: "text-custom-sidebar-text-200"
|
: "text-custom-sidebar-text-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
View
|
Display
|
||||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
|
|
||||||
@ -159,7 +156,11 @@ export const MyIssuesViewOptions: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{GROUP_BY_OPTIONS.map((option) => {
|
{GROUP_BY_OPTIONS.map((option) => {
|
||||||
if (issueView === "kanban" && option.key === null) return null;
|
if (issueView === "kanban" && option.key === null) return null;
|
||||||
if (option.key === "state" || option.key === "created_by")
|
if (
|
||||||
|
option.key === "state" ||
|
||||||
|
option.key === "created_by" ||
|
||||||
|
option.key === "assignees"
|
||||||
|
)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -234,6 +234,9 @@ export const MyIssuesView: React.FC<Props> = ({
|
|||||||
isOpen={deleteIssueModal}
|
isOpen={deleteIssueModal}
|
||||||
data={issueToDelete}
|
data={issueToDelete}
|
||||||
user={user}
|
user={user}
|
||||||
|
onSubmit={async () => {
|
||||||
|
mutateMyIssues();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{areFiltersApplied && (
|
{areFiltersApplied && (
|
||||||
<>
|
<>
|
||||||
@ -249,6 +252,7 @@ export const MyIssuesView: React.FC<Props> = ({
|
|||||||
labels: null,
|
labels: null,
|
||||||
priority: null,
|
priority: null,
|
||||||
state_group: null,
|
state_group: null,
|
||||||
|
start_date: null,
|
||||||
target_date: null,
|
target_date: null,
|
||||||
type: null,
|
type: null,
|
||||||
})
|
})
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user