Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a60cfd44e | ||
|
|
4783e791ec | ||
|
|
41e812a811 | ||
|
|
a94c607031 | ||
|
|
665a07f15a | ||
|
|
61f184a2ef | ||
|
|
85a8af5125 | ||
|
|
5661ae730a | ||
|
|
7628419a26 | ||
|
|
2cd16c61ba | ||
|
|
8f7b05b73f | ||
|
|
98d545cfb9 | ||
|
|
2548a9f062 | ||
|
|
77c89427e6 | ||
|
|
e55d7175dc | ||
|
|
430d9de722 | ||
|
|
d901277a20 | ||
|
|
489555f788 | ||
|
|
b827a1af27 | ||
|
|
c35d650de0 | ||
|
|
6bde956166 | ||
|
|
178ad3dd73 | ||
|
|
1d2e331cec | ||
|
|
e51e4761b9 | ||
|
|
9299478539 | ||
|
|
01f5a0a4e2 | ||
|
|
fb4cffdd1c | ||
|
|
83bf28bb83 | ||
|
|
9d39096ed2 | ||
|
|
06f6a21077 | ||
|
|
8c5f42874c | ||
|
|
25a2816a76 | ||
|
|
571b89632c | ||
|
|
5b5698ad97 | ||
|
|
b1989bae1b | ||
|
|
83139989c2 | ||
|
|
1bf06821bb | ||
|
|
a5fd6f0e8a | ||
|
|
02380c730d | ||
|
|
eea3b4fa54 | ||
|
|
f64284f6a0 | ||
|
|
06496ff0f0 | ||
|
|
68e8c449c8 | ||
|
|
247937d93a | ||
|
|
e0a18578f5 | ||
|
|
608eea67f7 | ||
|
|
badb190c25 | ||
|
|
e93bbec4cd | ||
|
|
d90aaba842 | ||
|
|
a303e52039 | ||
|
|
73d12cf1bb | ||
|
|
7651640e29 | ||
|
|
41a9dc3603 | ||
|
|
afda3ee6ca | ||
|
|
fc8edab59b | ||
|
|
7e1b90c97a | ||
|
|
9cf5348019 | ||
|
|
042ed04a03 | ||
|
|
e29edfc02b | ||
|
|
eb0af8de59 | ||
|
|
0fb43c6fc5 | ||
|
|
963d26ccda | ||
|
|
3eb819c4ae | ||
|
|
4b67a41b41 | ||
|
|
0a36850d03 | ||
|
|
5bce2014c5 | ||
|
|
3f7f91e120 | ||
|
|
46a6a599a1 | ||
|
|
8ae1e3cff2 | ||
|
|
ffdd515cf8 | ||
|
|
5d9393cfa6 | ||
|
|
0fb531e4b7 | ||
|
|
cf10e3445d | ||
|
|
f90595ca31 | ||
|
|
1ce7f20c2d | ||
|
|
84d3d34e14 | ||
|
|
76e55bee95 | ||
|
|
f05b8de91d | ||
|
|
bc694bb742 | ||
|
|
65d2a2546d | ||
|
|
cd81ec1002 | ||
|
|
6325f97c8e | ||
|
|
fe505e6b31 | ||
|
|
d57d91e530 | ||
|
|
984f7ed6b8 | ||
|
|
90bcdeccf4 | ||
|
|
1d88de472e | ||
|
|
4af5fef210 | ||
|
|
f1ded8540e | ||
|
|
b38a352c98 | ||
|
|
a77839a942 | ||
|
|
f63a04c1ab | ||
|
|
dfb6b2b247 | ||
|
|
088cc8c659 | ||
|
|
05e4311e06 | ||
|
|
5674acd985 | ||
|
|
8e590f6f60 |
@@ -28,7 +28,7 @@ jobs:
|
||||
- id: set_env_variables
|
||||
name: Set Environment Variables
|
||||
run: |
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; then
|
||||
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
steps:
|
||||
- name: Set Frontend Docker Tag
|
||||
run: |
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
steps:
|
||||
- name: Set Space Docker Tag
|
||||
run: |
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
steps:
|
||||
- name: Set Backend Docker Tag
|
||||
run: |
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
steps:
|
||||
- name: Set Proxy Docker Tag
|
||||
run: |
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
# All the python scripts that are used for back migrations
|
||||
import uuid
|
||||
import random
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from plane.db.models import ProjectIdentifier
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueComment,
|
||||
User,
|
||||
Project,
|
||||
ProjectMember,
|
||||
Label,
|
||||
Integration,
|
||||
)
|
||||
|
||||
|
||||
# Update description and description html values for old descriptions
|
||||
def update_description():
|
||||
try:
|
||||
issues = Issue.objects.all()
|
||||
updated_issues = []
|
||||
|
||||
for issue in issues:
|
||||
issue.description_html = f"<p>{issue.description}</p>"
|
||||
issue.description_stripped = issue.description
|
||||
updated_issues.append(issue)
|
||||
|
||||
Issue.objects.bulk_update(
|
||||
updated_issues,
|
||||
["description_html", "description_stripped"],
|
||||
batch_size=100,
|
||||
)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def update_comments():
|
||||
try:
|
||||
issue_comments = IssueComment.objects.all()
|
||||
updated_issue_comments = []
|
||||
|
||||
for issue_comment in issue_comments:
|
||||
issue_comment.comment_html = (
|
||||
f"<p>{issue_comment.comment_stripped}</p>"
|
||||
)
|
||||
updated_issue_comments.append(issue_comment)
|
||||
|
||||
IssueComment.objects.bulk_update(
|
||||
updated_issue_comments, ["comment_html"], batch_size=100
|
||||
)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def update_project_identifiers():
|
||||
try:
|
||||
project_identifiers = ProjectIdentifier.objects.filter(
|
||||
workspace_id=None
|
||||
).select_related("project", "project__workspace")
|
||||
updated_identifiers = []
|
||||
|
||||
for identifier in project_identifiers:
|
||||
identifier.workspace_id = identifier.project.workspace_id
|
||||
updated_identifiers.append(identifier)
|
||||
|
||||
ProjectIdentifier.objects.bulk_update(
|
||||
updated_identifiers, ["workspace_id"], batch_size=50
|
||||
)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def update_user_empty_password():
|
||||
try:
|
||||
users = User.objects.filter(password="")
|
||||
updated_users = []
|
||||
|
||||
for user in users:
|
||||
user.password = make_password(uuid.uuid4().hex)
|
||||
user.is_password_autoset = True
|
||||
updated_users.append(user)
|
||||
|
||||
User.objects.bulk_update(updated_users, ["password"], batch_size=50)
|
||||
print("Success")
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def updated_issue_sort_order():
|
||||
try:
|
||||
issues = Issue.objects.all()
|
||||
updated_issues = []
|
||||
|
||||
for issue in issues:
|
||||
issue.sort_order = issue.sequence_id * random.randint(100, 500)
|
||||
updated_issues.append(issue)
|
||||
|
||||
Issue.objects.bulk_update(
|
||||
updated_issues, ["sort_order"], batch_size=100
|
||||
)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def update_project_cover_images():
|
||||
try:
|
||||
project_cover_images = [
|
||||
"https://images.unsplash.com/photo-1677432658720-3d84f9d657b4?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
|
||||
"https://images.unsplash.com/photo-1661107564401-57497d8fe86f?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
|
||||
"https://images.unsplash.com/photo-1677352241429-dc90cfc7a623?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
|
||||
"https://images.unsplash.com/photo-1677196728306-eeafea692454?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1331&q=80",
|
||||
"https://images.unsplash.com/photo-1660902179734-c94c944f7830?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1255&q=80",
|
||||
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
|
||||
"https://images.unsplash.com/photo-1677040628614-53936ff66632?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
|
||||
"https://images.unsplash.com/photo-1676920410907-8d5f8dd4b5ba?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
|
||||
"https://images.unsplash.com/photo-1676846328604-ce831c481346?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1155&q=80",
|
||||
"https://images.unsplash.com/photo-1676744843212-09b7e64c3a05?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
|
||||
"https://images.unsplash.com/photo-1676798531090-1608bedeac7b?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
|
||||
"https://images.unsplash.com/photo-1597088758740-56fd7ec8a3f0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1169&q=80",
|
||||
"https://images.unsplash.com/photo-1676638392418-80aad7c87b96?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80",
|
||||
"https://images.unsplash.com/photo-1649639194967-2fec0b4ea7bc?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
|
||||
"https://images.unsplash.com/photo-1675883086902-b453b3f8146e?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80",
|
||||
"https://images.unsplash.com/photo-1675887057159-40fca28fdc5d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1173&q=80",
|
||||
"https://images.unsplash.com/photo-1675373980203-f84c5a672aa5?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
|
||||
"https://images.unsplash.com/photo-1675191475318-d2bf6bad1200?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
|
||||
"https://images.unsplash.com/photo-1675456230532-2194d0c4bcc0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
|
||||
"https://images.unsplash.com/photo-1675371788315-60fa0ef48267?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
|
||||
]
|
||||
|
||||
projects = Project.objects.all()
|
||||
updated_projects = []
|
||||
for project in projects:
|
||||
project.cover_image = project_cover_images[random.randint(0, 19)]
|
||||
updated_projects.append(project)
|
||||
|
||||
Project.objects.bulk_update(
|
||||
updated_projects, ["cover_image"], batch_size=100
|
||||
)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def update_user_view_property():
|
||||
try:
|
||||
project_members = ProjectMember.objects.all()
|
||||
updated_project_members = []
|
||||
for project_member in project_members:
|
||||
project_member.default_props = {
|
||||
"filters": {"type": None},
|
||||
"orderBy": "-created_at",
|
||||
"collapsed": True,
|
||||
"issueView": "list",
|
||||
"filterIssue": None,
|
||||
"groupByProperty": None,
|
||||
"showEmptyGroups": True,
|
||||
}
|
||||
updated_project_members.append(project_member)
|
||||
|
||||
ProjectMember.objects.bulk_update(
|
||||
updated_project_members, ["default_props"], batch_size=100
|
||||
)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def update_label_color():
|
||||
try:
|
||||
labels = Label.objects.filter(color="")
|
||||
updated_labels = []
|
||||
for label in labels:
|
||||
label.color = "#" + "%06x" % random.randint(0, 0xFFFFFF)
|
||||
updated_labels.append(label)
|
||||
|
||||
Label.objects.bulk_update(updated_labels, ["color"], batch_size=100)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def create_slack_integration():
|
||||
try:
|
||||
_ = Integration.objects.create(
|
||||
provider="slack", network=2, title="Slack"
|
||||
)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def update_integration_verified():
|
||||
try:
|
||||
integrations = Integration.objects.all()
|
||||
updated_integrations = []
|
||||
for integration in integrations:
|
||||
integration.verified = True
|
||||
updated_integrations.append(integration)
|
||||
|
||||
Integration.objects.bulk_update(
|
||||
updated_integrations, ["verified"], batch_size=10
|
||||
)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def update_start_date():
|
||||
try:
|
||||
issues = Issue.objects.filter(
|
||||
state__group__in=["started", "completed"]
|
||||
)
|
||||
updated_issues = []
|
||||
for issue in issues:
|
||||
issue.start_date = issue.created_at.date()
|
||||
updated_issues.append(issue)
|
||||
Issue.objects.bulk_update(
|
||||
updated_issues, ["start_date"], batch_size=500
|
||||
)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.15.0"
|
||||
"version": "0.15.1"
|
||||
}
|
||||
|
||||
@@ -36,9 +36,8 @@ from .project import (
|
||||
)
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .view import (
|
||||
GlobalViewSerializer,
|
||||
IssueViewSerializer,
|
||||
IssueViewFavoriteSerializer,
|
||||
ViewSerializer,
|
||||
ViewFavoriteSerializer,
|
||||
)
|
||||
from .cycle import (
|
||||
CycleSerializer,
|
||||
@@ -68,6 +67,7 @@ from .issue import (
|
||||
IssueRelationSerializer,
|
||||
RelatedIssueSerializer,
|
||||
IssuePublicSerializer,
|
||||
IssueDetailSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
|
||||
@@ -586,7 +586,6 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"description_html",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
@@ -618,6 +617,13 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
|
||||
|
||||
|
||||
class IssueDetailSerializer(IssueSerializer):
|
||||
description_html = serializers.CharField()
|
||||
|
||||
class Meta(IssueSerializer.Meta):
|
||||
fields = IssueSerializer.Meta.fields + ['description_html']
|
||||
|
||||
|
||||
class IssueLiteSerializer(DynamicBaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(
|
||||
read_only=True, source="workspace"
|
||||
|
||||
@@ -3,69 +3,33 @@ from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from plane.db.models import GlobalView, IssueView, IssueViewFavorite
|
||||
from plane.db.models import View, ViewFavorite
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class GlobalViewSerializer(BaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(
|
||||
source="workspace", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = GlobalView
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"query",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
query_params = validated_data.get("query_data", {})
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
validated_data["query"] = dict()
|
||||
return GlobalView.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
query_params = validated_data.get("query_data", {})
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
validated_data["query"] = dict()
|
||||
validated_data["query"] = issue_filters(query_params, "PATCH")
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class IssueViewSerializer(DynamicBaseSerializer):
|
||||
class ViewSerializer(DynamicBaseSerializer):
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
workspace_detail = WorkspaceLiteSerializer(
|
||||
source="workspace", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IssueView
|
||||
model = View
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"query",
|
||||
"access",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
query_params = validated_data.get("query_data", {})
|
||||
query_params = validated_data.get("filters", {})
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
validated_data["query"] = {}
|
||||
return IssueView.objects.create(**validated_data)
|
||||
return View.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
query_params = validated_data.get("query_data", {})
|
||||
query_params = validated_data.get("filters", {})
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
@@ -74,11 +38,10 @@ class IssueViewSerializer(DynamicBaseSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class IssueViewFavoriteSerializer(BaseSerializer):
|
||||
view_detail = IssueViewSerializer(source="issue_view", read_only=True)
|
||||
class ViewFavoriteSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = IssueViewFavorite
|
||||
model = ViewFavorite
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
|
||||
@@ -14,6 +14,8 @@ from plane.app.views import (
|
||||
UserActivityGraphEndpoint,
|
||||
UserIssueCompletedGraphEndpoint,
|
||||
UserWorkspaceDashboardEndpoint,
|
||||
UserWorkspaceViewViewSet,
|
||||
UserProjectViewViewSet,
|
||||
## End Workspaces
|
||||
)
|
||||
|
||||
@@ -85,6 +87,7 @@ urlpatterns = [
|
||||
UserIssueCompletedGraphEndpoint.as_view(),
|
||||
name="completed-graph",
|
||||
),
|
||||
## End User Graph
|
||||
path(
|
||||
"users/me/workspaces/<str:slug>/dashboard/",
|
||||
UserWorkspaceDashboardEndpoint.as_view(),
|
||||
@@ -95,5 +98,83 @@ urlpatterns = [
|
||||
SetUserPasswordEndpoint.as_view(),
|
||||
name="set-password",
|
||||
),
|
||||
## End User Graph
|
||||
path(
|
||||
"users/me/workspaces/<str:slug>/views/",
|
||||
UserWorkspaceViewViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="user-workspace-views",
|
||||
),
|
||||
path(
|
||||
"users/me/workspaces/<str:slug>/views/<uuid:pk>/",
|
||||
UserWorkspaceViewViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="user-workspace-views",
|
||||
),
|
||||
path(
|
||||
"users/me/workspaces/<str:slug>/views/<uuid:pk>/duplicate/",
|
||||
UserWorkspaceViewViewSet.as_view(
|
||||
{
|
||||
"post": "duplicate",
|
||||
}
|
||||
),
|
||||
name="user-workspace-views",
|
||||
),
|
||||
path(
|
||||
"users/me/workspaces/<str:slug>/views/<uuid:pk>/lock/",
|
||||
UserWorkspaceViewViewSet.as_view(
|
||||
{
|
||||
"post": "toggle_lock",
|
||||
}
|
||||
),
|
||||
name="user-workspace-views-lock",
|
||||
),
|
||||
path(
|
||||
"users/me/workspaces/<str:slug>/projects/<uuid:project_id>/views/",
|
||||
UserProjectViewViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="user-project-views",
|
||||
),
|
||||
path(
|
||||
"users/me/workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/",
|
||||
UserProjectViewViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="user-project-views",
|
||||
),
|
||||
path(
|
||||
"users/me/workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/lock/",
|
||||
UserProjectViewViewSet.as_view(
|
||||
{
|
||||
"post": "toggle_lock",
|
||||
}
|
||||
),
|
||||
name="user-project-lock-views",
|
||||
),
|
||||
path(
|
||||
"users/me/workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/duplicate/",
|
||||
UserWorkspaceViewViewSet.as_view(
|
||||
{
|
||||
"post": "duplicate",
|
||||
}
|
||||
),
|
||||
name="user-project-duplicate-views",
|
||||
),
|
||||
|
||||
]
|
||||
|
||||
@@ -2,17 +2,86 @@ from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import (
|
||||
IssueViewViewSet,
|
||||
GlobalViewViewSet,
|
||||
GlobalViewIssuesViewSet,
|
||||
IssueViewFavoriteViewSet,
|
||||
ProjectViewViewSet,
|
||||
WorkspaceViewViewSet,
|
||||
WorkspaceViewFavoriteViewSet,
|
||||
ProjectViewFavoriteViewSet,
|
||||
WorkspaceViewIssuesViewSet,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/",
|
||||
WorkspaceViewIssuesViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
name="workspace-view-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/views/",
|
||||
WorkspaceViewViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="workspace-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/views/<uuid:pk>/",
|
||||
WorkspaceViewViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="workspace-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/views/<uuid:view_id>/duplicate/",
|
||||
WorkspaceViewFavoriteViewSet.as_view(
|
||||
{
|
||||
"post": "duplicate",
|
||||
}
|
||||
),
|
||||
name="workspace-duplicate-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/views/<uuid:view_id>/favorite/",
|
||||
WorkspaceViewFavoriteViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="workspace-favorite-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/views/<uuid:pk>/visibility/",
|
||||
WorkspaceViewViewSet.as_view(
|
||||
{
|
||||
"post": "visibility",
|
||||
}
|
||||
),
|
||||
name="workspace-duplicate-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/lock/",
|
||||
WorkspaceViewViewSet.as_view(
|
||||
{
|
||||
"post": "toggle_lock",
|
||||
}
|
||||
),
|
||||
name="project-lock-views",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/views/",
|
||||
IssueViewViewSet.as_view(
|
||||
ProjectViewViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
@@ -22,7 +91,7 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/",
|
||||
IssueViewViewSet.as_view(
|
||||
ProjectViewViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"put": "update",
|
||||
@@ -33,53 +102,41 @@ urlpatterns = [
|
||||
name="project-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/views/",
|
||||
GlobalViewViewSet.as_view(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/duplicate/",
|
||||
ProjectViewViewSet.as_view(
|
||||
{
|
||||
"post": "duplicate",
|
||||
}
|
||||
),
|
||||
name="project-duplicate-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/visibility/",
|
||||
ProjectViewViewSet.as_view(
|
||||
{
|
||||
"post": "visibility",
|
||||
}
|
||||
),
|
||||
name="project-duplicate-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/lock/",
|
||||
ProjectViewViewSet.as_view(
|
||||
{
|
||||
"post": "toggle_lock",
|
||||
}
|
||||
),
|
||||
name="project-lock-views",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:view_id>/favorite/",
|
||||
ProjectViewFavoriteViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="global-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/views/<uuid:pk>/",
|
||||
GlobalViewViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="global-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/",
|
||||
GlobalViewIssuesViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
name="global-view-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/",
|
||||
IssueViewFavoriteViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="user-favorite-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/<uuid:view_id>/",
|
||||
IssueViewFavoriteViewSet.as_view(
|
||||
{
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="user-favorite-view",
|
||||
name="project-favorite-view",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -52,10 +52,14 @@ from .workspace import (
|
||||
)
|
||||
from .state import StateViewSet
|
||||
from .view import (
|
||||
GlobalViewViewSet,
|
||||
GlobalViewIssuesViewSet,
|
||||
IssueViewViewSet,
|
||||
IssueViewFavoriteViewSet,
|
||||
WorkspaceViewViewSet,
|
||||
ProjectViewViewSet,
|
||||
WorkspaceViewFavoriteViewSet,
|
||||
ProjectViewFavoriteViewSet,
|
||||
UserWorkspaceViewViewSet,
|
||||
UserProjectViewViewSet,
|
||||
ProjectViewViewSet,
|
||||
WorkspaceViewIssuesViewSet,
|
||||
)
|
||||
from .cycle import (
|
||||
CycleViewSet,
|
||||
|
||||
@@ -27,7 +27,7 @@ from plane.app.serializers import (
|
||||
InboxSerializer,
|
||||
InboxIssueSerializer,
|
||||
IssueCreateSerializer,
|
||||
IssueStateInboxSerializer,
|
||||
IssueDetailSerializer,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
@@ -333,7 +333,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
|
||||
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
|
||||
issue = self.get_queryset().filter(pk=issue_id).first()
|
||||
serializer = IssueSerializer(issue, expand=self.expand,)
|
||||
serializer = IssueDetailSerializer(issue, expand=self.expand,)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def destroy(self, request, slug, project_id, inbox_id, issue_id):
|
||||
|
||||
@@ -50,6 +50,7 @@ from plane.app.serializers import (
|
||||
CommentReactionSerializer,
|
||||
IssueRelationSerializer,
|
||||
RelatedIssueSerializer,
|
||||
IssueDetailSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
@@ -267,7 +268,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
issue = self.get_queryset().filter(pk=pk).first()
|
||||
return Response(
|
||||
IssueSerializer(
|
||||
IssueDetailSerializer(
|
||||
issue, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
status=status.HTTP_200_OK,
|
||||
@@ -1208,13 +1209,13 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
archived_at__isnull=False,
|
||||
pk=pk,
|
||||
issue = self.get_queryset().filter(pk=pk).first()
|
||||
return Response(
|
||||
IssueDetailSerializer(
|
||||
issue, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
|
||||
def unarchive(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.objects.get(
|
||||
|
||||
@@ -17,7 +17,7 @@ from plane.db.models import (
|
||||
Cycle,
|
||||
Module,
|
||||
Page,
|
||||
IssueView,
|
||||
View,
|
||||
)
|
||||
from plane.utils.issue_search import search_issues
|
||||
|
||||
@@ -161,7 +161,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
issue_views = IssueView.objects.filter(
|
||||
issue_views = View.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
@@ -247,12 +247,7 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
if parent == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True
|
||||
).exclude(
|
||||
pk__in=Issue.issue_objects.filter(
|
||||
parent__isnull=False
|
||||
).values_list("parent_id", flat=True)
|
||||
)
|
||||
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id))
|
||||
if issue_relation == "true" and issue_id:
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
|
||||
+388
-106
@@ -1,6 +1,5 @@
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
@@ -10,68 +9,370 @@ from django.db.models import (
|
||||
When,
|
||||
Exists,
|
||||
Max,
|
||||
Q,
|
||||
)
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.db.models import Prefetch, OuterRef, Exists
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet, BaseAPIView
|
||||
from . import BaseViewSet
|
||||
from plane.app.serializers import (
|
||||
GlobalViewSerializer,
|
||||
IssueViewSerializer,
|
||||
ViewSerializer,
|
||||
IssueSerializer,
|
||||
IssueViewFavoriteSerializer,
|
||||
ViewFavoriteSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
ProjectEntityPermission,
|
||||
WorkspaceViewerPermission,
|
||||
ProjectLitePermission,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
GlobalView,
|
||||
IssueView,
|
||||
View,
|
||||
Issue,
|
||||
IssueViewFavorite,
|
||||
IssueReaction,
|
||||
ViewFavorite,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.grouper import group_results
|
||||
|
||||
|
||||
class GlobalViewViewSet(BaseViewSet):
|
||||
serializer_class = IssueViewSerializer
|
||||
model = IssueView
|
||||
class UserWorkspaceViewViewSet(BaseViewSet):
|
||||
serializer_class = ViewSerializer
|
||||
model = View
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
|
||||
serializer.save(workspace_id=workspace.id)
|
||||
serializer.save(
|
||||
workspace_id=workspace.id, access=0, owned_by=self.request.user
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
subquery = ViewFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
view_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
# .filter(project__isnull=True)
|
||||
.filter(Q(owned_by=self.request.user) & Q(access=0))
|
||||
.select_related("workspace")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.order_by(self.request.GET.get("order_by", "-is_pinned"))
|
||||
.order_by("-is_pinned", "-created_at")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, pk):
|
||||
view = View.objects.get(pk=pk, workspace__slug=slug)
|
||||
if view.owned_by == self.request.user and not view.is_locked:
|
||||
serializer = ViewSerializer(view, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response(
|
||||
{"error": "You cannot update the view"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
def list(self, request, slug):
|
||||
type = request.GET.get("type", None)
|
||||
views = self.get_queryset()
|
||||
|
||||
if type == "workspace":
|
||||
views = views.filter(project__isnull=True)
|
||||
|
||||
if type == "project":
|
||||
views = views.filter(project__isnull=False)
|
||||
|
||||
serializer = ViewSerializer(views, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def toggle_lock(self, request, slug, pk):
|
||||
view = View.objects.get(pk=pk, workspace__slug=slug)
|
||||
lock = request.data.get("lock", view.is_locked)
|
||||
if view.owned_by != self.request.user:
|
||||
return Response(
|
||||
{"error": "You cannot lock the view"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
view.is_locked = lock
|
||||
view.save(update_fields=["is_locked"])
|
||||
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def duplicate(self, request, slug, pk):
|
||||
view = View.objects.get(workspace__slug=slug, pk=pk)
|
||||
# Create a shallow copy of the original view object
|
||||
new_view = view
|
||||
|
||||
# Set the primary key of the new view to None to ensure it gets a new primary key
|
||||
new_view.pk = None
|
||||
|
||||
# Modify the name of the new view to indicate that it's a copy
|
||||
new_view.name = f"{view.name} (Copy)"
|
||||
new_view.save(owned_by=request.user)
|
||||
return Response(ViewSerializer(new_view).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
def destroy(self, request, slug, pk):
|
||||
view = View.objects.get(workspace__slug=slug, pk=pk)
|
||||
if view.owned_by != self.request.user:
|
||||
return Response(
|
||||
{"error": "You cannot delete the view"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
view.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class WorkspaceViewViewSet(BaseViewSet):
|
||||
serializer_class = ViewSerializer
|
||||
model = View
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
|
||||
serializer.save(workspace_id=workspace.id, owned_by=self.request.user)
|
||||
|
||||
def get_queryset(self):
|
||||
subquery = ViewFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
view_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project__isnull=True)
|
||||
.filter(Q(access=1))
|
||||
.select_related("workspace")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.order_by(self.request.GET.get("order_by", "-created_at"))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def toggle_lock(self, request, slug, pk):
|
||||
view = View.objects.get(pk=pk, workspace__slug=slug)
|
||||
lock = request.data.get("lock", view.is_locked)
|
||||
view.is_locked = lock
|
||||
view.save(update_fields=["is_locked"])
|
||||
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
|
||||
|
||||
def duplicate(self, request, slug, pk):
|
||||
view = View.objects.get(workspace__slug=slug, pk=pk)
|
||||
# Create a shallow copy of the original view object
|
||||
new_view = view
|
||||
|
||||
# Set the primary key of the new view to None to ensure it gets a new primary key
|
||||
new_view.pk = None
|
||||
|
||||
# Modify the name of the new view to indicate that it's a copy
|
||||
new_view.name = f"{view.name} (Copy)"
|
||||
new_view.save(owned_by=request.user)
|
||||
return Response(ViewSerializer(new_view).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
def visibility(self, request, slug, pk):
|
||||
view = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk, workspace__slug=slug)
|
||||
.first()
|
||||
)
|
||||
if view.owned_by != self.request.user:
|
||||
return Response(
|
||||
{"error": "You cannot update the view"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
view.access = request.data.get("access", view.access)
|
||||
view.save(update_fields=["access"])
|
||||
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class UserProjectViewViewSet(BaseViewSet):
|
||||
serializer_class = ViewSerializer
|
||||
model = View
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
|
||||
serializer.save(
|
||||
workspace_id=workspace.id,
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
access=0,
|
||||
owned_by=self.request.user,
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
subquery = ViewFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
view_id=OuterRef("pk"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(Q(owned_by=self.request.user) & Q(access=0))
|
||||
.select_related("workspace")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.order_by(self.request.GET.get("order_by", "-created_at"))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
view = View.objects.get(
|
||||
pk=pk, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
if view.owned_by == self.request.user and not view.is_locked:
|
||||
serializer = ViewSerializer(view, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response(
|
||||
{"error": "You cannot update the view"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
def toggle_lock(self, request, slug, project_id, pk):
|
||||
view = View.objects.get(
|
||||
pk=pk, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
lock = request.data.get("lock", view.is_locked)
|
||||
if view.owned_by != self.request.user:
|
||||
return Response(
|
||||
{"error": "You cannot lock the view"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
view.is_locked = lock
|
||||
view.save(update_fields=["is_locked"])
|
||||
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
|
||||
|
||||
def duplicate(self, request, slug, project_id, pk):
|
||||
view = View.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
# Create a shallow copy of the original view object
|
||||
new_view = view
|
||||
|
||||
# Set the primary key of the new view to None to ensure it gets a new primary key
|
||||
new_view.pk = None
|
||||
|
||||
# Modify the name of the new view to indicate that it's a copy
|
||||
new_view.name = f"{view.name} (Copy)"
|
||||
new_view.save(owned_by=request.user)
|
||||
return Response(ViewSerializer(new_view).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
view = View.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
if view.owned_by != self.request.user:
|
||||
return Response(
|
||||
{"error": "You cannot delete the view"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
view.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class ProjectViewViewSet(BaseViewSet):
|
||||
serializer_class = ViewSerializer
|
||||
model = View
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
|
||||
serializer.save(
|
||||
workspace_id=workspace.id,
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
owned_by=self.request.user,
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
subquery = ViewFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
view_id=OuterRef("pk"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(Q(access=1))
|
||||
.select_related("workspace")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.order_by(self.request.GET.get("order_by", "-created_at"))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def toggle_lock(self, request, slug, project_id, pk):
|
||||
view = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk, project_id=project_id, workspace__slug=slug)
|
||||
.first()
|
||||
)
|
||||
lock = request.data.get("lock", view.is_locked)
|
||||
view.is_locked = lock
|
||||
view.save(update_fields=["is_locked"])
|
||||
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
|
||||
|
||||
def duplicate(self, request, slug, project_id, pk):
|
||||
view = View.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
# Create a shallow copy of the original view object
|
||||
new_view = view
|
||||
|
||||
# Set the primary key of the new view to None to ensure it gets a new primary key
|
||||
new_view.pk = None
|
||||
|
||||
# Modify the name of the new view to indicate that it's a copy
|
||||
new_view.name = f"{view.name} (Copy)"
|
||||
new_view.save(owned_by=request.user)
|
||||
return Response(ViewSerializer(new_view).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def visibility(self, request, slug, project_id, pk):
|
||||
view = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk, project_id=project_id, workspace__slug=slug)
|
||||
.first()
|
||||
)
|
||||
if view.owned_by != self.request.user:
|
||||
return Response(
|
||||
{"error": "You cannot update the view"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
view.access = request.data.get("access", view.access)
|
||||
view.save(update_fields=["access"])
|
||||
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
@@ -87,41 +388,9 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
|
||||
# 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 = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
@@ -147,6 +416,29 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
|
||||
# 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 = self.get_queryset().filter(**filters)
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
priority_order = (
|
||||
@@ -213,52 +505,9 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueViewViewSet(BaseViewSet):
|
||||
serializer_class = IssueViewSerializer
|
||||
model = IssueView
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||
|
||||
def get_queryset(self):
|
||||
subquery = IssueViewFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
view_id=OuterRef("pk"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.order_by("-is_favorite", "name")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
queryset = self.get_queryset()
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
views = IssueViewSerializer(
|
||||
queryset, many=True, fields=fields if fields else None
|
||||
).data
|
||||
return Response(views, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueViewFavoriteViewSet(BaseViewSet):
|
||||
serializer_class = IssueViewFavoriteSerializer
|
||||
model = IssueViewFavorite
|
||||
class WorkspaceViewFavoriteViewSet(BaseViewSet):
|
||||
serializer_class = ViewFavoriteSerializer
|
||||
model = ViewFavorite
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
@@ -269,19 +518,52 @@ class IssueViewFavoriteViewSet(BaseViewSet):
|
||||
.select_related("view")
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
serializer = IssueViewFavoriteSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(user=request.user, project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
def create(self, request, slug, view_id):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
view = ViewFavorite.objects.create(
|
||||
view_id=view_id, user=request.user, workspace_id=workspace.id
|
||||
)
|
||||
return Response(
|
||||
ViewFavoriteSerializer(view).data, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, view_id):
|
||||
view_favorite = ViewFavorite.objects.get(
|
||||
user=request.user,
|
||||
workspace__slug=slug,
|
||||
view_id=view_id,
|
||||
)
|
||||
view_favorite.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class ProjectViewFavoriteViewSet(BaseViewSet):
|
||||
serializer_class = ViewFavoriteSerializer
|
||||
model = ViewFavorite
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(user=self.request.user)
|
||||
.select_related("view")
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id, view_id):
|
||||
view = ViewFavorite.objects.create(
|
||||
view_id=view_id, user=request.user, project_id=project_id
|
||||
)
|
||||
return Response(
|
||||
ViewFavoriteSerializer(view).data, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, view_id):
|
||||
view_favourite = IssueViewFavorite.objects.get(
|
||||
view_favorite = ViewFavorite.objects.get(
|
||||
project=project_id,
|
||||
user=request.user,
|
||||
workspace__slug=slug,
|
||||
view_id=view_id,
|
||||
)
|
||||
view_favourite.delete()
|
||||
view_favorite.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1516,11 +1516,9 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def get(self, request, slug):
|
||||
(
|
||||
workspace_properties,
|
||||
_,
|
||||
) = WorkspaceUserProperties.objects.get_or_create(
|
||||
user=request.user, workspace__slug=slug
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
workspace_properties, _ = WorkspaceUserProperties.objects.get_or_create(
|
||||
user=request.user, workspace=workspace
|
||||
)
|
||||
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-02 13:15
|
||||
|
||||
from plane.db.models import WorkspaceUserProperties, ProjectMember, IssueView
|
||||
from plane.db.models import ProjectMember
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def workspace_user_properties(apps, schema_editor):
|
||||
WorkspaceMember = apps.get_model("db", "WorkspaceMember")
|
||||
WorkspaceUserProperties = apps.get_model("db", "WorkspaceUserProperties")
|
||||
updated_workspace_user_properties = []
|
||||
for workspace_members in WorkspaceMember.objects.all():
|
||||
updated_workspace_user_properties.append(
|
||||
@@ -49,6 +50,7 @@ def project_user_properties(apps, schema_editor):
|
||||
|
||||
def issue_view(apps, schema_editor):
|
||||
GlobalView = apps.get_model("db", "GlobalView")
|
||||
IssueView = apps.get_model("db", "IssueView")
|
||||
updated_issue_views = []
|
||||
|
||||
for global_view in GlobalView.objects.all():
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-30 07:49
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db.models import F
|
||||
|
||||
def views_owned_by(apps, schema_editor):
|
||||
View = apps.get_model("db", "View")
|
||||
View.objects.update(owned_by=F('created_by'))
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0060_cycle_progress_snapshot'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name='IssueView',
|
||||
new_name='View',
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name='view',
|
||||
table='views',
|
||||
),
|
||||
migrations.RenameModel(
|
||||
old_name='IssueViewFavorite',
|
||||
new_name='ViewFavorite',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='viewfavorite',
|
||||
name='project',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name='workspaceuserproperties',
|
||||
table='workspace_user_properties',
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='view',
|
||||
options={'ordering': ('-created_at',), 'verbose_name': 'View', 'verbose_name_plural': 'Views'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='view',
|
||||
name='is_locked',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='view',
|
||||
name='is_pinned',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='view',
|
||||
name='owned_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='views', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='view',
|
||||
name='access',
|
||||
field=models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public'), (2, 'Shared')], default=1),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='viewfavorite',
|
||||
name='view',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='view_favorites', to='db.view'),
|
||||
),
|
||||
migrations.RunPython(views_owned_by)
|
||||
]
|
||||
@@ -52,7 +52,7 @@ from .state import State
|
||||
|
||||
from .cycle import Cycle, CycleIssue, CycleFavorite, CycleUserProperties
|
||||
|
||||
from .view import GlobalView, IssueView, IssueViewFavorite
|
||||
from .view import View, ViewFavorite
|
||||
|
||||
from .module import (
|
||||
Module,
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
# Module import
|
||||
from . import ProjectBaseModel, BaseModel, WorkspaceBaseModel
|
||||
from . import BaseModel, WorkspaceBaseModel
|
||||
|
||||
|
||||
def get_default_filters():
|
||||
@@ -84,7 +84,7 @@ class GlobalView(BaseModel):
|
||||
return f"{self.name} <{self.workspace.name}>"
|
||||
|
||||
|
||||
class IssueView(WorkspaceBaseModel):
|
||||
class View(WorkspaceBaseModel):
|
||||
name = models.CharField(max_length=255, verbose_name="View Name")
|
||||
description = models.TextField(verbose_name="View Description", blank=True)
|
||||
query = models.JSONField(verbose_name="View Query")
|
||||
@@ -94,29 +94,44 @@ class IssueView(WorkspaceBaseModel):
|
||||
default=get_default_display_properties
|
||||
)
|
||||
access = models.PositiveSmallIntegerField(
|
||||
default=1, choices=((0, "Private"), (1, "Public"))
|
||||
default=1, choices=((0, "Private"), (1, "Public"), (2, "Shared"))
|
||||
)
|
||||
owned_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="views", null=True, blank=True
|
||||
)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
is_locked = models.BooleanField(default=False)
|
||||
is_pinned = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue View"
|
||||
verbose_name_plural = "Issue Views"
|
||||
db_table = "issue_views"
|
||||
verbose_name = "View"
|
||||
verbose_name_plural = "Views"
|
||||
db_table = "views"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
largest_sort_order = View.objects.filter(
|
||||
workspace=self.workspace
|
||||
).aggregate(largest=models.Max("sort_order"))["largest"]
|
||||
if largest_sort_order is not None:
|
||||
self.sort_order = largest_sort_order + 10000
|
||||
|
||||
super(View, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the View"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
|
||||
class IssueViewFavorite(ProjectBaseModel):
|
||||
class ViewFavorite(WorkspaceBaseModel):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="user_view_favorites",
|
||||
)
|
||||
view = models.ForeignKey(
|
||||
"db.IssueView", on_delete=models.CASCADE, related_name="view_favorites"
|
||||
"db.View", on_delete=models.CASCADE, related_name="view_favorites"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -326,7 +326,7 @@ class WorkspaceUserProperties(BaseModel):
|
||||
unique_together = ["workspace", "user"]
|
||||
verbose_name = "Workspace User Property"
|
||||
verbose_name_plural = "Workspace User Property"
|
||||
db_table = "Workspace_user_properties"
|
||||
db_table = "workspace_user_properties"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# base requirements
|
||||
|
||||
Django==4.2.7
|
||||
Django==4.2.10
|
||||
psycopg==3.1.12
|
||||
djangorestframework==3.14.0
|
||||
redis==4.6.0
|
||||
@@ -30,7 +30,7 @@ openpyxl==3.1.2
|
||||
beautifulsoup4==4.12.2
|
||||
dj-database-url==2.1.0
|
||||
posthog==3.0.2
|
||||
cryptography==41.0.6
|
||||
cryptography==42.0.0
|
||||
lxml==4.9.3
|
||||
boto3==1.28.40
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ services:
|
||||
dockerfile: Dockerfile.dev
|
||||
args:
|
||||
DOCKER_BUILDKIT: 1
|
||||
restart: no
|
||||
restart: "no"
|
||||
networks:
|
||||
- dev_env
|
||||
volumes:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
@@ -34,4 +34,4 @@
|
||||
"@types/react": "18.2.42"
|
||||
},
|
||||
"packageManager": "yarn@1.22.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor-core",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.1",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/document-editor",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.1",
|
||||
"description": "Package that powers Plane's Pages Editor",
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
@@ -6,10 +6,16 @@ import { scrollSummary } from "src/utils/editor-summary-utils";
|
||||
interface ContentBrowserProps {
|
||||
editor: Editor;
|
||||
markings: IMarking[];
|
||||
setSidePeekVisible?: (sidePeekState: boolean) => void;
|
||||
}
|
||||
|
||||
export const ContentBrowser = (props: ContentBrowserProps) => {
|
||||
const { editor, markings } = props;
|
||||
const { editor, markings, setSidePeekVisible } = props;
|
||||
|
||||
const handleOnClick = (marking: IMarking) => {
|
||||
scrollSummary(editor, marking);
|
||||
if (setSidePeekVisible) setSidePeekVisible(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
@@ -18,11 +24,11 @@ export const ContentBrowser = (props: ContentBrowserProps) => {
|
||||
{markings.length !== 0 ? (
|
||||
markings.map((marking) =>
|
||||
marking.level === 1 ? (
|
||||
<HeadingComp onClick={() => scrollSummary(editor, marking)} heading={marking.text} />
|
||||
<HeadingComp onClick={() => handleOnClick(marking)} heading={marking.text} />
|
||||
) : marking.level === 2 ? (
|
||||
<SubheadingComp onClick={() => scrollSummary(editor, marking)} subHeading={marking.text} />
|
||||
<SubheadingComp onClick={() => handleOnClick(marking)} subHeading={marking.text} />
|
||||
) : (
|
||||
<HeadingThreeComp heading={marking.text} onClick={() => scrollSummary(editor, marking)} />
|
||||
<HeadingThreeComp heading={marking.text} onClick={() => handleOnClick(marking)} />
|
||||
)
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -42,8 +42,8 @@ export const EditorHeader = (props: IEditorHeader) => {
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center border-b border-custom-border-200 px-5 py-2">
|
||||
<div className="w-56 flex-shrink-0 lg:w-72">
|
||||
<div className="flex items-center border-b border-custom-border-200 md:px-5 px-3 py-2">
|
||||
<div className="md:w-56 flex-shrink-0 lg:w-72 w-fit">
|
||||
<SummaryPopover
|
||||
editor={editor}
|
||||
markings={markings}
|
||||
@@ -52,7 +52,7 @@ export const EditorHeader = (props: IEditorHeader) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex-shrink-0 hidden md:flex">
|
||||
{!readonly && uploadFile && (
|
||||
<FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />
|
||||
)}
|
||||
|
||||
@@ -152,7 +152,7 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full pb-64 pl-7 pt-5 page-renderer">
|
||||
<div className="w-full pb-64 md:pl-7 pl-3 pt-5 page-renderer">
|
||||
{!readonly ? (
|
||||
<input
|
||||
onChange={(e) => handlePageTitleChange(e.target.value)}
|
||||
|
||||
@@ -33,23 +33,36 @@ export const SummaryPopover: React.FC<Props> = (props) => {
|
||||
<button
|
||||
type="button"
|
||||
ref={setReferenceElement}
|
||||
className={`grid h-7 w-7 place-items-center rounded ${
|
||||
sidePeekVisible ? "bg-custom-primary-100/20 text-custom-primary-100" : "text-custom-text-300"
|
||||
}`}
|
||||
className={`grid h-7 w-7 place-items-center rounded ${sidePeekVisible ? "bg-custom-primary-100/20 text-custom-primary-100" : "text-custom-text-300"
|
||||
}`}
|
||||
onClick={() => setSidePeekVisible(!sidePeekVisible)}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</button>
|
||||
{!sidePeekVisible && (
|
||||
<div
|
||||
className="z-10 hidden max-h-80 w-64 overflow-y-auto rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg group-hover/summary-popover:block"
|
||||
ref={setPopperElement}
|
||||
style={summaryPopoverStyles.popper}
|
||||
{...summaryPopoverAttributes.popper}
|
||||
>
|
||||
<ContentBrowser editor={editor} markings={markings} />
|
||||
</div>
|
||||
)}
|
||||
<div className="md:hidden block">
|
||||
{sidePeekVisible && (
|
||||
<div
|
||||
className="z-10 max-h-80 w-64 overflow-y-auto rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg"
|
||||
ref={setPopperElement}
|
||||
style={summaryPopoverStyles.popper}
|
||||
{...summaryPopoverAttributes.popper}
|
||||
>
|
||||
<ContentBrowser setSidePeekVisible={setSidePeekVisible} editor={editor} markings={markings} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
{!sidePeekVisible && (
|
||||
<div
|
||||
className="z-10 hidden max-h-80 w-64 overflow-y-auto rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg group-hover/summary-popover:block"
|
||||
ref={setPopperElement}
|
||||
style={summaryPopoverStyles.popper}
|
||||
{...summaryPopoverAttributes.popper}
|
||||
>
|
||||
<ContentBrowser editor={editor} markings={markings} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DocumentDetails } from "src/types/editor-types";
|
||||
import { PageRenderer } from "src/ui/components/page-renderer";
|
||||
import { getMenuOptions } from "src/utils/menu-options";
|
||||
import { useRouter } from "next/router";
|
||||
import { FixedMenu } from "src";
|
||||
|
||||
interface IDocumentEditor {
|
||||
// document info
|
||||
@@ -149,11 +150,14 @@ const DocumentEditor = ({
|
||||
documentDetails={documentDetails}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
<div className="flex-shrink-0 md:hidden border-b border-custom-border-200 pl-3 py-2">
|
||||
{uploadFile && <FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />}
|
||||
</div>
|
||||
<div className="flex h-full w-full overflow-y-auto frame-renderer">
|
||||
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-72">
|
||||
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-72 hidden md:block">
|
||||
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
|
||||
</div>
|
||||
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
|
||||
<div className="h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
|
||||
<PageRenderer
|
||||
onActionCompleteHandler={onActionCompleteHandler}
|
||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||
|
||||
@@ -77,7 +77,7 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center divide-x divide-custom-border-200">
|
||||
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
|
||||
<div className="flex items-center gap-0.5 pr-2">
|
||||
{basicMarkItems.map((item) => (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor-extensions",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.1",
|
||||
"description": "Package that powers Plane's Editor with extensions",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/lite-text-editor",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.1",
|
||||
"description": "Package that powers Plane's Comment Editor",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/rich-text-editor",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.1",
|
||||
"description": "Rich Text Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eslint-config-custom",
|
||||
"private": true,
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.1",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tailwind-config-custom",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.1",
|
||||
"description": "common tailwind configuration across monorepo",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tsconfig",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.1",
|
||||
"private": true,
|
||||
"files": [
|
||||
"base.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/types",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.1",
|
||||
"private": true,
|
||||
"main": "./src/index.d.ts"
|
||||
}
|
||||
|
||||
Vendored
-72
@@ -1,72 +0,0 @@
|
||||
import { TIssue } from "./issues/base";
|
||||
import type { IProjectLite } from "./projects";
|
||||
|
||||
export type TInboxIssueExtended = {
|
||||
completed_at: string | null;
|
||||
start_date: string | null;
|
||||
target_date: string | null;
|
||||
};
|
||||
|
||||
export interface IInboxIssue extends TIssue, TInboxIssueExtended {
|
||||
issue_inbox: {
|
||||
duplicate_to: string | null;
|
||||
id: string;
|
||||
snoozed_till: Date | null;
|
||||
source: string;
|
||||
status: -2 | -1 | 0 | 1 | 2;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface IInbox {
|
||||
id: string;
|
||||
project_detail: IProjectLite;
|
||||
pending_issue_count: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
name: string;
|
||||
description: string;
|
||||
is_default: boolean;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
project: string;
|
||||
view_props: { filters: IInboxFilterOptions };
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
interface StatePending {
|
||||
readonly status: -2;
|
||||
}
|
||||
interface StatusReject {
|
||||
status: -1;
|
||||
}
|
||||
|
||||
interface StatusSnoozed {
|
||||
status: 0;
|
||||
snoozed_till: Date;
|
||||
}
|
||||
|
||||
interface StatusAccepted {
|
||||
status: 1;
|
||||
}
|
||||
|
||||
interface StatusDuplicate {
|
||||
status: 2;
|
||||
duplicate_to: string;
|
||||
}
|
||||
|
||||
export type TInboxStatus =
|
||||
| StatusReject
|
||||
| StatusSnoozed
|
||||
| StatusAccepted
|
||||
| StatusDuplicate
|
||||
| StatePending;
|
||||
|
||||
export interface IInboxFilterOptions {
|
||||
priority?: string[] | null;
|
||||
inbox_status?: number[] | null;
|
||||
}
|
||||
|
||||
export interface IInboxQueryParams {
|
||||
priority: string | null;
|
||||
inbox_status: string | null;
|
||||
}
|
||||
Vendored
+1
-4
@@ -13,11 +13,7 @@ export * from "./pages";
|
||||
export * from "./ai";
|
||||
export * from "./estimate";
|
||||
export * from "./importer";
|
||||
|
||||
// FIXME: Remove this after development and the refactor/mobx-store-issue branch is stable
|
||||
export * from "./inbox";
|
||||
export * from "./inbox/root";
|
||||
|
||||
export * from "./analytics";
|
||||
export * from "./calendar";
|
||||
export * from "./notifications";
|
||||
@@ -31,6 +27,7 @@ export * from "./auth";
|
||||
export * from "./api_token";
|
||||
export * from "./instance";
|
||||
export * from "./app";
|
||||
export * from "./view/root";
|
||||
|
||||
export type NestedKeyOf<ObjectType extends object> = {
|
||||
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
|
||||
|
||||
Vendored
+8
-2
@@ -1,5 +1,11 @@
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import type { IUser, IUserLite, IWorkspace, IWorkspaceLite, TStateGroups } from ".";
|
||||
import type {
|
||||
IUser,
|
||||
IUserLite,
|
||||
IWorkspace,
|
||||
IWorkspaceLite,
|
||||
TStateGroups,
|
||||
} from ".";
|
||||
|
||||
export interface IProject {
|
||||
archive_in: number;
|
||||
@@ -117,7 +123,7 @@ export type TProjectIssuesSearchParams = {
|
||||
parent?: boolean;
|
||||
issue_relation?: boolean;
|
||||
cycle?: boolean;
|
||||
module?: string[];
|
||||
module?: string;
|
||||
sub_issue?: boolean;
|
||||
issue_id?: string;
|
||||
workspace_search: boolean;
|
||||
|
||||
Vendored
+54
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
TViewFilters,
|
||||
TViewDisplayFilters,
|
||||
TViewDisplayProperties,
|
||||
} from "./filter";
|
||||
|
||||
export type TViewTypes =
|
||||
| "WORKSPACE_PRIVATE_VIEWS"
|
||||
| "WORKSPACE_PUBLIC_VIEWS"
|
||||
| "PROJECT_PRIVATE_VIEWS"
|
||||
| "PROJECT_PUBLIC_VIEWS";
|
||||
|
||||
declare enum EViewAccess {
|
||||
"public" = 0,
|
||||
"private" = 1,
|
||||
"shared" = 2,
|
||||
}
|
||||
|
||||
export type TViewAccess =
|
||||
| EViewAccess.public
|
||||
| EViewAccess.private
|
||||
| EViewAccess.shared;
|
||||
|
||||
export type TView = {
|
||||
id: string | undefined;
|
||||
workspace: string | undefined;
|
||||
project: string | undefined;
|
||||
name: string | undefined;
|
||||
description: string | undefined;
|
||||
query: string | undefined;
|
||||
filters: TViewFilters;
|
||||
display_filters: TViewDisplayFilters;
|
||||
display_properties: TViewDisplayProperties;
|
||||
access: TViewAccess | undefined;
|
||||
owned_by: string | undefined;
|
||||
sort_order: number | undefined;
|
||||
is_locked: boolean;
|
||||
is_pinned: boolean;
|
||||
is_favorite: boolean;
|
||||
created_by: string | undefined;
|
||||
updated_by: string | undefined;
|
||||
created_at: Date | undefined;
|
||||
updated_at: Date | undefined;
|
||||
// local view variables
|
||||
is_local_view: boolean;
|
||||
};
|
||||
|
||||
export type TUpdateView = {
|
||||
name: string | undefined;
|
||||
description: string | undefined;
|
||||
filters: TViewFilters;
|
||||
display_filters: TViewDisplayFilters;
|
||||
display_properties: TViewDisplayProperties;
|
||||
};
|
||||
Vendored
+134
@@ -0,0 +1,134 @@
|
||||
declare enum EViewLayouts {
|
||||
LIST = "list",
|
||||
KANBAN = "kanban",
|
||||
CALENDAR = "calendar",
|
||||
SPREADSHEET = "spreadsheet",
|
||||
GANTT = "gantt",
|
||||
}
|
||||
|
||||
export type TViewLayouts =
|
||||
| EViewLayouts.LIST
|
||||
| EViewLayouts.KANBAN
|
||||
| EViewLayouts.CALENDAR
|
||||
| EViewLayouts.SPREADSHEET
|
||||
| EViewLayouts.GANTT;
|
||||
|
||||
export type TViewDisplayFiltersGrouped =
|
||||
| "project"
|
||||
| "state_detail.group"
|
||||
| "state"
|
||||
| "priority"
|
||||
| "labels"
|
||||
| "created_by"
|
||||
| "assignees"
|
||||
| "mentions"
|
||||
| "modules"
|
||||
| "cycles";
|
||||
|
||||
export type TViewDisplayFiltersOrderBy =
|
||||
| "sort_order"
|
||||
| "created_at"
|
||||
| "-created_at"
|
||||
| "updated_at"
|
||||
| "-updated_at"
|
||||
| "start_date"
|
||||
| "-start_date"
|
||||
| "target_date"
|
||||
| "-target_date"
|
||||
| "state__name"
|
||||
| "-state__name"
|
||||
| "priority"
|
||||
| "-priority"
|
||||
| "labels__name"
|
||||
| "-labels__name"
|
||||
| "assignees__first_name"
|
||||
| "-assignees__first_name"
|
||||
| "estimate_point"
|
||||
| "-estimate_point"
|
||||
| "link_count"
|
||||
| "-link_count"
|
||||
| "attachment_count"
|
||||
| "-attachment_count"
|
||||
| "sub_issues_count"
|
||||
| "-sub_issues_count";
|
||||
|
||||
export type TViewDisplayFiltersExtraOptions = "sub_issue" | "show_empty_groups";
|
||||
|
||||
export type TViewDisplayFiltersType = "active" | "backlog";
|
||||
|
||||
export type TViewCalendarLayouts = "month" | "week";
|
||||
|
||||
export type TViewFilters = {
|
||||
project: string[];
|
||||
module: string[];
|
||||
cycle: string[];
|
||||
priority: string[];
|
||||
state: string[];
|
||||
state_group: string[];
|
||||
assignees: string[];
|
||||
mentions: string[];
|
||||
subscriber: string[];
|
||||
created_by: string[];
|
||||
labels: string[];
|
||||
start_date: string[];
|
||||
target_date: string[];
|
||||
};
|
||||
|
||||
export type TViewDisplayFilters = {
|
||||
layout: TViewLayouts;
|
||||
group_by: TViewDisplayFiltersGrouped | undefined;
|
||||
sub_group_by: TViewDisplayFiltersGrouped | undefined;
|
||||
order_by: TViewDisplayFiltersOrderBy;
|
||||
type: TViewDisplayFiltersType | undefined;
|
||||
sub_issue: boolean;
|
||||
show_empty_groups: boolean;
|
||||
calendar: {
|
||||
show_weekends: boolean;
|
||||
layout: TViewCalendarLayouts;
|
||||
};
|
||||
};
|
||||
|
||||
export type TViewDisplayProperties = {
|
||||
assignee: boolean;
|
||||
start_date: boolean;
|
||||
due_date: boolean;
|
||||
labels: boolean;
|
||||
key: boolean;
|
||||
priority: boolean;
|
||||
state: boolean;
|
||||
sub_issue_count: boolean;
|
||||
link: boolean;
|
||||
attachment_count: boolean;
|
||||
estimate: boolean;
|
||||
created_on: boolean;
|
||||
updated_on: boolean;
|
||||
};
|
||||
|
||||
export type TViewFilterProps = {
|
||||
filters: TViewFilters;
|
||||
display_filters: TViewDisplayFilters;
|
||||
display_properties: TViewDisplayProperties;
|
||||
};
|
||||
|
||||
export type TViewFilterPartialProps = {
|
||||
filters: Partial<TViewFilters>;
|
||||
display_filters: Partial<TViewDisplayFilters>;
|
||||
display_properties: Partial<TViewDisplayProperties>;
|
||||
};
|
||||
|
||||
export type TViewFilterQueryParams =
|
||||
| "project"
|
||||
| "module"
|
||||
| "cycle"
|
||||
| "priority"
|
||||
| "state"
|
||||
| "state_group"
|
||||
| "assignees"
|
||||
| "mentions"
|
||||
| "subscriber"
|
||||
| "created_by"
|
||||
| "labels"
|
||||
| "start_date"
|
||||
| "target_date"
|
||||
| "type"
|
||||
| "sub_issue";
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export * from "./filter";
|
||||
export * from "./base";
|
||||
export * from "./user-base";
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
TViewFilters,
|
||||
TViewDisplayFilters,
|
||||
TViewDisplayProperties,
|
||||
} from "./filter";
|
||||
|
||||
export type TUserView = {
|
||||
id: string | undefined;
|
||||
workspace: string | undefined;
|
||||
user: string | undefined;
|
||||
filters: TViewFilters;
|
||||
display_filters: TViewDisplayFilters;
|
||||
display_properties: TViewDisplayProperties;
|
||||
created_by: string | undefined;
|
||||
updated_by: string | undefined;
|
||||
created_at: Date | undefined;
|
||||
updated_at: Date | undefined;
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@plane/ui",
|
||||
"description": "UI components shared across multiple apps internally",
|
||||
"private": true,
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.1",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@@ -1,35 +1,73 @@
|
||||
import * as React from "react";
|
||||
|
||||
// icons
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
type BreadcrumbsProps = {
|
||||
children: any;
|
||||
children: React.ReactNode;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
const Breadcrumbs = ({ children }: BreadcrumbsProps) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
{React.Children.map(children, (child, index) => (
|
||||
<div key={index} className="flex items-center gap-2.5">
|
||||
{child}
|
||||
{index !== React.Children.count(children) - 1 && (
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const Breadcrumbs = ({ children, onBack }: BreadcrumbsProps) => {
|
||||
const [isSmallScreen, setIsSmallScreen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsSmallScreen(window.innerWidth <= 640); // Adjust this value as per your requirement
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
handleResize(); // Call it initially to set the correct state
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
{!isSmallScreen && (
|
||||
<>
|
||||
{childrenArray.map((child, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && !isSmallScreen && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-center gap-2.5 ${isSmallScreen && index > 0 ? 'hidden sm:flex' : 'flex'}`}>
|
||||
{child}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSmallScreen && childrenArray.length > 1 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{onBack && <span onClick={onBack} className="text-custom-text-200">...</span>}
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">{childrenArray[childrenArray.length - 1]}</div>
|
||||
</>
|
||||
)}
|
||||
{isSmallScreen && childrenArray.length === 1 && childrenArray}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
type?: "text" | "component";
|
||||
component?: React.ReactNode;
|
||||
link?: JSX.Element;
|
||||
};
|
||||
|
||||
const BreadcrumbItem: React.FC<Props> = (props) => {
|
||||
const { type = "text", component, link } = props;
|
||||
return <>{type != "text" ? <div className="flex items-center space-x-2">{component}</div> : link}</>;
|
||||
return <>{type !== "text" ? <div className="flex items-center space-x-2">{component}</div> : link}</>;
|
||||
};
|
||||
|
||||
Breadcrumbs.BreadcrumbItem = BreadcrumbItem;
|
||||
|
||||
export { Breadcrumbs, BreadcrumbItem };
|
||||
export { Breadcrumbs, BreadcrumbItem };
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { mutate } from "swr";
|
||||
@@ -138,14 +138,18 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||
|
||||
const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds;
|
||||
|
||||
|
||||
return (
|
||||
<div className={cn("relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100", !isProjectLevel ? "flex-col" : "")}
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100",
|
||||
!isProjectLevel ? "flex-col" : ""
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
|
||||
<LayersIcon height={14} width={14} />
|
||||
{analytics ? analytics.total : "..."} <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
|
||||
{analytics ? analytics.total : "..."}{" "}
|
||||
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
|
||||
</div>
|
||||
{isProjectLevel && (
|
||||
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
|
||||
@@ -154,8 +158,8 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||
(cycleId
|
||||
? cycleDetails?.created_at
|
||||
: moduleId
|
||||
? moduleDetails?.created_at
|
||||
: projectDetails?.created_at) ?? ""
|
||||
? moduleDetails?.created_at
|
||||
: projectDetails?.created_at) ?? ""
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -78,7 +78,6 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !workspaceSlug || !projectId) return;
|
||||
if (issues.length <= 0) setIsSearching(true);
|
||||
|
||||
projectService
|
||||
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
|
||||
@@ -88,16 +87,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
})
|
||||
.then((res) => setIssues(res))
|
||||
.finally(() => setIsSearching(false));
|
||||
}, [issues, debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm("");
|
||||
setIssues([]);
|
||||
setSelectedIssues([]);
|
||||
setIsSearching(false);
|
||||
setIsSubmitting(false);
|
||||
setIsWorkspaceLevel(false);
|
||||
}, [isOpen]);
|
||||
}, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -40,13 +40,15 @@ const RenderIfVisible: React.FC<Props> = (props) => {
|
||||
if (intersectionRef.current) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (typeof window !== undefined && window.requestIdleCallback) {
|
||||
window.requestIdleCallback(() => setShouldVisible(entries[0].isIntersecting), {
|
||||
timeout: 300,
|
||||
});
|
||||
} else {
|
||||
setShouldVisible(entries[0].isIntersecting);
|
||||
}
|
||||
//DO no remove comments for future
|
||||
// if (typeof window !== undefined && window.requestIdleCallback) {
|
||||
// window.requestIdleCallback(() => setShouldVisible(entries[0].isIntersecting), {
|
||||
// timeout: 300,
|
||||
// });
|
||||
// } else {
|
||||
// setShouldVisible(entries[0].isIntersecting);
|
||||
// }
|
||||
setShouldVisible(entries[0].isIntersecting);
|
||||
},
|
||||
{
|
||||
root: root?.current,
|
||||
|
||||
@@ -3,12 +3,20 @@ import { Menu } from "lucide-react";
|
||||
import { useApplication } from "hooks/store";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
export const SidebarHamburgerToggle: FC = observer(() => {
|
||||
const { theme: themStore } = useApplication();
|
||||
type Props = {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const SidebarHamburgerToggle: FC<Props> = observer((props) => {
|
||||
const { onClick } = props
|
||||
const { theme: themeStore } = useApplication();
|
||||
return (
|
||||
<div
|
||||
className="w-7 h-7 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden"
|
||||
onClick={() => themStore.toggleSidebar()}
|
||||
className="w-7 h-7 flex-shrink-0 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden"
|
||||
onClick={() => {
|
||||
if (onClick) onClick()
|
||||
else themeStore.toggleMobileSidebar()
|
||||
}}
|
||||
>
|
||||
<Menu size={14} className="text-custom-text-200 group-hover:text-custom-text-100 transition-all" />
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,8 @@ import { ICycle, TCycleGroups } from "@plane/types";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
import { CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
|
||||
import { CYCLE_EMPTY_STATE_DETAILS, CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle";
|
||||
import { CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle";
|
||||
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state";
|
||||
|
||||
interface IActiveCycleDetails {
|
||||
workspaceSlug: string;
|
||||
|
||||
@@ -38,7 +38,7 @@ export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspa
|
||||
{peekCycle && (
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300"
|
||||
className="flex h-full w-full max-w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300 fixed md:relative right-0 z-[9]"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FC, MouseEvent, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useEventTracker, useCycle, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
@@ -26,7 +27,7 @@ export interface ICyclesBoardCard {
|
||||
cycleId: string;
|
||||
}
|
||||
|
||||
export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
const { cycleId, workspaceSlug, projectId } = props;
|
||||
// states
|
||||
const [updateModal, setUpdateModal] = useState(false);
|
||||
@@ -175,7 +176,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
/>
|
||||
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
||||
<div className="flex h-44 w-full min-w-[250px] flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
|
||||
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3 truncate">
|
||||
<span className="flex-shrink-0">
|
||||
@@ -253,7 +254,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
) : (
|
||||
<span className="text-xs text-custom-text-400">No due date</span>
|
||||
)}
|
||||
<div className="z-10 flex items-center gap-1.5">
|
||||
<div className="z-[5] flex items-center gap-1.5">
|
||||
{isEditingAllowed &&
|
||||
(cycleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
@@ -295,4 +296,4 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useUser } from "hooks/store";
|
||||
import { CyclePeekOverview, CyclesBoardCard } from "components/cycles";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// constants
|
||||
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/cycle";
|
||||
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state";
|
||||
|
||||
export interface ICyclesBoard {
|
||||
cycleIds: string[];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FC, MouseEvent, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useEventTracker, useCycle, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
@@ -30,7 +31,7 @@ type TCyclesListItem = {
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
const { cycleId, workspaceSlug, projectId } = props;
|
||||
// states
|
||||
const [updateModal, setUpdateModal] = useState(false);
|
||||
@@ -204,7 +205,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<button onClick={openCycleOverview} className="invisible z-10 flex-shrink-0 group-hover:visible">
|
||||
<button onClick={openCycleOverview} className="flex-shrink-0 z-[5] invisible group-hover:visible">
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -289,4 +290,4 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// constants
|
||||
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/cycle";
|
||||
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state";
|
||||
|
||||
export interface ICyclesList {
|
||||
cycleIds: string[];
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCycle } from "hooks/store";
|
||||
// components
|
||||
import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles";
|
||||
// ui components
|
||||
import { Loader } from "@plane/ui";
|
||||
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui";
|
||||
// types
|
||||
import { TCycleLayout, TCycleView } from "@plane/types";
|
||||
|
||||
@@ -25,6 +25,7 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
|
||||
currentProjectDraftCycleIds,
|
||||
currentProjectUpcomingCycleIds,
|
||||
currentProjectCycleIds,
|
||||
loader,
|
||||
} = useCycle();
|
||||
|
||||
const cyclesList =
|
||||
@@ -36,55 +37,32 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
|
||||
? currentProjectUpcomingCycleIds
|
||||
: currentProjectCycleIds;
|
||||
|
||||
if (loader || !cyclesList)
|
||||
return (
|
||||
<>
|
||||
{layout === "list" && <CycleModuleListLayout />}
|
||||
{layout === "board" && <CycleModuleBoardLayout />}
|
||||
{layout === "gantt" && <GanttLayoutLoader />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{layout === "list" && (
|
||||
<>
|
||||
{cyclesList ? (
|
||||
<CyclesList cycleIds={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
) : (
|
||||
<Loader className="space-y-4 p-8">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
<CyclesList cycleIds={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
)}
|
||||
|
||||
{layout === "board" && (
|
||||
<>
|
||||
{cyclesList ? (
|
||||
<CyclesBoard
|
||||
cycleIds={cyclesList}
|
||||
filter={filter}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
peekCycle={peekCycle}
|
||||
/>
|
||||
) : (
|
||||
<Loader className="grid grid-cols-1 gap-9 p-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Loader.Item height="200px" />
|
||||
<Loader.Item height="200px" />
|
||||
<Loader.Item height="200px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
<CyclesBoard
|
||||
cycleIds={cyclesList}
|
||||
filter={filter}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
peekCycle={peekCycle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{layout === "gantt" && (
|
||||
<>
|
||||
{cyclesList ? (
|
||||
<CyclesListGanttChartView cycleIds={cyclesList} workspaceSlug={workspaceSlug} />
|
||||
) : (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{layout === "gantt" && <CyclesListGanttChartView cycleIds={cyclesList} workspaceSlug={workspaceSlug} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useApplication, useCycle } from "hooks/store";
|
||||
// ui
|
||||
import { Tooltip, ContrastIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
|
||||
export const CycleGanttBlock = ({ data }: { data: ICycle }) => {
|
||||
type Props = {
|
||||
cycleId: string;
|
||||
};
|
||||
|
||||
export const CycleGanttBlock: React.FC<Props> = observer((props) => {
|
||||
const { cycleId } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
// store hooks
|
||||
const {
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
const { getCycleById } = useCycle();
|
||||
// derived values
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
|
||||
const cycleStatus = cycleDetails?.status.toLocaleLowerCase();
|
||||
|
||||
const cycleStatus = data.status.toLocaleLowerCase();
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-full w-full items-center rounded"
|
||||
@@ -26,36 +40,45 @@ export const CycleGanttBlock = ({ data }: { data: ICycle }) => {
|
||||
? "rgb(var(--color-text-200))"
|
||||
: "",
|
||||
}}
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/cycles/${data?.id}`)}
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)}
|
||||
>
|
||||
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" />
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<div className="space-y-1">
|
||||
<h5>{data?.name}</h5>
|
||||
<h5>{cycleDetails?.name}</h5>
|
||||
<div>
|
||||
{renderFormattedDate(data?.start_date ?? "")} to {renderFormattedDate(data?.end_date ?? "")}
|
||||
{renderFormattedDate(cycleDetails?.start_date ?? "")} to{" "}
|
||||
{renderFormattedDate(cycleDetails?.end_date ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
position="top-left"
|
||||
>
|
||||
<div className="relative w-full truncate px-2.5 py-1 text-sm text-custom-text-100">{data?.name}</div>
|
||||
<div className="relative w-full truncate px-2.5 py-1 text-sm text-custom-text-100">{cycleDetails?.name}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export const CycleGanttSidebarBlock = ({ data }: { data: ICycle }) => {
|
||||
export const CycleGanttSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
const { cycleId } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
// store hooks
|
||||
const {
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
const { getCycleById } = useCycle();
|
||||
// derived values
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
|
||||
const cycleStatus = data.status.toLocaleLowerCase();
|
||||
const cycleStatus = cycleDetails?.status.toLocaleLowerCase();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-full w-full items-center gap-2"
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/cycles/${data?.id}`)}
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)}
|
||||
>
|
||||
<ContrastIcon
|
||||
className="h-5 w-5 flex-shrink-0"
|
||||
@@ -71,7 +94,7 @@ export const CycleGanttSidebarBlock = ({ data }: { data: ICycle }) => {
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
<h6 className="flex-grow truncate text-sm font-medium">{data?.name}</h6>
|
||||
<h6 className="flex-grow truncate text-sm font-medium">{cycleDetails?.name}</h6>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
||||
blocks={cycleIds ? blockFormat(cycleIds.map((c) => getCycleById(c))) : null}
|
||||
blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)}
|
||||
sidebarToRender={(props) => <CycleGanttSidebar {...props} />}
|
||||
blockToRender={(data: ICycle) => <CycleGanttBlock data={data} />}
|
||||
blockToRender={(data: ICycle) => <CycleGanttBlock cycleId={data.id} />}
|
||||
enableBlockLeftResize={false}
|
||||
enableBlockRightResize={false}
|
||||
enableBlockMove={false}
|
||||
|
||||
@@ -100,7 +100,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
})
|
||||
|
||||
.catch((_) => {
|
||||
.catch(() => {
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_UPDATED,
|
||||
payload: {
|
||||
@@ -329,7 +329,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const isEndValid = new Date(`${cycleDetails?.end_date}`) >= new Date(`${cycleDetails?.start_date}`);
|
||||
|
||||
const progressPercentage = cycleDetails
|
||||
? isCompleted
|
||||
? isCompleted && cycleDetails?.progress_snapshot
|
||||
? Math.round(
|
||||
(cycleDetails.progress_snapshot.completed_issues / cycleDetails.progress_snapshot.total_issues) * 100
|
||||
)
|
||||
|
||||
@@ -61,6 +61,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
@@ -111,23 +112,15 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
// fetch cycles of the project if not already present in the store
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
if (!cycleIds) fetchAllCycles(workspaceSlug, projectId);
|
||||
}, [cycleIds, fetchAllCycles, projectId, workspaceSlug]);
|
||||
|
||||
const selectedCycle = value ? getCycleById(value) : null;
|
||||
|
||||
const onOpen = () => {
|
||||
if (referenceElement) referenceElement.focus();
|
||||
if (workspaceSlug && !cycleIds) fetchAllCycles(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
if (referenceElement) referenceElement.blur();
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
@@ -151,6 +144,12 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
@@ -216,6 +215,8 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, ReactNode, useRef, useState } from "react";
|
||||
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { usePopper } from "react-popper";
|
||||
@@ -60,6 +60,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
@@ -110,13 +111,11 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
|
||||
const onOpen = () => {
|
||||
if (!activeEstimate && workspaceSlug) fetchProjectEstimates(workspaceSlug, projectId);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
if (referenceElement) referenceElement.blur();
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
@@ -140,6 +139,12 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
@@ -205,6 +210,8 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { usePopper } from "react-popper";
|
||||
@@ -50,6 +50,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
@@ -103,13 +104,11 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
||||
|
||||
const onOpen = () => {
|
||||
if (!projectMemberIds && workspaceSlug) fetchProjectMembers(workspaceSlug, projectId);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
if (referenceElement) referenceElement.blur();
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
@@ -133,6 +132,12 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
@@ -203,6 +208,8 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { usePopper } from "react-popper";
|
||||
@@ -44,6 +44,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
@@ -91,19 +92,13 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
|
||||
};
|
||||
if (multiple) comboboxProps.multiple = true;
|
||||
|
||||
const onOpen = () => {
|
||||
if (referenceElement) referenceElement.focus();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
if (referenceElement) referenceElement.blur();
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!isOpen) onOpen();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
};
|
||||
|
||||
@@ -122,6 +117,12 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
@@ -192,6 +193,8 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
|
||||
@@ -166,6 +166,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
@@ -216,21 +217,13 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
// fetch modules of the project if not already present in the store
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
if (!moduleIds) fetchModules(workspaceSlug, projectId);
|
||||
}, [moduleIds, fetchModules, projectId, workspaceSlug]);
|
||||
|
||||
const onOpen = () => {
|
||||
if (referenceElement) referenceElement.focus();
|
||||
if (!moduleIds && workspaceSlug) fetchModules(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
if (referenceElement) referenceElement.blur();
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
@@ -261,6 +254,12 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
};
|
||||
if (multiple) comboboxProps.multiple = true;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
@@ -331,6 +330,8 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, ReactNode, useRef, useState } from "react";
|
||||
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
@@ -272,6 +272,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
@@ -305,19 +306,13 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
const filteredOptions =
|
||||
query === "" ? options : options.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const onOpen = () => {
|
||||
if (referenceElement) referenceElement.focus();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
if (referenceElement) referenceElement.blur();
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!isOpen) onOpen();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
};
|
||||
|
||||
@@ -342,6 +337,12 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
? BackgroundButton
|
||||
: TransparentButton;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
@@ -409,6 +410,8 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, ReactNode, useRef, useState } from "react";
|
||||
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { usePopper } from "react-popper";
|
||||
@@ -50,6 +50,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
@@ -94,19 +95,13 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
||||
|
||||
const selectedProject = value ? getProjectById(value) : null;
|
||||
|
||||
const onOpen = () => {
|
||||
if (referenceElement) referenceElement.focus();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
onClose && onClose();
|
||||
if (referenceElement) referenceElement.blur();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!isOpen) onOpen();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
};
|
||||
|
||||
@@ -125,6 +120,12 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
@@ -198,6 +199,8 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, ReactNode, useRef, useState } from "react";
|
||||
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { usePopper } from "react-popper";
|
||||
@@ -52,6 +52,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
@@ -92,14 +93,12 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
|
||||
const onOpen = () => {
|
||||
if (!statesList && workspaceSlug) fetchProjectStates(workspaceSlug, projectId);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
onClose && onClose();
|
||||
if (referenceElement) referenceElement.blur();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
@@ -122,6 +121,12 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
@@ -193,6 +198,8 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
|
||||
@@ -65,14 +65,14 @@ export const EmptyState: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-full min-w-full overflow-y-auto py-10 px-20">
|
||||
<div className="flex items-center justify-center min-h-full min-w-full overflow-y-auto py-10 md:px-20 px-5">
|
||||
<div
|
||||
className={cn("flex flex-col gap-5", {
|
||||
"min-w-[24rem] max-w-[45rem]": size === "sm",
|
||||
"min-w-[30rem] max-w-[60rem]": size === "lg",
|
||||
"md:min-w-[24rem] max-w-[45rem]": size === "sm",
|
||||
"md:min-w-[30rem] max-w-[60rem]": size === "lg",
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 flex-shrink-0">{emptyStateHeader}</div>
|
||||
<div className="flex flex-col gap-1.5 flex-shrink">{emptyStateHeader}</div>
|
||||
|
||||
<Image
|
||||
src={image}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// store hooks
|
||||
import { useEstimate, useProject } from "hooks/store";
|
||||
import { useEstimate, useProject, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { CreateUpdateEstimateModal, DeleteEstimateModal, EstimateListItem } from "components/estimates";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// ui
|
||||
import { Button, Loader } from "@plane/ui";
|
||||
import { EmptyState } from "components/common";
|
||||
// images
|
||||
import emptyEstimate from "public/empty-state/estimate.svg";
|
||||
// types
|
||||
import { IEstimate } from "@plane/types";
|
||||
// helpers
|
||||
import { orderArrayBy } from "helpers/array.helper";
|
||||
// constants
|
||||
import { PROJECT_SETTINGS_EMPTY_STATE_DETAILS } from "constants/empty-state";
|
||||
|
||||
export const EstimatesList: React.FC = observer(() => {
|
||||
// states
|
||||
@@ -25,9 +25,12 @@ export const EstimatesList: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// theme
|
||||
const { resolvedTheme } = useTheme();
|
||||
// store hooks
|
||||
const { updateProject, currentProjectDetails } = useProject();
|
||||
const { projectEstimates, getProjectEstimateById } = useEstimate();
|
||||
const { currentUser } = useUser();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@@ -55,6 +58,10 @@ export const EstimatesList: React.FC = observer(() => {
|
||||
});
|
||||
};
|
||||
|
||||
const emptyStateDetail = PROJECT_SETTINGS_EMPTY_STATE_DETAILS["estimate"];
|
||||
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
|
||||
const emptyStateImage = getEmptyStateImagePath("project-settings", "estimates", isLightMode);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateEstimateModal
|
||||
@@ -108,19 +115,12 @@ export const EstimatesList: React.FC = observer(() => {
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<div className="w-full py-8">
|
||||
<div className="h-full w-full py-8">
|
||||
<EmptyState
|
||||
title="No estimates yet"
|
||||
description="Estimates help you communicate the complexity of an issue."
|
||||
image={emptyEstimate}
|
||||
primaryButton={{
|
||||
icon: <Plus className="h-4 w-4" />,
|
||||
text: "Add Estimate",
|
||||
onClick: () => {
|
||||
setEstimateFormOpen(true);
|
||||
setEstimateToUpdate(undefined);
|
||||
},
|
||||
}}
|
||||
title={emptyStateDetail.title}
|
||||
description={emptyStateDetail.description}
|
||||
image={emptyStateImage}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,25 +3,28 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useUser } from "hooks/store";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// services
|
||||
import { IntegrationService } from "services/integrations";
|
||||
// components
|
||||
import { Exporter, SingleExport } from "components/exporter";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// ui
|
||||
import { Button, Loader } from "@plane/ui";
|
||||
import { Button } from "@plane/ui";
|
||||
import { ImportExportSettingsLoader } from "components/ui";
|
||||
// icons
|
||||
import { MoveLeft, MoveRight, RefreshCw } from "lucide-react";
|
||||
// fetch-keys
|
||||
import { EXPORT_SERVICES_LIST } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { EXPORTERS_LIST } from "constants/workspace";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useUser } from "hooks/store";
|
||||
|
||||
import { WORKSPACE_SETTINGS_EMPTY_STATE_DETAILS } from "constants/empty-state";
|
||||
|
||||
// services
|
||||
const integrationService = new IntegrationService();
|
||||
@@ -34,6 +37,8 @@ const IntegrationGuide = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, provider } = router.query;
|
||||
// theme
|
||||
const { resolvedTheme } = useTheme();
|
||||
// store hooks
|
||||
const { currentUser, currentUserLoader } = useUser();
|
||||
// custom hooks
|
||||
@@ -46,6 +51,10 @@ const IntegrationGuide = observer(() => {
|
||||
: null
|
||||
);
|
||||
|
||||
const emptyStateDetail = WORKSPACE_SETTINGS_EMPTY_STATE_DETAILS["export"];
|
||||
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
|
||||
const emptyStateImage = getEmptyStateImagePath("workspace-settings", "exports", isLightMode);
|
||||
|
||||
const handleCsvClose = () => {
|
||||
router.replace(`/${workspaceSlug?.toString()}/settings/exports`);
|
||||
};
|
||||
@@ -140,15 +149,17 @@ const IntegrationGuide = observer(() => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-4 py-6 text-sm text-custom-text-200">No previous export available.</p>
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<EmptyState
|
||||
title={emptyStateDetail.title}
|
||||
description={emptyStateDetail.description}
|
||||
image={emptyStateImage}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="mt-6 grid grid-cols-1 gap-3">
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
</Loader>
|
||||
<ImportExportSettingsLoader />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+47
-33
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { FC } from "react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
@@ -8,6 +9,8 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||
// constants
|
||||
import { BLOCK_HEIGHT, HEADER_HEIGHT } from "../constants";
|
||||
|
||||
export type GanttChartBlocksProps = {
|
||||
itemsContainerWidth: number;
|
||||
@@ -17,10 +20,11 @@ export type GanttChartBlocksProps = {
|
||||
enableBlockLeftResize: boolean;
|
||||
enableBlockRightResize: boolean;
|
||||
enableBlockMove: boolean;
|
||||
enableAddBlock: boolean;
|
||||
showAllBlocks: boolean;
|
||||
};
|
||||
|
||||
export const GanttChartBlocks: FC<GanttChartBlocksProps> = (props) => {
|
||||
export const GanttChartBlocksList: FC<GanttChartBlocksProps> = observer((props) => {
|
||||
const {
|
||||
itemsContainerWidth,
|
||||
blocks,
|
||||
@@ -29,11 +33,13 @@ export const GanttChartBlocks: FC<GanttChartBlocksProps> = (props) => {
|
||||
enableBlockLeftResize,
|
||||
enableBlockRightResize,
|
||||
enableBlockMove,
|
||||
enableAddBlock,
|
||||
showAllBlocks,
|
||||
} = props;
|
||||
|
||||
const { activeBlock, dispatch } = useChart();
|
||||
// store hooks
|
||||
const { peekIssue } = useIssueDetail();
|
||||
// chart hook
|
||||
const { activeBlock, dispatch } = useChart();
|
||||
|
||||
// update the active block on hover
|
||||
const updateActiveBlock = (block: IGanttBlock | null) => {
|
||||
@@ -77,43 +83,51 @@ export const GanttChartBlocks: FC<GanttChartBlocksProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative z-[5] mt-[72px] h-full overflow-hidden overflow-y-auto"
|
||||
style={{ width: `${itemsContainerWidth}px` }}
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${itemsContainerWidth}px`,
|
||||
transform: `translateY(${HEADER_HEIGHT}px)`,
|
||||
}}
|
||||
>
|
||||
{blocks &&
|
||||
blocks.length > 0 &&
|
||||
blocks.map((block) => {
|
||||
// hide the block if it doesn't have start and target dates and showAllBlocks is false
|
||||
if (!showAllBlocks && !(block.start_date && block.target_date)) return;
|
||||
{blocks?.map((block) => {
|
||||
// hide the block if it doesn't have start and target dates and showAllBlocks is false
|
||||
if (!showAllBlocks && !(block.start_date && block.target_date)) return;
|
||||
|
||||
const isBlockVisibleOnChart = block.start_date && block.target_date;
|
||||
const isBlockVisibleOnChart = block.start_date && block.target_date;
|
||||
|
||||
return (
|
||||
return (
|
||||
<div
|
||||
key={`block-${block.id}`}
|
||||
className="relative min-w-full w-max"
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
key={`block-${block.id}`}
|
||||
className={cn(
|
||||
"h-11",
|
||||
{ "rounded bg-custom-background-80": activeBlock?.id === block.id },
|
||||
{
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
|
||||
peekIssue?.issueId === block.data.id,
|
||||
}
|
||||
)}
|
||||
className={cn("relative h-full", {
|
||||
"bg-custom-background-80": activeBlock?.id === block.id,
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
|
||||
peekIssue?.issueId === block.data.id,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlock(block)}
|
||||
onMouseLeave={() => updateActiveBlock(null)}
|
||||
>
|
||||
{!isBlockVisibleOnChart && <ChartAddBlock block={block} blockUpdateHandler={blockUpdateHandler} />}
|
||||
<ChartDraggable
|
||||
block={block}
|
||||
blockToRender={blockToRender}
|
||||
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
/>
|
||||
{isBlockVisibleOnChart ? (
|
||||
<ChartDraggable
|
||||
block={block}
|
||||
blockToRender={blockToRender}
|
||||
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
/>
|
||||
) : (
|
||||
enableAddBlock && <ChartAddBlock block={block} blockUpdateHandler={blockUpdateHandler} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
export * from "./blocks-display";
|
||||
export * from "./blocks-list";
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Expand, Shrink } from "lucide-react";
|
||||
// hooks
|
||||
import { useChart } from "../hooks";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { IGanttBlock, TGanttViews } from "../types";
|
||||
|
||||
type Props = {
|
||||
blocks: IGanttBlock[] | null;
|
||||
fullScreenMode: boolean;
|
||||
handleChartView: (view: TGanttViews) => void;
|
||||
handleToday: () => void;
|
||||
loaderTitle: string;
|
||||
title: string;
|
||||
toggleFullScreenMode: () => void;
|
||||
};
|
||||
|
||||
export const GanttChartHeader: React.FC<Props> = (props) => {
|
||||
const { blocks, fullScreenMode, handleChartView, handleToday, loaderTitle, title, toggleFullScreenMode } = props;
|
||||
// chart hook
|
||||
const { currentView, allViews } = useChart();
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2 z-10">
|
||||
<div className="flex items-center gap-2 text-lg font-medium">{title}</div>
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto text-sm font-medium">{blocks ? `${blocks.length} ${loaderTitle}` : "Loading..."}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{allViews?.map((chartView: any) => (
|
||||
<div
|
||||
key={chartView?.key}
|
||||
className={cn("cursor-pointer rounded-sm p-1 px-2 text-xs", {
|
||||
"bg-custom-background-80": currentView === chartView?.key,
|
||||
"hover:bg-custom-background-90": currentView !== chartView?.key,
|
||||
})}
|
||||
onClick={() => handleChartView(chartView?.key)}
|
||||
>
|
||||
{chartView?.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className="rounded-sm p-1 px-2 text-xs hover:bg-custom-background-80" onClick={handleToday}>
|
||||
Today
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center rounded-sm border border-custom-border-200 p-1 transition-all hover:bg-custom-background-80"
|
||||
onClick={toggleFullScreenMode}
|
||||
>
|
||||
{fullScreenMode ? <Shrink className="h-4 w-4" /> : <Expand className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./views";
|
||||
export * from "./header";
|
||||
export * from "./main-content";
|
||||
export * from "./root";
|
||||
@@ -1,324 +0,0 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
// icons
|
||||
// components
|
||||
import { GanttChartBlocks } from "components/gantt-chart";
|
||||
// import { GanttSidebar } from "../sidebar";
|
||||
// import { HourChartView } from "./hours";
|
||||
// import { DayChartView } from "./day";
|
||||
// import { WeekChartView } from "./week";
|
||||
// import { BiWeekChartView } from "./bi-week";
|
||||
import { MonthChartView } from "./month";
|
||||
// import { QuarterChartView } from "./quarter";
|
||||
// import { YearChartView } from "./year";
|
||||
// icons
|
||||
import { Expand, Shrink } from "lucide-react";
|
||||
// views
|
||||
import {
|
||||
// generateHourChart,
|
||||
// generateDayChart,
|
||||
// generateWeekChart,
|
||||
// generateBiWeekChart,
|
||||
generateMonthChart,
|
||||
// generateQuarterChart,
|
||||
// generateYearChart,
|
||||
getNumberOfDaysBetweenTwoDatesInMonth,
|
||||
// getNumberOfDaysBetweenTwoDatesInQuarter,
|
||||
// getNumberOfDaysBetweenTwoDatesInYear,
|
||||
getMonthChartItemPositionWidthInMonth,
|
||||
} from "../views";
|
||||
// types
|
||||
import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types";
|
||||
// data
|
||||
import { currentViewDataWithView } from "../data";
|
||||
// context
|
||||
import { useChart } from "../hooks";
|
||||
|
||||
type ChartViewRootProps = {
|
||||
border: boolean;
|
||||
title: string;
|
||||
loaderTitle: string;
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blockToRender: (data: any) => React.ReactNode;
|
||||
sidebarToRender: (props: any) => React.ReactNode;
|
||||
enableBlockLeftResize: boolean;
|
||||
enableBlockRightResize: boolean;
|
||||
enableBlockMove: boolean;
|
||||
enableReorder: boolean;
|
||||
bottomSpacing: boolean;
|
||||
showAllBlocks: boolean;
|
||||
};
|
||||
|
||||
export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
|
||||
const {
|
||||
border,
|
||||
title,
|
||||
blocks = null,
|
||||
loaderTitle,
|
||||
blockUpdateHandler,
|
||||
sidebarToRender,
|
||||
blockToRender,
|
||||
enableBlockLeftResize,
|
||||
enableBlockRightResize,
|
||||
enableBlockMove,
|
||||
enableReorder,
|
||||
bottomSpacing,
|
||||
showAllBlocks,
|
||||
} = props;
|
||||
// states
|
||||
const [itemsContainerWidth, setItemsContainerWidth] = useState<number>(0);
|
||||
const [fullScreenMode, setFullScreenMode] = useState<boolean>(false);
|
||||
const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null); // blocks state management starts
|
||||
// hooks
|
||||
const { currentView, currentViewData, renderView, dispatch, allViews, updateScrollLeft } = useChart();
|
||||
|
||||
const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) =>
|
||||
blocks && blocks.length > 0
|
||||
? blocks.map((block: any) => ({
|
||||
...block,
|
||||
position: getMonthChartItemPositionWidthInMonth(view, block),
|
||||
}))
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
if (currentViewData && blocks) setChartBlocks(() => renderBlockStructure(currentViewData, blocks));
|
||||
}, [currentViewData, blocks]);
|
||||
|
||||
// blocks state management ends
|
||||
|
||||
const handleChartView = (key: TGanttViews) => updateCurrentViewRenderPayload(null, key);
|
||||
|
||||
const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews) => {
|
||||
const selectedCurrentView: TGanttViews = view;
|
||||
const selectedCurrentViewData: ChartDataType | undefined =
|
||||
selectedCurrentView && selectedCurrentView === currentViewData?.key
|
||||
? currentViewData
|
||||
: currentViewDataWithView(view);
|
||||
|
||||
if (selectedCurrentViewData === undefined) return;
|
||||
|
||||
let currentRender: any;
|
||||
|
||||
// if (view === "hours") currentRender = generateHourChart(selectedCurrentViewData, side);
|
||||
// if (view === "day") currentRender = generateDayChart(selectedCurrentViewData, side);
|
||||
// if (view === "week") currentRender = generateWeekChart(selectedCurrentViewData, side);
|
||||
// if (view === "bi_week") currentRender = generateBiWeekChart(selectedCurrentViewData, side);
|
||||
if (selectedCurrentView === "month") currentRender = generateMonthChart(selectedCurrentViewData, side);
|
||||
// if (view === "quarter") currentRender = generateQuarterChart(selectedCurrentViewData, side);
|
||||
// if (selectedCurrentView === "year")
|
||||
// currentRender = generateYearChart(selectedCurrentViewData, side);
|
||||
|
||||
// updating the prevData, currentData and nextData
|
||||
if (currentRender.payload.length > 0) {
|
||||
if (side === "left") {
|
||||
dispatch({
|
||||
type: "PARTIAL_UPDATE",
|
||||
payload: {
|
||||
currentView: selectedCurrentView,
|
||||
currentViewData: currentRender.state,
|
||||
renderView: [...currentRender.payload, ...renderView],
|
||||
},
|
||||
});
|
||||
updatingCurrentLeftScrollPosition(currentRender.scrollWidth);
|
||||
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
|
||||
} else if (side === "right") {
|
||||
dispatch({
|
||||
type: "PARTIAL_UPDATE",
|
||||
payload: {
|
||||
currentView: view,
|
||||
currentViewData: currentRender.state,
|
||||
renderView: [...renderView, ...currentRender.payload],
|
||||
},
|
||||
});
|
||||
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
|
||||
} else {
|
||||
dispatch({
|
||||
type: "PARTIAL_UPDATE",
|
||||
payload: {
|
||||
currentView: view,
|
||||
currentViewData: currentRender.state,
|
||||
renderView: [...currentRender.payload],
|
||||
},
|
||||
});
|
||||
setItemsContainerWidth(currentRender.scrollWidth);
|
||||
setTimeout(() => {
|
||||
handleScrollToCurrentSelectedDate(currentRender.state, currentRender.state.data.currentDate);
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToday = () => updateCurrentViewRenderPayload(null, currentView);
|
||||
|
||||
// handling the scroll positioning from left and right
|
||||
useEffect(() => {
|
||||
handleToday();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const updatingCurrentLeftScrollPosition = (width: number) => {
|
||||
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
scrollContainer.scrollLeft = width + scrollContainer?.scrollLeft;
|
||||
setItemsContainerWidth(width + scrollContainer?.scrollLeft);
|
||||
};
|
||||
|
||||
const handleScrollToCurrentSelectedDate = (currentState: ChartDataType, date: Date) => {
|
||||
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const clientVisibleWidth: number = scrollContainer?.clientWidth;
|
||||
let scrollWidth: number = 0;
|
||||
let daysDifference: number = 0;
|
||||
|
||||
// if (currentView === "hours")
|
||||
// daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
|
||||
// if (currentView === "day")
|
||||
// daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
|
||||
// if (currentView === "week")
|
||||
// daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
|
||||
// if (currentView === "bi_week")
|
||||
// daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
|
||||
if (currentView === "month")
|
||||
daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
|
||||
// if (currentView === "quarter")
|
||||
// daysDifference = getNumberOfDaysBetweenTwoDatesInQuarter(currentState.data.startDate, date);
|
||||
// if (currentView === "year")
|
||||
// daysDifference = getNumberOfDaysBetweenTwoDatesInYear(currentState.data.startDate, date);
|
||||
|
||||
scrollWidth = daysDifference * currentState.data.width - (clientVisibleWidth / 2 - currentState.data.width);
|
||||
|
||||
scrollContainer.scrollLeft = scrollWidth;
|
||||
};
|
||||
|
||||
// handling scroll functionality
|
||||
const onScroll = () => {
|
||||
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const scrollWidth: number = scrollContainer?.scrollWidth;
|
||||
const clientVisibleWidth: number = scrollContainer?.clientWidth;
|
||||
const currentScrollPosition: number = scrollContainer?.scrollLeft;
|
||||
|
||||
updateScrollLeft(currentScrollPosition);
|
||||
|
||||
const approxRangeLeft: number = scrollWidth >= clientVisibleWidth + 1000 ? 1000 : scrollWidth - clientVisibleWidth;
|
||||
const approxRangeRight: number = scrollWidth - (approxRangeLeft + clientVisibleWidth);
|
||||
|
||||
if (currentScrollPosition >= approxRangeRight) updateCurrentViewRenderPayload("right", currentView);
|
||||
if (currentScrollPosition <= approxRangeLeft) updateCurrentViewRenderPayload("left", currentView);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
fullScreenMode ? `fixed bottom-0 left-0 right-0 top-0 z-[999999] bg-custom-background-100` : `relative`
|
||||
} ${
|
||||
border ? `border border-custom-border-200` : ``
|
||||
} flex h-full select-none flex-col rounded-sm bg-custom-background-100 shadow`}
|
||||
>
|
||||
{/* chart header */}
|
||||
<div className="flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2">
|
||||
{title && (
|
||||
<div className="flex items-center gap-2 text-lg font-medium">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="ml-auto">
|
||||
{blocks === null ? (
|
||||
<div className="ml-auto text-sm font-medium">Loading...</div>
|
||||
) : (
|
||||
<div className="ml-auto text-sm font-medium">
|
||||
{blocks.length} {loaderTitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{allViews &&
|
||||
allViews.length > 0 &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
allViews.map((_chatView: any, _idx: any) => (
|
||||
<div
|
||||
key={_chatView?.key}
|
||||
className={`cursor-pointer rounded-sm p-1 px-2 text-xs ${
|
||||
currentView === _chatView?.key ? `bg-custom-background-80` : `hover:bg-custom-background-90`
|
||||
}`}
|
||||
onClick={() => handleChartView(_chatView?.key)}
|
||||
>
|
||||
{_chatView?.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className="cursor-pointer rounded-sm p-1 px-2 text-xs hover:bg-custom-background-80"
|
||||
onClick={handleToday}
|
||||
>
|
||||
Today
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-center rounded-sm border border-custom-border-200 p-1 transition-all hover:bg-custom-background-80"
|
||||
onClick={() => setFullScreenMode((prevData) => !prevData)}
|
||||
>
|
||||
{fullScreenMode ? <Shrink className="h-4 w-4" /> : <Expand className="h-4 w-4" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* content */}
|
||||
<div
|
||||
id="gantt-container"
|
||||
className={`relative flex h-full w-full flex-1 overflow-hidden border-t border-custom-border-200 ${
|
||||
bottomSpacing ? "mb-8" : ""
|
||||
}`}
|
||||
>
|
||||
<div id="gantt-sidebar" className="flex h-full w-1/4 flex-col border-r border-custom-border-200">
|
||||
<div className="box-border flex h-[60px] flex-shrink-0 items-end justify-between gap-2 border-b border-custom-border-200 pb-2 pl-10 pr-4 text-sm font-medium text-custom-text-300">
|
||||
<h6>{title}</h6>
|
||||
<h6>Duration</h6>
|
||||
</div>
|
||||
|
||||
{sidebarToRender && sidebarToRender({ title, blockUpdateHandler, blocks, enableReorder })}
|
||||
</div>
|
||||
<div
|
||||
className="horizontal-scroll-enable relative flex h-full w-full flex-1 flex-col overflow-hidden overflow-x-auto"
|
||||
id="scroll-container"
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{/* {currentView && currentView === "hours" && <HourChartView />} */}
|
||||
{/* {currentView && currentView === "day" && <DayChartView />} */}
|
||||
{/* {currentView && currentView === "week" && <WeekChartView />} */}
|
||||
{/* {currentView && currentView === "bi_week" && <BiWeekChartView />} */}
|
||||
{currentView && currentView === "month" && <MonthChartView />}
|
||||
{/* {currentView && currentView === "quarter" && <QuarterChartView />} */}
|
||||
{/* {currentView && currentView === "year" && <YearChartView />} */}
|
||||
|
||||
{/* blocks */}
|
||||
{currentView && currentViewData && (
|
||||
<GanttChartBlocks
|
||||
itemsContainerWidth={itemsContainerWidth}
|
||||
blocks={chartBlocks}
|
||||
blockToRender={blockToRender}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
showAllBlocks={showAllBlocks}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
// components
|
||||
import {
|
||||
BiWeekChartView,
|
||||
DayChartView,
|
||||
GanttChartBlocksList,
|
||||
GanttChartSidebar,
|
||||
HourChartView,
|
||||
IBlockUpdateData,
|
||||
IGanttBlock,
|
||||
MonthChartView,
|
||||
QuarterChartView,
|
||||
TGanttViews,
|
||||
WeekChartView,
|
||||
YearChartView,
|
||||
useChart,
|
||||
} from "components/gantt-chart";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockToRender: (data: any) => React.ReactNode;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
bottomSpacing: boolean;
|
||||
chartBlocks: IGanttBlock[] | null;
|
||||
enableBlockLeftResize: boolean;
|
||||
enableBlockMove: boolean;
|
||||
enableBlockRightResize: boolean;
|
||||
enableReorder: boolean;
|
||||
enableAddBlock: boolean;
|
||||
itemsContainerWidth: number;
|
||||
showAllBlocks: boolean;
|
||||
sidebarToRender: (props: any) => React.ReactNode;
|
||||
title: string;
|
||||
updateCurrentViewRenderPayload: (direction: "left" | "right", currentView: TGanttViews) => void;
|
||||
quickAdd?: React.JSX.Element | undefined;
|
||||
};
|
||||
|
||||
export const GanttChartMainContent: React.FC<Props> = (props) => {
|
||||
const {
|
||||
blocks,
|
||||
blockToRender,
|
||||
blockUpdateHandler,
|
||||
bottomSpacing,
|
||||
chartBlocks,
|
||||
enableBlockLeftResize,
|
||||
enableBlockMove,
|
||||
enableBlockRightResize,
|
||||
enableReorder,
|
||||
enableAddBlock,
|
||||
itemsContainerWidth,
|
||||
showAllBlocks,
|
||||
sidebarToRender,
|
||||
title,
|
||||
updateCurrentViewRenderPayload,
|
||||
quickAdd,
|
||||
} = props;
|
||||
// chart hook
|
||||
const { currentView, currentViewData, updateScrollLeft } = useChart();
|
||||
// handling scroll functionality
|
||||
const onScroll = (e: React.UIEvent<HTMLDivElement, UIEvent>) => {
|
||||
const { clientWidth, scrollLeft, scrollWidth } = e.currentTarget;
|
||||
|
||||
updateScrollLeft(scrollLeft);
|
||||
|
||||
const approxRangeLeft = scrollLeft >= clientWidth + 1000 ? 1000 : scrollLeft - clientWidth;
|
||||
const approxRangeRight = scrollWidth - (scrollLeft + clientWidth);
|
||||
|
||||
if (approxRangeRight < 1000) updateCurrentViewRenderPayload("right", currentView);
|
||||
if (approxRangeLeft < 1000) updateCurrentViewRenderPayload("left", currentView);
|
||||
};
|
||||
|
||||
const CHART_VIEW_COMPONENTS: {
|
||||
[key in TGanttViews]: React.FC;
|
||||
} = {
|
||||
hours: HourChartView,
|
||||
day: DayChartView,
|
||||
week: WeekChartView,
|
||||
bi_week: BiWeekChartView,
|
||||
month: MonthChartView,
|
||||
quarter: QuarterChartView,
|
||||
year: YearChartView,
|
||||
};
|
||||
|
||||
if (!currentView) return null;
|
||||
const ActiveChartView = CHART_VIEW_COMPONENTS[currentView];
|
||||
|
||||
return (
|
||||
<div
|
||||
// DO NOT REMOVE THE ID
|
||||
id="gantt-container"
|
||||
className={cn(
|
||||
"h-full w-full overflow-auto horizontal-scroll-enable flex border-t-[0.5px] border-custom-border-200",
|
||||
{
|
||||
"mb-8": bottomSpacing,
|
||||
}
|
||||
)}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<GanttChartSidebar
|
||||
blocks={blocks}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableReorder={enableReorder}
|
||||
sidebarToRender={sidebarToRender}
|
||||
title={title}
|
||||
quickAdd={quickAdd}
|
||||
/>
|
||||
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
|
||||
<ActiveChartView />
|
||||
{currentViewData && (
|
||||
<GanttChartBlocksList
|
||||
itemsContainerWidth={itemsContainerWidth}
|
||||
blocks={chartBlocks}
|
||||
blockToRender={blockToRender}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableAddBlock={enableAddBlock}
|
||||
showAllBlocks={showAllBlocks}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
import { FC } from "react";
|
||||
// hooks
|
||||
import { useChart } from "../hooks";
|
||||
// types
|
||||
import { IMonthBlock } from "../views";
|
||||
|
||||
export const MonthChartView: FC<any> = () => {
|
||||
const { currentViewData, renderView } = useChart();
|
||||
|
||||
const monthBlocks: IMonthBlock[] = renderView;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute flex h-full flex-grow divide-x divide-custom-border-100/50">
|
||||
{monthBlocks &&
|
||||
monthBlocks.length > 0 &&
|
||||
monthBlocks.map((block, _idxRoot) => (
|
||||
<div key={`month-${block?.month}-${block?.year}`} className="relative flex flex-col">
|
||||
<div className="h-[60px] w-full">
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[30px] w-full">
|
||||
{block?.children &&
|
||||
block?.children.length > 0 &&
|
||||
block?.children.map((monthDay, _idx) => (
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="flex-shrink-0 border-b border-custom-border-200 py-1 text-center capitalize"
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
>
|
||||
<div className="space-x-1 text-xs">
|
||||
<span className="text-custom-text-200">{monthDay.dayData.shortTitle[0]}</span>{" "}
|
||||
<span className={monthDay.today ? "rounded-full bg-custom-primary-100 px-1 text-white" : ""}>
|
||||
{monthDay.day}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full w-full divide-x divide-custom-border-100/50">
|
||||
{block?.children &&
|
||||
block?.children.length > 0 &&
|
||||
block?.children.map((monthDay, _idx) => (
|
||||
<div
|
||||
key={`column-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
className={`relative flex h-full w-full flex-1 justify-center ${
|
||||
["sat", "sun"].includes(monthDay?.dayData?.shortTitle || "") ? `bg-custom-background-90` : ``
|
||||
}`}
|
||||
>
|
||||
{/* {monthDay?.today && (
|
||||
<div className="absolute top-0 bottom-0 w-[1px] bg-red-500" />
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
// components
|
||||
import { GanttChartHeader, useChart, GanttChartMainContent } from "components/gantt-chart";
|
||||
// views
|
||||
import {
|
||||
generateMonthChart,
|
||||
getNumberOfDaysBetweenTwoDatesInMonth,
|
||||
getMonthChartItemPositionWidthInMonth,
|
||||
} from "../views";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types";
|
||||
// data
|
||||
import { currentViewDataWithView } from "../data";
|
||||
// constants
|
||||
import { SIDEBAR_WIDTH } from "../constants";
|
||||
|
||||
type ChartViewRootProps = {
|
||||
border: boolean;
|
||||
title: string;
|
||||
loaderTitle: string;
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blockToRender: (data: any) => React.ReactNode;
|
||||
sidebarToRender: (props: any) => React.ReactNode;
|
||||
enableBlockLeftResize: boolean;
|
||||
enableBlockRightResize: boolean;
|
||||
enableBlockMove: boolean;
|
||||
enableReorder: boolean;
|
||||
enableAddBlock: boolean;
|
||||
bottomSpacing: boolean;
|
||||
showAllBlocks: boolean;
|
||||
quickAdd?: React.JSX.Element | undefined;
|
||||
};
|
||||
|
||||
export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
|
||||
const {
|
||||
border,
|
||||
title,
|
||||
blocks = null,
|
||||
loaderTitle,
|
||||
blockUpdateHandler,
|
||||
sidebarToRender,
|
||||
blockToRender,
|
||||
enableBlockLeftResize,
|
||||
enableBlockRightResize,
|
||||
enableBlockMove,
|
||||
enableReorder,
|
||||
enableAddBlock,
|
||||
bottomSpacing,
|
||||
showAllBlocks,
|
||||
quickAdd,
|
||||
} = props;
|
||||
// states
|
||||
const [itemsContainerWidth, setItemsContainerWidth] = useState(0);
|
||||
const [fullScreenMode, setFullScreenMode] = useState(false);
|
||||
const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null);
|
||||
// hooks
|
||||
const { currentView, currentViewData, renderView, dispatch } = useChart();
|
||||
|
||||
// rendering the block structure
|
||||
const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) =>
|
||||
blocks
|
||||
? blocks.map((block: any) => ({
|
||||
...block,
|
||||
position: getMonthChartItemPositionWidthInMonth(view, block),
|
||||
}))
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentViewData || !blocks) return;
|
||||
setChartBlocks(() => renderBlockStructure(currentViewData, blocks));
|
||||
}, [currentViewData, blocks]);
|
||||
|
||||
const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews) => {
|
||||
const selectedCurrentView: TGanttViews = view;
|
||||
const selectedCurrentViewData: ChartDataType | undefined =
|
||||
selectedCurrentView && selectedCurrentView === currentViewData?.key
|
||||
? currentViewData
|
||||
: currentViewDataWithView(view);
|
||||
|
||||
if (selectedCurrentViewData === undefined) return;
|
||||
|
||||
let currentRender: any;
|
||||
if (selectedCurrentView === "month") currentRender = generateMonthChart(selectedCurrentViewData, side);
|
||||
|
||||
// updating the prevData, currentData and nextData
|
||||
if (currentRender.payload.length > 0) {
|
||||
if (side === "left") {
|
||||
dispatch({
|
||||
type: "PARTIAL_UPDATE",
|
||||
payload: {
|
||||
currentView: selectedCurrentView,
|
||||
currentViewData: currentRender.state,
|
||||
renderView: [...currentRender.payload, ...renderView],
|
||||
},
|
||||
});
|
||||
updatingCurrentLeftScrollPosition(currentRender.scrollWidth);
|
||||
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
|
||||
} else if (side === "right") {
|
||||
dispatch({
|
||||
type: "PARTIAL_UPDATE",
|
||||
payload: {
|
||||
currentView: view,
|
||||
currentViewData: currentRender.state,
|
||||
renderView: [...renderView, ...currentRender.payload],
|
||||
},
|
||||
});
|
||||
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
|
||||
} else {
|
||||
dispatch({
|
||||
type: "PARTIAL_UPDATE",
|
||||
payload: {
|
||||
currentView: view,
|
||||
currentViewData: currentRender.state,
|
||||
renderView: [...currentRender.payload],
|
||||
},
|
||||
});
|
||||
setItemsContainerWidth(currentRender.scrollWidth);
|
||||
setTimeout(() => {
|
||||
handleScrollToCurrentSelectedDate(currentRender.state, currentRender.state.data.currentDate);
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToday = () => updateCurrentViewRenderPayload(null, currentView);
|
||||
|
||||
// handling the scroll positioning from left and right
|
||||
useEffect(() => {
|
||||
handleToday();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const updatingCurrentLeftScrollPosition = (width: number) => {
|
||||
const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
scrollContainer.scrollLeft = width + scrollContainer?.scrollLeft;
|
||||
setItemsContainerWidth(width + scrollContainer?.scrollLeft);
|
||||
};
|
||||
|
||||
const handleScrollToCurrentSelectedDate = (currentState: ChartDataType, date: Date) => {
|
||||
const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const clientVisibleWidth: number = scrollContainer?.clientWidth;
|
||||
let scrollWidth: number = 0;
|
||||
let daysDifference: number = 0;
|
||||
|
||||
// if (currentView === "hours")
|
||||
// daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
|
||||
// if (currentView === "day")
|
||||
// daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
|
||||
// if (currentView === "week")
|
||||
// daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
|
||||
// if (currentView === "bi_week")
|
||||
// daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
|
||||
if (currentView === "month")
|
||||
daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
|
||||
// if (currentView === "quarter")
|
||||
// daysDifference = getNumberOfDaysBetweenTwoDatesInQuarter(currentState.data.startDate, date);
|
||||
// if (currentView === "year")
|
||||
// daysDifference = getNumberOfDaysBetweenTwoDatesInYear(currentState.data.startDate, date);
|
||||
|
||||
scrollWidth =
|
||||
daysDifference * currentState.data.width - (clientVisibleWidth / 2 - currentState.data.width) + SIDEBAR_WIDTH / 2;
|
||||
|
||||
scrollContainer.scrollLeft = scrollWidth;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex flex-col h-full select-none rounded-sm bg-custom-background-100 shadow", {
|
||||
"fixed inset-0 z-[999999] bg-custom-background-100": fullScreenMode,
|
||||
"border-[0.5px] border-custom-border-200": border,
|
||||
})}
|
||||
>
|
||||
<GanttChartHeader
|
||||
blocks={blocks}
|
||||
fullScreenMode={fullScreenMode}
|
||||
toggleFullScreenMode={() => setFullScreenMode((prevData) => !prevData)}
|
||||
handleChartView={(key) => updateCurrentViewRenderPayload(null, key)}
|
||||
handleToday={handleToday}
|
||||
loaderTitle={loaderTitle}
|
||||
title={title}
|
||||
/>
|
||||
<GanttChartMainContent
|
||||
blocks={blocks}
|
||||
blockToRender={blockToRender}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
bottomSpacing={bottomSpacing}
|
||||
chartBlocks={chartBlocks}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableReorder={enableReorder}
|
||||
enableAddBlock={enableAddBlock}
|
||||
itemsContainerWidth={itemsContainerWidth}
|
||||
showAllBlocks={showAllBlocks}
|
||||
sidebarToRender={sidebarToRender}
|
||||
title={title}
|
||||
updateCurrentViewRenderPayload={updateCurrentViewRenderPayload}
|
||||
quickAdd={quickAdd}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { FC } from "react";
|
||||
// context
|
||||
import { useChart } from "../hooks";
|
||||
import { useChart } from "components/gantt-chart";
|
||||
|
||||
export const BiWeekChartView: FC<any> = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { FC } from "react";
|
||||
// context
|
||||
import { useChart } from "../hooks";
|
||||
import { useChart } from "../../hooks";
|
||||
|
||||
export const DayChartView: FC<any> = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { FC } from "react";
|
||||
// context
|
||||
import { useChart } from "../hooks";
|
||||
import { useChart } from "components/gantt-chart";
|
||||
|
||||
export const HourChartView: FC<any> = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./bi-week";
|
||||
export * from "./day";
|
||||
export * from "./hours";
|
||||
export * from "./month";
|
||||
export * from "./quarter";
|
||||
export * from "./week";
|
||||
export * from "./year";
|
||||
@@ -0,0 +1,74 @@
|
||||
import { FC } from "react";
|
||||
// hooks
|
||||
import { useChart } from "components/gantt-chart";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { IMonthBlock } from "../../views";
|
||||
// constants
|
||||
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "components/gantt-chart/constants";
|
||||
|
||||
export const MonthChartView: FC<any> = () => {
|
||||
// chart hook
|
||||
const { currentViewData, renderView } = useChart();
|
||||
const monthBlocks: IMonthBlock[] = renderView;
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 left-0 h-full w-max flex divide-x divide-custom-border-100/50">
|
||||
{monthBlocks?.map((block, rootIndex) => (
|
||||
<div key={`month-${block?.month}-${block?.year}`} className="relative">
|
||||
<div
|
||||
className="w-full sticky top-0 z-[5] bg-custom-background-100"
|
||||
style={{
|
||||
height: `${HEADER_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<div className="h-1/2">
|
||||
<div
|
||||
className="sticky inline-flex whitespace-nowrap px-3 py-2 text-xs font-medium capitalize"
|
||||
style={{
|
||||
left: `${SIDEBAR_WIDTH}px`,
|
||||
}}
|
||||
>
|
||||
{block?.title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1/2 w-full flex">
|
||||
{block?.children?.map((monthDay, index) => (
|
||||
<div
|
||||
key={`sub-title-${rootIndex}-${index}`}
|
||||
className="flex-shrink-0 border-b-[0.5px] border-custom-border-200 py-1 text-center capitalize"
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
>
|
||||
<div className="space-x-1 text-xs">
|
||||
<span className="text-custom-text-200">{monthDay.dayData.shortTitle[0]}</span>{" "}
|
||||
<span
|
||||
className={cn({
|
||||
"rounded-full bg-custom-primary-100 px-1 text-white": monthDay.today,
|
||||
})}
|
||||
>
|
||||
{monthDay.day}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full w-full flex divide-x divide-custom-border-100/50">
|
||||
{block?.children?.map((monthDay, index) => (
|
||||
<div
|
||||
key={`column-${rootIndex}-${index}`}
|
||||
className="h-full overflow-hidden"
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
>
|
||||
{["sat", "sun"].includes(monthDay?.dayData?.shortTitle) && (
|
||||
<div className="h-full bg-custom-background-90" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { FC } from "react";
|
||||
// context
|
||||
import { useChart } from "../hooks";
|
||||
import { useChart } from "../../hooks";
|
||||
|
||||
export const QuarterChartView: FC<any> = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { FC } from "react";
|
||||
// context
|
||||
import { useChart } from "../hooks";
|
||||
import { useChart } from "../../hooks";
|
||||
|
||||
export const WeekChartView: FC<any> = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { FC } from "react";
|
||||
// context
|
||||
import { useChart } from "../hooks";
|
||||
import { useChart } from "../../hooks";
|
||||
|
||||
export const YearChartView: FC<any> = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@@ -0,0 +1,5 @@
|
||||
export const BLOCK_HEIGHT = 44;
|
||||
|
||||
export const HEADER_HEIGHT = 60;
|
||||
|
||||
export const SIDEBAR_WIDTH = 360;
|
||||
@@ -24,6 +24,7 @@ const chartReducer = (state: ChartContextData, action: ChartContextActionPayload
|
||||
const initialView = "month";
|
||||
|
||||
export const ChartContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
// states;
|
||||
const [state, dispatch] = useState<ChartContextData>({
|
||||
currentView: initialView,
|
||||
currentViewData: currentViewDataWithView(initialView),
|
||||
@@ -31,23 +32,25 @@ export const ChartContextProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
allViews: allViewsWithData,
|
||||
activeBlock: null,
|
||||
});
|
||||
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
const handleDispatch = (action: ChartContextActionPayload): ChartContextData => {
|
||||
const newState = chartReducer(state, action);
|
||||
|
||||
dispatch(() => newState);
|
||||
|
||||
return newState;
|
||||
};
|
||||
|
||||
const updateScrollLeft = (scrollLeft: number) => {
|
||||
setScrollLeft(scrollLeft);
|
||||
};
|
||||
const updateScrollLeft = (scrollLeft: number) => setScrollLeft(scrollLeft);
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ ...state, scrollLeft, updateScrollLeft, dispatch: handleDispatch }}>
|
||||
<ChartContext.Provider
|
||||
value={{
|
||||
...state,
|
||||
scrollLeft,
|
||||
updateScrollLeft,
|
||||
dispatch: handleDispatch,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
// hooks
|
||||
import { useChart } from "../hooks";
|
||||
// types
|
||||
import { IGanttBlock } from "../types";
|
||||
import { IGanttBlock, useChart } from "components/gantt-chart";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// constants
|
||||
import { SIDEBAR_WIDTH } from "../constants";
|
||||
|
||||
type Props = {
|
||||
block: IGanttBlock;
|
||||
@@ -20,7 +22,7 @@ export const ChartDraggable: React.FC<Props> = (props) => {
|
||||
const [isLeftResizing, setIsLeftResizing] = useState(false);
|
||||
const [isRightResizing, setIsRightResizing] = useState(false);
|
||||
const [isMoving, setIsMoving] = useState(false);
|
||||
const [posFromLeft, setPosFromLeft] = useState<number | null>(null);
|
||||
const [isHidden, setIsHidden] = useState(true);
|
||||
// refs
|
||||
const resizableRef = useRef<HTMLDivElement>(null);
|
||||
// chart hook
|
||||
@@ -31,12 +33,10 @@ export const ChartDraggable: React.FC<Props> = (props) => {
|
||||
|
||||
let delWidth = 0;
|
||||
|
||||
const ganttContainer = document.querySelector("#gantt-container") as HTMLElement;
|
||||
const ganttSidebar = document.querySelector("#gantt-sidebar") as HTMLElement;
|
||||
const ganttContainer = document.querySelector("#gantt-container") as HTMLDivElement;
|
||||
const ganttSidebar = document.querySelector("#gantt-sidebar") as HTMLDivElement;
|
||||
|
||||
const scrollContainer = document.querySelector("#scroll-container") as HTMLElement;
|
||||
|
||||
if (!ganttContainer || !ganttSidebar || !scrollContainer) return 0;
|
||||
if (!ganttContainer || !ganttSidebar) return 0;
|
||||
|
||||
const posFromLeft = e.clientX;
|
||||
// manually scroll to left if reached the left end while dragging
|
||||
@@ -45,7 +45,7 @@ export const ChartDraggable: React.FC<Props> = (props) => {
|
||||
|
||||
delWidth = -5;
|
||||
|
||||
scrollContainer.scrollBy(delWidth, 0);
|
||||
ganttContainer.scrollBy(delWidth, 0);
|
||||
} else delWidth = e.movementX;
|
||||
|
||||
// manually scroll to right if reached the right end while dragging
|
||||
@@ -55,7 +55,7 @@ export const ChartDraggable: React.FC<Props> = (props) => {
|
||||
|
||||
delWidth = 5;
|
||||
|
||||
scrollContainer.scrollBy(delWidth, 0);
|
||||
ganttContainer.scrollBy(delWidth, 0);
|
||||
} else delWidth = e.movementX;
|
||||
|
||||
return delWidth;
|
||||
@@ -201,50 +201,61 @@ export const ChartDraggable: React.FC<Props> = (props) => {
|
||||
};
|
||||
// scroll to a hidden block
|
||||
const handleScrollToBlock = () => {
|
||||
const scrollContainer = document.querySelector("#scroll-container") as HTMLElement;
|
||||
|
||||
const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement;
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement;
|
||||
const resizableBlock = resizableRef.current;
|
||||
if (!resizableBlock || !intersectionRoot) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
setIsHidden(!entry.isIntersecting);
|
||||
});
|
||||
},
|
||||
{
|
||||
root: intersectionRoot,
|
||||
rootMargin: `0px 0px 0px -${SIDEBAR_WIDTH}px`,
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(resizableBlock);
|
||||
|
||||
return () => {
|
||||
observer.unobserve(resizableBlock);
|
||||
};
|
||||
}, [block.data.name]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* move to left side hidden block button */}
|
||||
{isBlockHiddenOnLeft && (
|
||||
<div
|
||||
className="fixed z-[1] ml-1 mt-1.5 grid h-8 w-8 cursor-pointer place-items-center rounded border border-custom-border-300 bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100"
|
||||
{/* move to the hidden block */}
|
||||
{isHidden && (
|
||||
<button
|
||||
type="button"
|
||||
className="sticky z-[1] grid h-8 w-8 translate-y-1.5 cursor-pointer place-items-center rounded border border-custom-border-300 bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100"
|
||||
style={{
|
||||
left: `${SIDEBAR_WIDTH + 4}px`,
|
||||
}}
|
||||
onClick={handleScrollToBlock}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
)}
|
||||
{/* move to right side hidden block button */}
|
||||
{isBlockHiddenOnRight && (
|
||||
<div
|
||||
className="fixed right-1 z-[1] mt-1.5 grid h-8 w-8 cursor-pointer place-items-center rounded border border-custom-border-300 bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100"
|
||||
onClick={handleScrollToBlock}
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<ArrowRight
|
||||
className={cn("h-3.5 w-3.5", {
|
||||
"rotate-180": isBlockHiddenOnLeft,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
id={`block-${block.id}`}
|
||||
ref={resizableRef}
|
||||
className="group relative inline-flex h-full cursor-pointer items-center font-medium transition-all"
|
||||
style={{
|
||||
@@ -259,17 +270,22 @@ export const ChartDraggable: React.FC<Props> = (props) => {
|
||||
onMouseDown={handleBlockLeftResize}
|
||||
onMouseEnter={() => setIsLeftResizing(true)}
|
||||
onMouseLeave={() => setIsLeftResizing(false)}
|
||||
className="absolute -left-2.5 top-1/2 z-[3] h-full w-6 -translate-y-1/2 cursor-col-resize rounded-md"
|
||||
className="absolute -left-2.5 top-1/2 -translate-y-1/2 z-[3] h-full w-6 cursor-col-resize rounded-md"
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-1/2 h-7 w-1 -translate-y-1/2 rounded-sm bg-custom-background-100 transition-all duration-300 ${
|
||||
isLeftResizing ? "-left-2.5" : "left-1"
|
||||
}`}
|
||||
className={cn(
|
||||
"absolute left-1 top-1/2 -translate-y-1/2 h-7 w-1 rounded-sm bg-custom-background-100 transition-all duration-300",
|
||||
{
|
||||
"-left-2.5": isLeftResizing,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={`relative z-[2] flex h-8 w-full items-center rounded ${isMoving ? "pointer-events-none" : ""}`}
|
||||
className={cn("relative z-[2] flex h-8 w-full items-center rounded", {
|
||||
"pointer-events-none": isMoving,
|
||||
})}
|
||||
onMouseDown={handleBlockMove}
|
||||
>
|
||||
{blockToRender(block.data)}
|
||||
@@ -281,12 +297,15 @@ export const ChartDraggable: React.FC<Props> = (props) => {
|
||||
onMouseDown={handleBlockRightResize}
|
||||
onMouseEnter={() => setIsRightResizing(true)}
|
||||
onMouseLeave={() => setIsRightResizing(false)}
|
||||
className="absolute -right-2.5 top-1/2 z-[2] h-full w-6 -translate-y-1/2 cursor-col-resize rounded-md"
|
||||
className="absolute -right-2.5 top-1/2 -translate-y-1/2 z-[2] h-full w-6 cursor-col-resize rounded-md"
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-1/2 h-7 w-1 -translate-y-1/2 rounded-sm bg-custom-background-100 transition-all duration-300 ${
|
||||
isRightResizing ? "-right-2.5" : "right-1"
|
||||
}`}
|
||||
className={cn(
|
||||
"absolute right-1 top-1/2 -translate-y-1/2 h-7 w-1 rounded-sm bg-custom-background-100 transition-all duration-300",
|
||||
{
|
||||
"-right-2.5": isRightResizing,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./blocks";
|
||||
export * from "./chart";
|
||||
export * from "./helpers";
|
||||
export * from "./hooks";
|
||||
export * from "./root";
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { FC } from "react";
|
||||
// components
|
||||
import { ChartViewRoot } from "./chart";
|
||||
import { ChartViewRoot, IBlockUpdateData, IGanttBlock } from "components/gantt-chart";
|
||||
// context
|
||||
import { ChartContextProvider } from "./contexts";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "./types";
|
||||
|
||||
type GanttChartRootProps = {
|
||||
border?: boolean;
|
||||
@@ -14,10 +12,12 @@ type GanttChartRootProps = {
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blockToRender: (data: any) => React.ReactNode;
|
||||
sidebarToRender: (props: any) => React.ReactNode;
|
||||
quickAdd?: React.JSX.Element | undefined;
|
||||
enableBlockLeftResize?: boolean;
|
||||
enableBlockRightResize?: boolean;
|
||||
enableBlockMove?: boolean;
|
||||
enableReorder?: boolean;
|
||||
enableAddBlock?: boolean;
|
||||
bottomSpacing?: boolean;
|
||||
showAllBlocks?: boolean;
|
||||
};
|
||||
@@ -31,12 +31,14 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
|
||||
blockUpdateHandler,
|
||||
sidebarToRender,
|
||||
blockToRender,
|
||||
enableBlockLeftResize = true,
|
||||
enableBlockRightResize = true,
|
||||
enableBlockMove = true,
|
||||
enableReorder = true,
|
||||
enableBlockLeftResize = false,
|
||||
enableBlockRightResize = false,
|
||||
enableBlockMove = false,
|
||||
enableReorder = false,
|
||||
enableAddBlock = false,
|
||||
bottomSpacing = false,
|
||||
showAllBlocks = false,
|
||||
quickAdd,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -53,8 +55,10 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableReorder={enableReorder}
|
||||
enableAddBlock={enableAddBlock}
|
||||
bottomSpacing={bottomSpacing}
|
||||
showAllBlocks={showAllBlocks}
|
||||
quickAdd={quickAdd}
|
||||
/>
|
||||
</ChartContextProvider>
|
||||
);
|
||||
|
||||
+17
-19
@@ -1,4 +1,3 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
// hooks
|
||||
@@ -9,8 +8,11 @@ import { Loader } from "@plane/ui";
|
||||
import { CycleGanttSidebarBlock } from "components/cycles";
|
||||
// helpers
|
||||
import { findTotalDaysInRange } from "helpers/date-time.helper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types";
|
||||
// constants
|
||||
import { BLOCK_HEIGHT } from "../constants";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
@@ -20,12 +22,8 @@ type Props = {
|
||||
};
|
||||
|
||||
export const CycleGanttSidebar: React.FC<Props> = (props) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { title, blockUpdateHandler, blocks, enableReorder } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { cycleId } = router.query;
|
||||
|
||||
const { blockUpdateHandler, blocks, enableReorder } = props;
|
||||
// chart hook
|
||||
const { activeBlock, dispatch } = useChart();
|
||||
|
||||
// update the active block on hover
|
||||
@@ -84,12 +82,7 @@ export const CycleGanttSidebar: React.FC<Props> = (props) => {
|
||||
<DragDropContext onDragEnd={handleOrderChange}>
|
||||
<Droppable droppableId="gantt-sidebar">
|
||||
{(droppableProvided) => (
|
||||
<div
|
||||
id={`gantt-sidebar-${cycleId}`}
|
||||
className="mt-3 max-h-full overflow-y-auto pl-2.5"
|
||||
ref={droppableProvided.innerRef}
|
||||
{...droppableProvided.droppableProps}
|
||||
>
|
||||
<div className="h-full" ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
|
||||
<>
|
||||
{blocks ? (
|
||||
blocks.map((block, index) => {
|
||||
@@ -104,7 +97,9 @@ export const CycleGanttSidebar: React.FC<Props> = (props) => {
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`h-11 ${snapshot.isDragging ? "rounded bg-custom-background-80" : ""}`}
|
||||
className={cn({
|
||||
"rounded bg-custom-background-80": snapshot.isDragging,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlock(block)}
|
||||
onMouseLeave={() => updateActiveBlock(null)}
|
||||
ref={provided.innerRef}
|
||||
@@ -112,9 +107,12 @@ export const CycleGanttSidebar: React.FC<Props> = (props) => {
|
||||
>
|
||||
<div
|
||||
id={`sidebar-block-${block.id}`}
|
||||
className={`group flex h-full w-full items-center gap-2 rounded-l px-2 pr-4 ${
|
||||
activeBlock?.id === block.id ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
|
||||
"bg-custom-background-80": activeBlock?.id === block.id,
|
||||
})}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{enableReorder && (
|
||||
<button
|
||||
@@ -128,9 +126,9 @@ export const CycleGanttSidebar: React.FC<Props> = (props) => {
|
||||
)}
|
||||
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
|
||||
<div className="flex-grow truncate">
|
||||
<CycleGanttSidebarBlock data={block.data} />
|
||||
<CycleGanttSidebarBlock cycleId={block.data.id} />
|
||||
</div>
|
||||
{duration !== undefined && (
|
||||
{duration && (
|
||||
<div className="flex-shrink-0 text-sm text-custom-text-200">
|
||||
{duration} day{duration > 1 ? "s" : ""}
|
||||
</div>
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./cycle-sidebar";
|
||||
export * from "./module-sidebar";
|
||||
export * from "./sidebar";
|
||||
export * from "./project-view-sidebar";
|
||||
export * from "./cycles";
|
||||
export * from "./issues";
|
||||
export * from "./modules";
|
||||
export * from "./project-views";
|
||||
export * from "./root";
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
// hooks
|
||||
import { useChart } from "components/gantt-chart/hooks";
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { IssueGanttSidebarBlock } from "components/issues";
|
||||
// helpers
|
||||
import { findTotalDaysInRange } from "helpers/date-time.helper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types";
|
||||
import { BLOCK_HEIGHT } from "../constants";
|
||||
|
||||
type Props = {
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blocks: IGanttBlock[] | null;
|
||||
enableReorder: boolean;
|
||||
showAllBlocks?: boolean;
|
||||
};
|
||||
|
||||
export const IssueGanttSidebar: React.FC<Props> = observer((props: Props) => {
|
||||
const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props;
|
||||
|
||||
const { activeBlock, dispatch } = useChart();
|
||||
const { peekIssue } = useIssueDetail();
|
||||
|
||||
// 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}>
|
||||
<Droppable droppableId="gantt-sidebar">
|
||||
{(droppableProvided) => (
|
||||
<div ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
|
||||
<>
|
||||
{blocks ? (
|
||||
blocks.map((block, index) => {
|
||||
const isBlockVisibleOnSidebar = block.start_date && block.target_date;
|
||||
|
||||
// hide the block if it doesn't have start and target dates and showAllBlocks is false
|
||||
if (!showAllBlocks && !isBlockVisibleOnSidebar) return;
|
||||
|
||||
const duration =
|
||||
!block.start_date || !block.target_date
|
||||
? null
|
||||
: findTotalDaysInRange(block.start_date, block.target_date);
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={`sidebar-block-${block.id}`}
|
||||
draggableId={`sidebar-block-${block.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={cn({
|
||||
"rounded bg-custom-background-80": snapshot.isDragging,
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
|
||||
peekIssue?.issueId === block.data.id,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlock(block)}
|
||||
onMouseLeave={() => updateActiveBlock(null)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<div
|
||||
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
|
||||
"bg-custom-background-80": activeBlock?.id === block.id,
|
||||
})}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{enableReorder && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
|
||||
<div className="flex-grow truncate">
|
||||
<IssueGanttSidebarBlock issueId={block.data.id} />
|
||||
</div>
|
||||
{duration && (
|
||||
<div className="flex-shrink-0 text-sm text-custom-text-200">
|
||||
<span>
|
||||
{duration} day{duration > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Loader className="space-y-3 pr-2">
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
</Loader>
|
||||
)}
|
||||
{droppableProvided.placeholder}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</>
|
||||
);
|
||||
});
|
||||
+16
-18
@@ -1,4 +1,3 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
// hooks
|
||||
@@ -9,8 +8,11 @@ import { Loader } from "@plane/ui";
|
||||
import { ModuleGanttSidebarBlock } from "components/modules";
|
||||
// helpers
|
||||
import { findTotalDaysInRange } from "helpers/date-time.helper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart";
|
||||
// constants
|
||||
import { BLOCK_HEIGHT } from "../constants";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
@@ -20,12 +22,8 @@ type Props = {
|
||||
};
|
||||
|
||||
export const ModuleGanttSidebar: React.FC<Props> = (props) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { title, blockUpdateHandler, blocks, enableReorder } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { cycleId } = router.query;
|
||||
|
||||
const { blockUpdateHandler, blocks, enableReorder } = props;
|
||||
// chart hook
|
||||
const { activeBlock, dispatch } = useChart();
|
||||
|
||||
// update the active block on hover
|
||||
@@ -84,12 +82,7 @@ export const ModuleGanttSidebar: React.FC<Props> = (props) => {
|
||||
<DragDropContext onDragEnd={handleOrderChange}>
|
||||
<Droppable droppableId="gantt-sidebar">
|
||||
{(droppableProvided) => (
|
||||
<div
|
||||
id={`gantt-sidebar-${cycleId}`}
|
||||
className="mt-3 max-h-full overflow-y-auto pl-2.5"
|
||||
ref={droppableProvided.innerRef}
|
||||
{...droppableProvided.droppableProps}
|
||||
>
|
||||
<div className="h-full" ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
|
||||
<>
|
||||
{blocks ? (
|
||||
blocks.map((block, index) => {
|
||||
@@ -104,7 +97,9 @@ export const ModuleGanttSidebar: React.FC<Props> = (props) => {
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`h-11 ${snapshot.isDragging ? "rounded bg-custom-background-80" : ""}`}
|
||||
className={cn({
|
||||
"rounded bg-custom-background-80": snapshot.isDragging,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlock(block)}
|
||||
onMouseLeave={() => updateActiveBlock(null)}
|
||||
ref={provided.innerRef}
|
||||
@@ -112,9 +107,12 @@ export const ModuleGanttSidebar: React.FC<Props> = (props) => {
|
||||
>
|
||||
<div
|
||||
id={`sidebar-block-${block.id}`}
|
||||
className={`group flex h-full w-full items-center gap-2 rounded-l px-2 pr-4 ${
|
||||
activeBlock?.id === block.id ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
|
||||
"bg-custom-background-80": activeBlock?.id === block.id,
|
||||
})}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{enableReorder && (
|
||||
<button
|
||||
@@ -128,7 +126,7 @@ export const ModuleGanttSidebar: React.FC<Props> = (props) => {
|
||||
)}
|
||||
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
|
||||
<div className="flex-grow truncate">
|
||||
<ModuleGanttSidebarBlock data={block.data} />
|
||||
<ModuleGanttSidebarBlock moduleId={block.data.id} />
|
||||
</div>
|
||||
{duration !== undefined && (
|
||||
<div className="flex-shrink-0 text-sm text-custom-text-200">
|
||||
+9
-10
@@ -1,4 +1,3 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
// hooks
|
||||
@@ -11,6 +10,8 @@ import { IssueGanttSidebarBlock } from "components/issues";
|
||||
import { findTotalDaysInRange } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types";
|
||||
// constants
|
||||
import { BLOCK_HEIGHT } from "../constants";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
@@ -21,12 +22,8 @@ type Props = {
|
||||
};
|
||||
|
||||
export const ProjectViewGanttSidebar: React.FC<Props> = (props) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { title, blockUpdateHandler, blocks, enableReorder } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { cycleId } = router.query;
|
||||
|
||||
const { blockUpdateHandler, blocks, enableReorder } = props;
|
||||
// chart hook
|
||||
const { activeBlock, dispatch } = useChart();
|
||||
|
||||
// update the active block on hover
|
||||
@@ -86,7 +83,6 @@ export const ProjectViewGanttSidebar: React.FC<Props> = (props) => {
|
||||
<Droppable droppableId="gantt-sidebar">
|
||||
{(droppableProvided) => (
|
||||
<div
|
||||
id={`gantt-sidebar-${cycleId}`}
|
||||
className="mt-3 max-h-full overflow-y-auto pl-2.5"
|
||||
ref={droppableProvided.innerRef}
|
||||
{...droppableProvided.droppableProps}
|
||||
@@ -105,7 +101,10 @@ export const ProjectViewGanttSidebar: React.FC<Props> = (props) => {
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`h-11 ${snapshot.isDragging ? "rounded bg-custom-background-80" : ""}`}
|
||||
className={`${snapshot.isDragging ? "rounded bg-custom-background-80" : ""}`}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
onMouseEnter={() => updateActiveBlock(block)}
|
||||
onMouseLeave={() => updateActiveBlock(null)}
|
||||
ref={provided.innerRef}
|
||||
@@ -129,7 +128,7 @@ export const ProjectViewGanttSidebar: React.FC<Props> = (props) => {
|
||||
)}
|
||||
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
|
||||
<div className="flex-grow truncate">
|
||||
<IssueGanttSidebarBlock data={block.data} />
|
||||
<IssueGanttSidebarBlock issueId={block.data.id} />
|
||||
</div>
|
||||
{duration !== undefined && (
|
||||
<div className="flex-shrink-0 text-sm text-custom-text-200">
|
||||
@@ -0,0 +1,43 @@
|
||||
// components
|
||||
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart";
|
||||
// constants
|
||||
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "../constants";
|
||||
|
||||
type Props = {
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
enableReorder: boolean;
|
||||
sidebarToRender: (props: any) => React.ReactNode;
|
||||
title: string;
|
||||
quickAdd?: React.JSX.Element | undefined;
|
||||
};
|
||||
|
||||
export const GanttChartSidebar: React.FC<Props> = (props) => {
|
||||
const { blocks, blockUpdateHandler, enableReorder, sidebarToRender, title, quickAdd } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
// DO NOT REMOVE THE ID
|
||||
id="gantt-sidebar"
|
||||
className="sticky left-0 z-10 min-h-full h-max flex-shrink-0 border-r-[0.5px] border-custom-border-200 bg-custom-background-100"
|
||||
style={{
|
||||
width: `${SIDEBAR_WIDTH}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="box-border flex-shrink-0 flex items-end justify-between gap-2 border-b-[0.5px] border-custom-border-200 pb-2 pl-8 pr-4 text-sm font-medium text-custom-text-300 sticky top-0 z-10 bg-custom-background-100"
|
||||
style={{
|
||||
height: `${HEADER_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<h6>{title}</h6>
|
||||
<h6>Duration</h6>
|
||||
</div>
|
||||
|
||||
<div className="min-h-full h-max bg-custom-background-100 overflow-x-hidden overflow-y-auto">
|
||||
{sidebarToRender && sidebarToRender({ title, blockUpdateHandler, blocks, enableReorder })}
|
||||
</div>
|
||||
{quickAdd ? quickAdd : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user