mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
dev: promote the develop to stage-release (#399)
* chore: new link endpoints
* chore: added created by info for link
* chore: cannot have empty state group
* feat: filtering for cycle and module issue and updated grouper function for grouping in modules and cycles (#342)
* docs: github integration (#346)
* fix: add pagination for github repositories endpoint (#345)
* fix: remove bot accounts from list api (#344)
* refactor: create new endpoints for date checking getting current upcoming and past cycles (#343)
* refactor: create new endpoints for date checking getting current upcoming and past cycles
* refactor: rename endpoint to match consistency
* fix: remove project slug (#340)
* refactor: update links to different endpoints (#338)
* chore: cycle validation services and constants added
* style: kanban board
* chore: cycle type and services updated
* chore: completed cycle dynamic importing and refactor
* feat: cycle modal date validation
* fix: build fix
* style: redesigned sidebar, added new icons and spacing changes
* style: changed app header color to white
* feat: cover image selector for project create
* style/projects_page
* style: added dragging state design
* fix: cycle form date
* chore: draft cycle services and types
* feat: draft tab and cycle sidebar update
* style: projects list page
* fix: image aspect ratio
* style: assignee drop down label
* style: new primary button design
* style: assignee dropdown
* style: assignee dropdown stlye fix
* style: state dropdown redesign
* style: dropdown ui consisteny
* style: priority dropdown redesign
* style: label dropdown redesign
* style: issue dropdown re-order
* style: state Icon
* style: date dropdown redesign
* fix: dropdown issue label
* style: transsition
* style: color fixed
* chore: labels list file and function rename
* style: redesigned create project modal
style: changed image picker to pop-over instread of modal
* fix: upload button on workspace settings page not working, UX of workspace settings image upload
* feat: date range status function added
* style: project settings pages
* fix: merge conflicts
* fix: mutation fix and date range helper fn added
* style: workspace settings pages
* style: dropdowns, feat: favorite projects in sidebar
* feat: global component for combobox with new design
* feat: custom context menu for issues in kanban board
* refactor: global context menu component
* chore: updated context menu component
* chore: updated sidebar selects
* style: kanban horizontal scrollbar added (#372)
* style: new cycle list (#374)
* feat: short date helper function
* feat: linear progress indicator added
* style: new cyce list and cycle card design
* feat: short date function improve
* feat: linear progress indicator improvement
* style: cycle card and progress indicator
* fix: helper date function and progress indicator fix
* fix: build error
---------
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
* chore: updated project favorites endpoints (#375)
* feat: favorite cycle and style: style improvements (#376)
* style: consistent btn
* style: caret direction for disclosure
* fix: progress tooltip value rounded
* chore: favorite cycle serivces
* chore: favorite cycle type and constant
* feat: favorite cycle feat added
* refactor: favorite services and type
* fix: build fix
* refactor: sidebar projects menu (#377)
* feat: add endpoint for draft cycles and add validation for creating draft cycles (#355)
* feat: add endpoint for draft cycles and add validation for creating draft cycles
* fix: key error in cycle create endpoint
* feat: delete file assets from storage (#373)
* chore: rename past cycle to completed cycle (#347)
* fix: workspace member listing endpoint (#348)
* fix: module issue viewset typo (#349)
* feat: add project to favourites (#352)
* feat: add project to favourites
* feat: add project is_favourite attribute to list endpoints
* refactor: updated destroy endpoint to send project_id
* chore: nomenclature update
* feat: add cover image to project (#353)
* fix: cycle date filtering for current and upcoming cycle (#357)
* fix: update filtering for completed cycles
* fix: filter updated for upcoming cycles
* fix: cycle and module issue filtering (#363)
* feat: already exisiting url validation (#368)
* feat: cycle favourites for user (#369)
* feat: cycle favourites for user
* chore: update nomenclature
* chore: update on nomenclature
* feat: add favorites for completed and current cycle endpoints
* feat: module favourites for user (#370)
* feat: added floating toolbar on text selection (#378)
style: re-designed create-issue modal
* dev: migrations added for ProjectFavorite, ModuleFavorite, CycleFavorite including a bunch of other attribs
* chore: cycles loading, fix: cycles favorite mutation (#379)
* style: cycle sidebar, fix: cycle card bug fix (#383)
* style: new cycle sidebar
* style: other information section
* style: progress bar bg fix
* fix: cycle card bug fix
* style: progress chart
* style: chart tooltip
* style : module sidebar (#385)
* style: new cycle sidebar
* style: other information section
* style: progress bar bg fix
* fix: cycle card bug fix
* style: progress chart
* style: chart tooltip
* style: module link tab added in sidebar stats
* style: lead and member select
* fix: text selection moving when typing in between (#384)
* feat: added floating toolbar on text selection (#386)
style: re-designed create-issue modal
* style :module list (#387)
* chore: module favorite type and services
* style: module list
* style: module list and card
* fix: link fix
* style: truncate (#388)
* style: truncate
* fix: truncate text added to cycle and module card
* fix: custom menu link item (#390)
* fix: ui fixes (#392)
* fix: ui fixes
* chore: kanban issue title length
* style: ui fix (#393)
* style: truncate
* fix: truncate text added to cycle and module card
* fix: progress percentage
* feat: cycle card tooltip
* fix: sidebar fix
* fix: edit module mutation error (#394)
* fix: issue details mutation (#389)
* fix: ui improvement (#395)
* fix: current cycle date updation
* fix: sidebar overflow fix , date helper fn added
* chore: update module dropdowns (#396)
* fix: project member filter for bot accounts (#391)
* fix: make api token only view once (#382)
* dev: add back migration for project cover images (#381)
* fix: rename db host name for docker setup (#380)
* dev: promote to staging (#397)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia1001@gmail.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Narayana <narayana.vadapalli1996@gmail.com>
* Revert "dev: promote to staging (#397)" (#398)
This reverts commit f7405ba1d6
.
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: sphynxux <122926002+sphynxux@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@caravel.tech>
Co-authored-by: Dakshesh Jain <dakshesh.jain14@gmail.com>
Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia1001@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Narayana <narayana.vadapalli1996@gmail.com>
This commit is contained in:
parent
397a3cec4f
commit
c9252c9713
@ -1,7 +1,7 @@
|
||||
SECRET_KEY="<-- django secret -->"
|
||||
DJANGO_SETTINGS_MODULE="plane.settings.production"
|
||||
# Database
|
||||
DATABASE_URL=postgres://plane:plane@plane-db-1:5432/plane
|
||||
DATABASE_URL=postgres://plane:plane@db:5432/plane
|
||||
# Cache
|
||||
REDIS_URL=redis://redis:6379/
|
||||
# SMPT
|
||||
|
@ -3,7 +3,7 @@ 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
|
||||
from plane.db.models import Issue, IssueComment, User, Project
|
||||
|
||||
|
||||
# Update description and description html values for old descriptions
|
||||
@ -96,3 +96,41 @@ def updated_issue_sort_order():
|
||||
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")
|
||||
|
@ -17,11 +17,12 @@ from .project import (
|
||||
ProjectMemberSerializer,
|
||||
ProjectMemberInviteSerializer,
|
||||
ProjectIdentifierSerializer,
|
||||
ProjectFavoriteSerializer,
|
||||
)
|
||||
from .state import StateSerializer
|
||||
from .shortcut import ShortCutSerializer
|
||||
from .view import ViewSerializer
|
||||
from .cycle import CycleSerializer, CycleIssueSerializer
|
||||
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer
|
||||
from .asset import FileAssetSerializer
|
||||
from .issue import (
|
||||
IssueCreateSerializer,
|
||||
@ -36,9 +37,16 @@ from .issue import (
|
||||
IssueSerializer,
|
||||
IssueFlatSerializer,
|
||||
IssueStateSerializer,
|
||||
IssueLinkSerializer,
|
||||
)
|
||||
|
||||
from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer
|
||||
from .module import (
|
||||
ModuleWriteSerializer,
|
||||
ModuleSerializer,
|
||||
ModuleIssueSerializer,
|
||||
ModuleLinkSerializer,
|
||||
ModuleFavoriteSerializer,
|
||||
)
|
||||
|
||||
from .api_token import APITokenSerializer
|
||||
|
||||
|
@ -5,4 +5,10 @@ from plane.db.models import APIToken
|
||||
class APITokenSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = APIToken
|
||||
fields = "__all__"
|
||||
fields = [
|
||||
"label",
|
||||
"user",
|
||||
"user_type",
|
||||
"workspace",
|
||||
"created_at",
|
||||
]
|
||||
|
@ -5,12 +5,12 @@ from rest_framework import serializers
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .issue import IssueStateSerializer
|
||||
from plane.db.models import Cycle, CycleIssue
|
||||
from plane.db.models import Cycle, CycleIssue, CycleFavorite
|
||||
|
||||
|
||||
class CycleSerializer(BaseSerializer):
|
||||
|
||||
owned_by = UserLiteSerializer(read_only=True)
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Cycle
|
||||
@ -23,7 +23,6 @@ class CycleSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class CycleIssueSerializer(BaseSerializer):
|
||||
|
||||
issue_detail = IssueStateSerializer(read_only=True, source="issue")
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
@ -35,3 +34,16 @@ class CycleIssueSerializer(BaseSerializer):
|
||||
"project",
|
||||
"cycle",
|
||||
]
|
||||
|
||||
|
||||
class CycleFavoriteSerializer(BaseSerializer):
|
||||
cycle_detail = CycleSerializer(source="cycle", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CycleFavorite
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"user",
|
||||
]
|
||||
|
@ -28,11 +28,6 @@ from plane.db.models import (
|
||||
)
|
||||
|
||||
|
||||
class IssueLinkCreateSerializer(serializers.Serializer):
|
||||
url = serializers.CharField(required=True)
|
||||
title = serializers.CharField(required=False)
|
||||
|
||||
|
||||
class IssueFlatSerializer(BaseSerializer):
|
||||
## Contain only flat fields
|
||||
|
||||
@ -82,11 +77,6 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
links_list = serializers.ListField(
|
||||
child=IssueLinkCreateSerializer(),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
@ -105,7 +95,6 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
assignees = validated_data.pop("assignees_list", None)
|
||||
labels = validated_data.pop("labels_list", None)
|
||||
blocks = validated_data.pop("blocks_list", None)
|
||||
links = validated_data.pop("links_list", None)
|
||||
|
||||
project = self.context["project"]
|
||||
issue = Issue.objects.create(**validated_data, project=project)
|
||||
@ -174,24 +163,6 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
if links is not None:
|
||||
IssueLink.objects.bulk_create(
|
||||
[
|
||||
IssueLink(
|
||||
issue=issue,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
created_by=issue.created_by,
|
||||
updated_by=issue.updated_by,
|
||||
title=link.get("title", None),
|
||||
url=link.get("url", None),
|
||||
)
|
||||
for link in links
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
return issue
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@ -199,7 +170,6 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
assignees = validated_data.pop("assignees_list", None)
|
||||
labels = validated_data.pop("labels_list", None)
|
||||
blocks = validated_data.pop("blocks_list", None)
|
||||
links = validated_data.pop("links_list", None)
|
||||
|
||||
if blockers is not None:
|
||||
IssueBlocker.objects.filter(block=instance).delete()
|
||||
@ -269,25 +239,6 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
if links is not None:
|
||||
IssueLink.objects.filter(issue=instance).delete()
|
||||
IssueLink.objects.bulk_create(
|
||||
[
|
||||
IssueLink(
|
||||
issue=instance,
|
||||
project=instance.project,
|
||||
workspace=instance.project.workspace,
|
||||
created_by=instance.created_by,
|
||||
updated_by=instance.updated_by,
|
||||
title=link.get("title", None),
|
||||
url=link.get("url", None),
|
||||
)
|
||||
for link in links
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
@ -456,6 +407,25 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueLink
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"issue",
|
||||
]
|
||||
|
||||
# Validation if url already exists
|
||||
def create(self, validated_data):
|
||||
if IssueLink.objects.filter(
|
||||
url=validated_data.get("url"), issue_id=validated_data.get("issue_id")
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
return IssueLink.objects.create(**validated_data)
|
||||
|
||||
|
||||
# Issue Serializer with state details
|
||||
|
@ -7,27 +7,15 @@ from .user import UserLiteSerializer
|
||||
from .project import ProjectSerializer
|
||||
from .issue import IssueStateSerializer
|
||||
|
||||
from plane.db.models import User, Module, ModuleMember, ModuleIssue, ModuleLink
|
||||
|
||||
|
||||
class LinkCreateSerializer(serializers.Serializer):
|
||||
|
||||
url = serializers.CharField(required=True)
|
||||
title = serializers.CharField(required=False)
|
||||
from plane.db.models import User, Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite
|
||||
|
||||
|
||||
class ModuleWriteSerializer(BaseSerializer):
|
||||
|
||||
members_list = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
links_list = serializers.ListField(
|
||||
child=LinkCreateSerializer(),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
@ -42,9 +30,7 @@ class ModuleWriteSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
|
||||
members = validated_data.pop("members_list", None)
|
||||
links = validated_data.pop("links_list", None)
|
||||
|
||||
project = self.context["project"]
|
||||
|
||||
@ -67,30 +53,10 @@ class ModuleWriteSerializer(BaseSerializer):
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
if links is not None:
|
||||
ModuleLink.objects.bulk_create(
|
||||
[
|
||||
ModuleLink(
|
||||
module=module,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
created_by=module.created_by,
|
||||
updated_by=module.updated_by,
|
||||
title=link.get("title", None),
|
||||
url=link.get("url", None),
|
||||
)
|
||||
for link in links
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
return module
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
|
||||
members = validated_data.pop("members_list", None)
|
||||
links = validated_data.pop("links_list", None)
|
||||
|
||||
if members is not None:
|
||||
ModuleMember.objects.filter(module=instance).delete()
|
||||
@ -110,25 +76,6 @@ class ModuleWriteSerializer(BaseSerializer):
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
if links is not None:
|
||||
ModuleLink.objects.filter(module=instance).delete()
|
||||
ModuleLink.objects.bulk_create(
|
||||
[
|
||||
ModuleLink(
|
||||
module=instance,
|
||||
project=instance.project,
|
||||
workspace=instance.project.workspace,
|
||||
created_by=instance.created_by,
|
||||
updated_by=instance.updated_by,
|
||||
title=link.get("title", None),
|
||||
url=link.get("url", None),
|
||||
)
|
||||
for link in links
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
@ -147,7 +94,6 @@ class ModuleFlatSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class ModuleIssueSerializer(BaseSerializer):
|
||||
|
||||
module_detail = ModuleFlatSerializer(read_only=True, source="module")
|
||||
issue_detail = IssueStateSerializer(read_only=True, source="issue")
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
@ -167,7 +113,6 @@ class ModuleIssueSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class ModuleLinkSerializer(BaseSerializer):
|
||||
|
||||
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
||||
|
||||
class Meta:
|
||||
@ -180,16 +125,17 @@ class ModuleLinkSerializer(BaseSerializer):
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"module",
|
||||
]
|
||||
|
||||
|
||||
class ModuleSerializer(BaseSerializer):
|
||||
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
lead_detail = UserLiteSerializer(read_only=True, source="lead")
|
||||
members_detail = UserLiteSerializer(read_only=True, many=True, source="members")
|
||||
issue_module = ModuleIssueSerializer(read_only=True, many=True)
|
||||
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
@ -202,3 +148,15 @@ class ModuleSerializer(BaseSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
class ModuleFavoriteSerializer(BaseSerializer):
|
||||
module_detail = ModuleFlatSerializer(source="module", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ModuleFavorite
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"user",
|
||||
]
|
||||
|
@ -13,6 +13,7 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
ProjectMemberInvite,
|
||||
ProjectIdentifier,
|
||||
ProjectFavorite,
|
||||
)
|
||||
|
||||
|
||||
@ -44,7 +45,6 @@ class ProjectSerializer(BaseSerializer):
|
||||
return project
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
|
||||
identifier = validated_data.get("identifier", "").strip().upper()
|
||||
|
||||
# If identifier is not passed update the project and return
|
||||
@ -73,10 +73,10 @@ class ProjectSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class ProjectDetailSerializer(BaseSerializer):
|
||||
|
||||
workspace = WorkSpaceSerializer(read_only=True)
|
||||
default_assignee = UserLiteSerializer(read_only=True)
|
||||
project_lead = UserLiteSerializer(read_only=True)
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
@ -84,7 +84,6 @@ class ProjectDetailSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class ProjectMemberSerializer(BaseSerializer):
|
||||
|
||||
workspace = WorkSpaceSerializer(read_only=True)
|
||||
project = ProjectSerializer(read_only=True)
|
||||
member = UserLiteSerializer(read_only=True)
|
||||
@ -95,7 +94,6 @@ class ProjectMemberSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class ProjectMemberInviteSerializer(BaseSerializer):
|
||||
|
||||
project = ProjectSerializer(read_only=True)
|
||||
workspace = WorkSpaceSerializer(read_only=True)
|
||||
|
||||
@ -108,3 +106,15 @@ class ProjectIdentifierSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = ProjectIdentifier
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class ProjectFavoriteSerializer(BaseSerializer):
|
||||
project_detail = ProjectSerializer(source="project", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProjectFavorite
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"user",
|
||||
]
|
||||
|
@ -52,6 +52,7 @@ from plane.api.views import (
|
||||
ProjectJoinEndpoint,
|
||||
UserProjectInvitationsViewset,
|
||||
ProjectIdentifierEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
## End Projects
|
||||
# Issues
|
||||
IssueViewSet,
|
||||
@ -65,6 +66,8 @@ from plane.api.views import (
|
||||
IssuePropertyViewSet,
|
||||
LabelViewSet,
|
||||
SubIssuesEndpoint,
|
||||
IssueLinkViewSet,
|
||||
ModuleLinkViewSet,
|
||||
## End Issues
|
||||
# States
|
||||
StateViewSet,
|
||||
@ -78,10 +81,16 @@ from plane.api.views import (
|
||||
# Cycles
|
||||
CycleViewSet,
|
||||
CycleIssueViewSet,
|
||||
CycleDateCheckEndpoint,
|
||||
CurrentUpcomingCyclesEndpoint,
|
||||
CompletedCyclesEndpoint,
|
||||
CycleFavoriteViewSet,
|
||||
DraftCyclesEndpoint,
|
||||
## End Cycles
|
||||
# Modules
|
||||
ModuleViewSet,
|
||||
ModuleIssueViewSet,
|
||||
ModuleFavoriteViewSet,
|
||||
## End Modules
|
||||
# Api Tokens
|
||||
ApiTokenEndpoint,
|
||||
@ -372,6 +381,25 @@ urlpatterns = [
|
||||
ProjectMemberUserEndpoint.as_view(),
|
||||
name="project-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/user-favorite-projects/",
|
||||
ProjectFavoritesViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/user-favorite-projects/<uuid:project_id>/",
|
||||
ProjectFavoritesViewSet.as_view(
|
||||
{
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="project",
|
||||
),
|
||||
# End Projects
|
||||
# States
|
||||
path(
|
||||
@ -490,6 +518,45 @@ urlpatterns = [
|
||||
),
|
||||
name="project-cycle",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/date-check/",
|
||||
CycleDateCheckEndpoint.as_view(),
|
||||
name="project-cycle",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/current-upcoming-cycles/",
|
||||
CurrentUpcomingCyclesEndpoint.as_view(),
|
||||
name="project-cycle-upcoming",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/completed-cycles/",
|
||||
CompletedCyclesEndpoint.as_view(),
|
||||
name="project-cycle-completed",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/draft-cycles/",
|
||||
DraftCyclesEndpoint.as_view(),
|
||||
name="project-cycle-draft",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-cycles/",
|
||||
CycleFavoriteViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="user-favorite-cycle",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-cycles/<uuid:cycle_id>/",
|
||||
CycleFavoriteViewSet.as_view(
|
||||
{
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="user-favorite-cycle",
|
||||
),
|
||||
## End Cycles
|
||||
# Issue
|
||||
path(
|
||||
@ -555,6 +622,28 @@ urlpatterns = [
|
||||
SubIssuesEndpoint.as_view(),
|
||||
name="sub-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-links/",
|
||||
IssueLinkViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-issue-links",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-links/<uuid:pk>/",
|
||||
IssueLinkViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="project-issue-links",
|
||||
),
|
||||
## End Issues
|
||||
## Issue Activity
|
||||
path(
|
||||
@ -641,6 +730,11 @@ urlpatterns = [
|
||||
FileAssetEndpoint.as_view(),
|
||||
name="File Assets",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/file-assets/<uuid:pk>/",
|
||||
FileAssetEndpoint.as_view(),
|
||||
name="File Assets",
|
||||
),
|
||||
## End File Assets
|
||||
## Modules
|
||||
path(
|
||||
@ -687,6 +781,47 @@ urlpatterns = [
|
||||
),
|
||||
name="project-module-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-links/",
|
||||
ModuleLinkViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-issue-module-links",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-links/<uuid:pk>/",
|
||||
ModuleLinkViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="project-issue-module-links",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-modules/",
|
||||
ModuleFavoriteViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="user-favorite-module",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-modules/<uuid:module_id>/",
|
||||
ModuleFavoriteViewSet.as_view(
|
||||
{
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="user-favorite-module",
|
||||
),
|
||||
## End Modules
|
||||
# API Tokens
|
||||
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
|
||||
|
@ -11,6 +11,7 @@ from .project import (
|
||||
ProjectJoinEndpoint,
|
||||
ProjectUserViewsEndpoint,
|
||||
ProjectMemberUserEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
)
|
||||
from .people import (
|
||||
UserEndpoint,
|
||||
@ -39,7 +40,15 @@ from .workspace import (
|
||||
from .state import StateViewSet
|
||||
from .shortcut import ShortCutViewSet
|
||||
from .view import ViewViewSet
|
||||
from .cycle import CycleViewSet, CycleIssueViewSet
|
||||
from .cycle import (
|
||||
CycleViewSet,
|
||||
CycleIssueViewSet,
|
||||
CycleDateCheckEndpoint,
|
||||
CurrentUpcomingCyclesEndpoint,
|
||||
CompletedCyclesEndpoint,
|
||||
CycleFavoriteViewSet,
|
||||
DraftCyclesEndpoint,
|
||||
)
|
||||
from .asset import FileAssetEndpoint
|
||||
from .issue import (
|
||||
IssueViewSet,
|
||||
@ -52,6 +61,7 @@ from .issue import (
|
||||
BulkDeleteIssuesEndpoint,
|
||||
UserWorkSpaceIssues,
|
||||
SubIssuesEndpoint,
|
||||
IssueLinkViewSet,
|
||||
)
|
||||
|
||||
from .auth_extended import (
|
||||
@ -70,7 +80,12 @@ from .authentication import (
|
||||
MagicSignInGenerateEndpoint,
|
||||
)
|
||||
|
||||
from .module import ModuleViewSet, ModuleIssueViewSet
|
||||
from .module import (
|
||||
ModuleViewSet,
|
||||
ModuleIssueViewSet,
|
||||
ModuleLinkViewSet,
|
||||
ModuleFavoriteViewSet,
|
||||
)
|
||||
|
||||
from .api_token import ApiTokenEndpoint
|
||||
|
||||
|
@ -28,7 +28,11 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
serializer = APITokenSerializer(api_token)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
# Token will be only vissible while creating
|
||||
return Response(
|
||||
{"api_token": serializer.data, "token": api_token.token},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
@ -11,7 +11,6 @@ from plane.api.serializers import FileAssetSerializer
|
||||
|
||||
|
||||
class FileAssetEndpoint(BaseAPIView):
|
||||
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
|
||||
"""
|
||||
@ -27,7 +26,6 @@ class FileAssetEndpoint(BaseAPIView):
|
||||
try:
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
|
||||
if request.user.last_workspace_id is None:
|
||||
return Response(
|
||||
{"error": "Workspace id is required"},
|
||||
@ -43,3 +41,22 @@ class FileAssetEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, pk):
|
||||
try:
|
||||
file_asset = FileAsset.objects.get(pk=pk, workspace__slug=slug)
|
||||
# Delete the file from storage
|
||||
file_asset.asset.delete(save=False)
|
||||
# Delete the file object
|
||||
file_asset.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except FileAsset.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "File Asset doesn't exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
@ -2,8 +2,10 @@
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db.models import OuterRef, Func, F
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef
|
||||
from django.core import serializers
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
@ -11,11 +13,16 @@ from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet
|
||||
from plane.api.serializers import CycleSerializer, CycleIssueSerializer
|
||||
from . import BaseViewSet, BaseAPIView
|
||||
from plane.api.serializers import (
|
||||
CycleSerializer,
|
||||
CycleIssueSerializer,
|
||||
CycleFavoriteSerializer,
|
||||
)
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Cycle, CycleIssue, Issue
|
||||
from plane.db.models import Cycle, CycleIssue, Issue, CycleFavorite
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
|
||||
|
||||
class CycleViewSet(BaseViewSet):
|
||||
@ -43,6 +50,54 @@ class CycleViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
cycles = self.get_queryset().annotate(is_favorite=Exists(subquery))
|
||||
return Response(CycleSerializer(cycles, many=True).data)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
if (
|
||||
request.data.get("start_date", None) is None
|
||||
and request.data.get("end_date", None) is None
|
||||
) or (
|
||||
request.data.get("start_date", None) is not None
|
||||
and request.data.get("end_date", None) is not None
|
||||
):
|
||||
serializer = CycleSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id,
|
||||
owned_by=request.user,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "Both start date and end date are either required or are to be null"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CycleIssueViewSet(BaseViewSet):
|
||||
serializer_class = CycleIssueSerializer
|
||||
@ -52,6 +107,11 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
"issue__labels__id",
|
||||
"issue__assignees__id",
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
@ -80,6 +140,31 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id, cycle_id):
|
||||
try:
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
queryset = self.get_queryset().order_by(f"issue__{order_by}")
|
||||
group_by = request.GET.get("group_by", False)
|
||||
|
||||
cycle_issues = CycleIssueSerializer(queryset, many=True).data
|
||||
|
||||
if group_by:
|
||||
return Response(
|
||||
group_results(cycle_issues, f"issue_detail.{group_by}"),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
cycle_issues,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id, cycle_id):
|
||||
try:
|
||||
issues = request.data.get("issues", [])
|
||||
@ -175,3 +260,188 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CycleDateCheckEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, project_id):
|
||||
try:
|
||||
start_date = request.data.get("start_date")
|
||||
end_date = request.data.get("end_date")
|
||||
|
||||
cycles = Cycle.objects.filter(
|
||||
Q(start_date__lte=start_date, end_date__gte=start_date)
|
||||
| Q(start_date__gte=end_date, end_date__lte=end_date),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
if cycles.exists():
|
||||
return Response(
|
||||
{
|
||||
"error": "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
|
||||
"cycles": CycleSerializer(cycles, many=True).data,
|
||||
"status": False,
|
||||
}
|
||||
)
|
||||
else:
|
||||
return Response({"status": True}, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
current_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
start_date__lte=timezone.now(),
|
||||
end_date__gte=timezone.now(),
|
||||
).annotate(is_favorite=Exists(subquery))
|
||||
|
||||
upcoming_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
start_date__gt=timezone.now(),
|
||||
).annotate(is_favorite=Exists(subquery))
|
||||
|
||||
return Response(
|
||||
{
|
||||
"current_cycle": CycleSerializer(current_cycle, many=True).data,
|
||||
"upcoming_cycle": CycleSerializer(upcoming_cycle, many=True).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CompletedCyclesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
completed_cycles = Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
end_date__lt=timezone.now(),
|
||||
).annotate(is_favorite=Exists(subquery))
|
||||
|
||||
return Response(
|
||||
{
|
||||
"completed_cycles": CycleSerializer(
|
||||
completed_cycles, many=True
|
||||
).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class DraftCyclesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
draft_cycles = Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
end_date=None,
|
||||
start_date=None,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"draft_cycles": CycleSerializer(draft_cycles, many=True).data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CycleFavoriteViewSet(BaseViewSet):
|
||||
serializer_class = CycleFavoriteSerializer
|
||||
model = CycleFavorite
|
||||
|
||||
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("cycle", "cycle__owned_by")
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
serializer = CycleFavoriteSerializer(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)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"error": "The cycle is already added to favorites"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
else:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, cycle_id):
|
||||
try:
|
||||
cycle_favorite = CycleFavorite.objects.get(
|
||||
project=project_id,
|
||||
user=request.user,
|
||||
workspace__slug=slug,
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
cycle_favorite.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except CycleFavorite.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Cycle is not in favorites"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
@ -25,11 +25,15 @@ from plane.utils.integrations.github import get_github_repos
|
||||
class GithubRepositoriesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, workspace_integration_id):
|
||||
try:
|
||||
page = request.GET.get("page", 1)
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
workspace__slug=slug, pk=workspace_integration_id
|
||||
)
|
||||
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
|
||||
repositories_url = workspace_integration.metadata["repositories_url"]
|
||||
repositories_url = (
|
||||
workspace_integration.metadata["repositories_url"]
|
||||
+ f"?per_page=100&page={page}"
|
||||
)
|
||||
repositories = get_github_repos(access_tokens_url, repositories_url)
|
||||
return Response(repositories, status=status.HTTP_200_OK)
|
||||
except WorkspaceIntegration.DoesNotExist:
|
||||
|
@ -23,6 +23,7 @@ from plane.api.serializers import (
|
||||
IssueSerializer,
|
||||
LabelSerializer,
|
||||
IssueFlatSerializer,
|
||||
IssueLinkSerializer,
|
||||
)
|
||||
from plane.api.permissions import (
|
||||
ProjectEntityPermission,
|
||||
@ -185,7 +186,7 @@ class IssueViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
issues = IssueSerializer(issue_queryset, many=True).data
|
||||
|
||||
|
||||
## Grouping the results
|
||||
group_by = request.GET.get("group_by", False)
|
||||
if group_by:
|
||||
@ -690,3 +691,29 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class IssueLinkViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
model = IssueLink
|
||||
serializer_class = IssueLinkSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
issue_id=self.kwargs.get("issue_id"),
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.distinct()
|
||||
)
|
||||
|
@ -3,7 +3,7 @@ import json
|
||||
|
||||
# Django Imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Prefetch, F, OuterRef, Func
|
||||
from django.db.models import Prefetch, F, OuterRef, Func, Exists
|
||||
from django.core import serializers
|
||||
|
||||
# Third party imports
|
||||
@ -17,6 +17,8 @@ from plane.api.serializers import (
|
||||
ModuleWriteSerializer,
|
||||
ModuleSerializer,
|
||||
ModuleIssueSerializer,
|
||||
ModuleLinkSerializer,
|
||||
ModuleFavoriteSerializer,
|
||||
)
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
@ -25,8 +27,10 @@ from plane.db.models import (
|
||||
Project,
|
||||
Issue,
|
||||
ModuleLink,
|
||||
ModuleFavorite,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
|
||||
|
||||
class ModuleViewSet(BaseViewSet):
|
||||
@ -97,14 +101,31 @@ class ModuleViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = ModuleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
module_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
modules = self.get_queryset().annotate(is_favorite=Exists(subquery))
|
||||
return Response(ModuleSerializer(modules, many=True).data)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ModuleIssueViewSet(BaseViewSet):
|
||||
serializer_class = ModuleIssueSerializer
|
||||
model = ModuleIssue
|
||||
|
||||
filterset_fields = [
|
||||
"issue__id",
|
||||
"workspace__id",
|
||||
"issue__labels__id",
|
||||
"issue__assignees__id",
|
||||
]
|
||||
|
||||
permission_classes = [
|
||||
@ -140,6 +161,31 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id, module_id):
|
||||
try:
|
||||
order_by = request.GET.get("order_by", "issue__created_at")
|
||||
queryset = self.get_queryset().order_by(f"issue__{order_by}")
|
||||
group_by = request.GET.get("group_by", False)
|
||||
|
||||
module_issues = ModuleIssueSerializer(queryset, many=True).data
|
||||
|
||||
if group_by:
|
||||
return Response(
|
||||
group_results(module_issues, f"issue_detail.{group_by}"),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
module_issues,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id, module_id):
|
||||
try:
|
||||
issues = request.data.get("issues", [])
|
||||
@ -232,3 +278,91 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ModuleLinkViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
model = ModuleLink
|
||||
serializer_class = ModuleLinkSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
module_id=self.kwargs.get("module_id"),
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(module_id=self.kwargs.get("module_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
class ModuleFavoriteViewSet(BaseViewSet):
|
||||
serializer_class = ModuleFavoriteSerializer
|
||||
model = ModuleFavorite
|
||||
|
||||
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("module")
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
serializer = ModuleFavoriteSerializer(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)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"error": "The module is already added to favorites"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
else:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, module_id):
|
||||
try:
|
||||
module_favorite = ModuleFavorite.objects.get(
|
||||
project=project_id,
|
||||
user=request.user,
|
||||
workspace__slug=slug,
|
||||
module_id=module_id,
|
||||
)
|
||||
module_favorite.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except ModuleFavorite.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Module is not in favorites"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
@ -5,7 +5,7 @@ from datetime import datetime
|
||||
# Django imports
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, Exists, OuterRef
|
||||
from django.core.validators import validate_email
|
||||
from django.conf import settings
|
||||
|
||||
@ -22,6 +22,7 @@ from plane.api.serializers import (
|
||||
ProjectMemberSerializer,
|
||||
ProjectDetailSerializer,
|
||||
ProjectMemberInviteSerializer,
|
||||
ProjectFavoriteSerializer,
|
||||
)
|
||||
|
||||
from plane.api.permissions import ProjectBasePermission
|
||||
@ -35,6 +36,7 @@ from plane.db.models import (
|
||||
WorkspaceMember,
|
||||
State,
|
||||
TeamMember,
|
||||
ProjectFavorite,
|
||||
)
|
||||
|
||||
from plane.db.models import (
|
||||
@ -73,6 +75,22 @@ class ProjectViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def list(self, request, slug):
|
||||
try:
|
||||
subquery = ProjectFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
projects = self.get_queryset().annotate(is_favorite=Exists(subquery))
|
||||
return Response(ProjectDetailSerializer(projects, many=True).data)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def create(self, request, slug):
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
@ -345,6 +363,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(member__is_bot=False)
|
||||
.select_related("project")
|
||||
.select_related("member")
|
||||
.select_related("workspace", "workspace__owner")
|
||||
@ -659,3 +678,69 @@ class ProjectMemberUserEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ProjectFavoritesViewSet(BaseViewSet):
|
||||
serializer_class = ProjectFavoriteSerializer
|
||||
model = ProjectFavorite
|
||||
|
||||
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(
|
||||
"project", "project__project_lead", "project__default_assignee"
|
||||
)
|
||||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
def create(self, request, slug):
|
||||
try:
|
||||
serializer = ProjectFavoriteSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(user=request.user)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError as e:
|
||||
print(str(e))
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"error": "The project is already added to favorites"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
else:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id):
|
||||
try:
|
||||
project_favorite = ProjectFavorite.objects.get(
|
||||
project=project_id, user=request.user, workspace__slug=slug
|
||||
)
|
||||
project_favorite.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except ProjectFavorite.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project is not in favorites"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
@ -43,7 +43,6 @@ from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||
|
||||
|
||||
class WorkSpaceViewSet(BaseViewSet):
|
||||
|
||||
model = Workspace
|
||||
serializer_class = WorkSpaceSerializer
|
||||
permission_classes = [
|
||||
@ -101,7 +100,6 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
|
||||
search_fields = [
|
||||
"name",
|
||||
]
|
||||
@ -111,7 +109,6 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request):
|
||||
try:
|
||||
|
||||
member_count = (
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
@ -163,14 +160,12 @@ class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class InviteWorkspaceEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug):
|
||||
try:
|
||||
|
||||
emails = request.data.get("emails", False)
|
||||
# Check if email is provided
|
||||
if not emails or not len(emails):
|
||||
@ -267,7 +262,6 @@ class JoinWorkspaceEndpoint(BaseAPIView):
|
||||
|
||||
def post(self, request, slug, pk):
|
||||
try:
|
||||
|
||||
workspace_invite = WorkspaceMemberInvite.objects.get(
|
||||
pk=pk, workspace__slug=slug
|
||||
)
|
||||
@ -286,7 +280,6 @@ class JoinWorkspaceEndpoint(BaseAPIView):
|
||||
workspace_invite.save()
|
||||
|
||||
if workspace_invite.accepted:
|
||||
|
||||
# Check if the user created account after invitation
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
@ -334,7 +327,6 @@ class JoinWorkspaceEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class WorkspaceInvitationsViewset(BaseViewSet):
|
||||
|
||||
serializer_class = WorkSpaceMemberInviteSerializer
|
||||
model = WorkspaceMemberInvite
|
||||
|
||||
@ -352,7 +344,6 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||
|
||||
|
||||
class UserWorkspaceInvitationsEndpoint(BaseViewSet):
|
||||
|
||||
serializer_class = WorkSpaceMemberInviteSerializer
|
||||
model = WorkspaceMemberInvite
|
||||
|
||||
@ -366,7 +357,6 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
|
||||
|
||||
def create(self, request):
|
||||
try:
|
||||
|
||||
invitations = request.data.get("invitations")
|
||||
workspace_invitations = WorkspaceMemberInvite.objects.filter(
|
||||
pk__in=invitations
|
||||
@ -397,7 +387,6 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
|
||||
|
||||
|
||||
class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = WorkSpaceMemberSerializer
|
||||
model = WorkspaceMember
|
||||
|
||||
@ -414,14 +403,13 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"), member__is_bot=False)
|
||||
.select_related("workspace", "workspace__owner")
|
||||
.select_related("member")
|
||||
)
|
||||
|
||||
|
||||
class TeamMemberViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = TeamSerializer
|
||||
model = Team
|
||||
permission_classes = [
|
||||
@ -443,9 +431,7 @@ class TeamMemberViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
def create(self, request, slug):
|
||||
|
||||
try:
|
||||
|
||||
members = list(
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, member__id__in=request.data.get("members", [])
|
||||
@ -456,7 +442,6 @@ class TeamMemberViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if len(members) != len(request.data.get("members", [])):
|
||||
|
||||
users = list(set(request.data.get("members", [])).difference(members))
|
||||
users = User.objects.filter(pk__in=users)
|
||||
|
||||
@ -493,7 +478,6 @@ class TeamMemberViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class UserWorkspaceInvitationEndpoint(BaseViewSet):
|
||||
|
||||
model = WorkspaceMemberInvite
|
||||
serializer_class = WorkSpaceMemberInviteSerializer
|
||||
|
||||
@ -513,7 +497,6 @@ class UserWorkspaceInvitationEndpoint(BaseViewSet):
|
||||
class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
try:
|
||||
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
|
||||
last_workspace_id = user.last_workspace_id
|
||||
@ -577,7 +560,6 @@ class WorkspaceMemberUserEndpoint(BaseAPIView):
|
||||
class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
|
||||
def post(self, request, slug):
|
||||
try:
|
||||
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=request.user
|
||||
)
|
||||
|
101
apiserver/plane/db/migrations/0022_auto_20230307_0304.py
Normal file
101
apiserver/plane/db/migrations/0022_auto_20230307_0304.py
Normal file
@ -0,0 +1,101 @@
|
||||
# Generated by Django 3.2.16 on 2023-03-06 21:34
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0021_auto_20230223_0104'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='cycle',
|
||||
name='status',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='project',
|
||||
name='slug',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuelink',
|
||||
name='metadata',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='modulelink',
|
||||
name='metadata',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='cover_image',
|
||||
field=models.URLField(blank=True, null=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProjectFavorite',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectfavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_projectfavorite', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectfavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_favorites', to=settings.AUTH_USER_MODEL)),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_projectfavorite', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Project Favorite',
|
||||
'verbose_name_plural': 'Project Favorites',
|
||||
'db_table': 'project_favorites',
|
||||
'ordering': ('-created_at',),
|
||||
'unique_together': {('project', 'user')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ModuleFavorite',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulefavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='module_favorites', to='db.module')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_modulefavorite', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulefavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='module_favorites', to=settings.AUTH_USER_MODEL)),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_modulefavorite', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Module Favorite',
|
||||
'verbose_name_plural': 'Module Favorites',
|
||||
'db_table': 'module_favorites',
|
||||
'ordering': ('-created_at',),
|
||||
'unique_together': {('module', 'user')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CycleFavorite',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cyclefavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cycle_favorites', to='db.cycle')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_cyclefavorite', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cyclefavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cycle_favorites', to=settings.AUTH_USER_MODEL)),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_cyclefavorite', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Cycle Favorite',
|
||||
'verbose_name_plural': 'Cycle Favorites',
|
||||
'db_table': 'cycle_favorites',
|
||||
'ordering': ('-created_at',),
|
||||
'unique_together': {('cycle', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
@ -16,6 +16,7 @@ from .project import (
|
||||
ProjectBaseModel,
|
||||
ProjectMemberInvite,
|
||||
ProjectIdentifier,
|
||||
ProjectFavorite,
|
||||
)
|
||||
|
||||
from .issue import (
|
||||
@ -38,13 +39,13 @@ from .social_connection import SocialLoginConnection
|
||||
|
||||
from .state import State
|
||||
|
||||
from .cycle import Cycle, CycleIssue
|
||||
from .cycle import Cycle, CycleIssue, CycleFavorite
|
||||
|
||||
from .shortcut import Shortcut
|
||||
|
||||
from .view import View
|
||||
|
||||
from .module import Module, ModuleMember, ModuleIssue, ModuleLink
|
||||
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite
|
||||
|
||||
from .api_token import APIToken
|
||||
|
||||
|
@ -7,11 +7,6 @@ from . import ProjectBaseModel
|
||||
|
||||
|
||||
class Cycle(ProjectBaseModel):
|
||||
STATUS_CHOICES = (
|
||||
("draft", "Draft"),
|
||||
("started", "Started"),
|
||||
("completed", "Completed"),
|
||||
)
|
||||
name = models.CharField(max_length=255, verbose_name="Cycle Name")
|
||||
description = models.TextField(verbose_name="Cycle Description", blank=True)
|
||||
start_date = models.DateField(verbose_name="Start Date", blank=True, null=True)
|
||||
@ -21,12 +16,6 @@ class Cycle(ProjectBaseModel):
|
||||
on_delete=models.CASCADE,
|
||||
related_name="owned_by_cycle",
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name="Cycle Status",
|
||||
choices=STATUS_CHOICES,
|
||||
default="draft",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Cycle"
|
||||
@ -59,3 +48,29 @@ class CycleIssue(ProjectBaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.cycle}"
|
||||
|
||||
|
||||
class CycleFavorite(ProjectBaseModel):
|
||||
"""_summary_
|
||||
CycleFavorite (model): To store all the cycle favorite of the user
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="cycle_favorites",
|
||||
)
|
||||
cycle = models.ForeignKey(
|
||||
"db.Cycle", on_delete=models.CASCADE, related_name="cycle_favorites"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["cycle", "user"]
|
||||
verbose_name = "Cycle Favorite"
|
||||
verbose_name_plural = "Cycle Favorites"
|
||||
db_table = "cycle_favorites"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return user and the cycle"""
|
||||
return f"{self.user.email} <{self.cycle.name}>"
|
||||
|
@ -174,6 +174,7 @@ class IssueLink(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, related_name="issue_link"
|
||||
)
|
||||
metadata = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Link"
|
||||
|
@ -7,7 +7,6 @@ from . import ProjectBaseModel
|
||||
|
||||
|
||||
class Module(ProjectBaseModel):
|
||||
|
||||
name = models.CharField(max_length=255, verbose_name="Module Name")
|
||||
description = models.TextField(verbose_name="Module Description", blank=True)
|
||||
description_text = models.JSONField(
|
||||
@ -41,7 +40,6 @@ class Module(ProjectBaseModel):
|
||||
through_fields=("module", "member"),
|
||||
)
|
||||
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "project"]
|
||||
verbose_name = "Module"
|
||||
@ -54,7 +52,6 @@ class Module(ProjectBaseModel):
|
||||
|
||||
|
||||
class ModuleMember(ProjectBaseModel):
|
||||
|
||||
module = models.ForeignKey("db.Module", on_delete=models.CASCADE)
|
||||
member = models.ForeignKey("db.User", on_delete=models.CASCADE)
|
||||
|
||||
@ -70,7 +67,6 @@ class ModuleMember(ProjectBaseModel):
|
||||
|
||||
|
||||
class ModuleIssue(ProjectBaseModel):
|
||||
|
||||
module = models.ForeignKey(
|
||||
"db.Module", on_delete=models.CASCADE, related_name="issue_module"
|
||||
)
|
||||
@ -89,10 +85,12 @@ class ModuleIssue(ProjectBaseModel):
|
||||
|
||||
|
||||
class ModuleLink(ProjectBaseModel):
|
||||
|
||||
title = models.CharField(max_length=255, null=True)
|
||||
url = models.URLField()
|
||||
module = models.ForeignKey(Module, on_delete=models.CASCADE, related_name="link_module")
|
||||
module = models.ForeignKey(
|
||||
Module, on_delete=models.CASCADE, related_name="link_module"
|
||||
)
|
||||
metadata = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Module Link"
|
||||
@ -101,4 +99,30 @@ class ModuleLink(ProjectBaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.module.name} {self.url}"
|
||||
return f"{self.module.name} {self.url}"
|
||||
|
||||
|
||||
class ModuleFavorite(ProjectBaseModel):
|
||||
"""_summary_
|
||||
ModuleFavorite (model): To store all the module favorite of the user
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="module_favorites",
|
||||
)
|
||||
module = models.ForeignKey(
|
||||
"db.Module", on_delete=models.CASCADE, related_name="module_favorites"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["module", "user"]
|
||||
verbose_name = "Module Favorite"
|
||||
verbose_name_plural = "Module Favorites"
|
||||
db_table = "module_favorites"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return user and the module"""
|
||||
return f"{self.user.email} <{self.module.name}>"
|
||||
|
@ -46,7 +46,6 @@ class Project(BaseModel):
|
||||
max_length=5,
|
||||
verbose_name="Project Identifier",
|
||||
)
|
||||
slug = models.SlugField(max_length=100, blank=True)
|
||||
default_assignee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
@ -64,6 +63,7 @@ class Project(BaseModel):
|
||||
icon = models.CharField(max_length=255, null=True, blank=True)
|
||||
module_view = models.BooleanField(default=True)
|
||||
cycle_view = models.BooleanField(default=True)
|
||||
cover_image = models.URLField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the project"""
|
||||
@ -77,7 +77,6 @@ class Project(BaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.slug = slugify(self.name)
|
||||
self.identifier = self.identifier.strip().upper()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@ -157,3 +156,22 @@ class ProjectIdentifier(AuditModel):
|
||||
verbose_name_plural = "Project Identifiers"
|
||||
db_table = "project_identifiers"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class ProjectFavorite(ProjectBaseModel):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="project_favorites",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "user"]
|
||||
verbose_name = "Project Favorite"
|
||||
verbose_name_plural = "Project Favorites"
|
||||
db_table = "project_favorites"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return user of the project"""
|
||||
return f"{self.user.email} <{self.project.name}>"
|
||||
|
@ -1,12 +1,34 @@
|
||||
def group_results(results_data, group_by):
|
||||
def resolve_keys(group_keys, value):
|
||||
"""resolve keys to a key which will be used for
|
||||
grouping
|
||||
|
||||
Args:
|
||||
group_keys (string): key which will be used for grouping
|
||||
value (obj): data value
|
||||
|
||||
Returns:
|
||||
string: the key which will be used for
|
||||
"""
|
||||
Utility function to group data into a given attribute.
|
||||
Function can group attributes of string and list type.
|
||||
keys = group_keys.split(".")
|
||||
for key in keys:
|
||||
value = value.get(key, None)
|
||||
return value
|
||||
|
||||
|
||||
def group_results(results_data, group_by):
|
||||
"""group results data into certain group_by
|
||||
|
||||
Args:
|
||||
results_data (obj): complete results data
|
||||
group_by (key): string
|
||||
|
||||
Returns:
|
||||
obj: grouped results
|
||||
"""
|
||||
response_dict = dict()
|
||||
|
||||
for value in results_data:
|
||||
group_attribute = value.get(group_by, None)
|
||||
group_attribute = resolve_keys(group_by, value)
|
||||
if isinstance(group_attribute, list):
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
@ -28,4 +50,4 @@ def group_results(results_data, group_by):
|
||||
response_dict[str(group_attribute)] = []
|
||||
response_dict[str(group_attribute)].append(value)
|
||||
|
||||
return response_dict
|
||||
return response_dict
|
||||
|
@ -204,7 +204,7 @@ export const CommandPalette: React.FC = () => {
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||
<div className="fixed inset-0 z-20 p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@ -214,7 +214,7 @@ export const CommandPalette: React.FC = () => {
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
||||
<Combobox
|
||||
onChange={(value: any) => {
|
||||
if (value?.url) router.push(value.url);
|
||||
|
@ -11,6 +11,7 @@ type Props = {
|
||||
states: IState[] | undefined;
|
||||
members: IProjectMember[] | undefined;
|
||||
addIssueToState: (groupTitle: string, stateId: string | null) => void;
|
||||
makeIssueCopy: (issue: IIssue) => void;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
@ -25,6 +26,7 @@ export const AllBoards: React.FC<Props> = ({
|
||||
states,
|
||||
members,
|
||||
addIssueToState,
|
||||
makeIssueCopy,
|
||||
handleEditIssue,
|
||||
openIssuesListModal,
|
||||
handleDeleteIssue,
|
||||
@ -37,43 +39,46 @@ export const AllBoards: React.FC<Props> = ({
|
||||
return (
|
||||
<>
|
||||
{groupedByIssues ? (
|
||||
<div className="h-[calc(100vh-157px)] lg:h-[calc(100vh-115px)] w-full">
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full gap-x-4 overflow-x-auto overflow-y-hidden">
|
||||
{Object.keys(groupedByIssues).map((singleGroup, index) => {
|
||||
const stateId =
|
||||
selectedGroup === "state_detail.name"
|
||||
? states?.find((s) => s.name === singleGroup)?.id ?? null
|
||||
: null;
|
||||
<div className="h-[calc(100vh-157px)] w-full lg:h-[calc(100vh-115px)]">
|
||||
<div className="horizontal-scroll-enable flex h-full gap-x-4 overflow-x-auto overflow-y-hidden">
|
||||
{Object.keys(groupedByIssues).map((singleGroup, index) => {
|
||||
const currentState =
|
||||
selectedGroup === "state_detail.name"
|
||||
? states?.find((s) => s.name === singleGroup)
|
||||
: null;
|
||||
|
||||
const bgColor =
|
||||
selectedGroup === "state_detail.name"
|
||||
? states?.find((s) => s.name === singleGroup)?.color
|
||||
: "#000000";
|
||||
const stateId =
|
||||
selectedGroup === "state_detail.name"
|
||||
? states?.find((s) => s.name === singleGroup)?.id ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SingleBoard
|
||||
key={index}
|
||||
type={type}
|
||||
bgColor={bgColor}
|
||||
groupTitle={singleGroup}
|
||||
groupedByIssues={groupedByIssues}
|
||||
selectedGroup={selectedGroup}
|
||||
members={members}
|
||||
handleEditIssue={handleEditIssue}
|
||||
addIssueToState={() => addIssueToState(singleGroup, stateId)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
openIssuesListModal={openIssuesListModal ?? null}
|
||||
orderBy={orderBy}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={removeIssue}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
const bgColor =
|
||||
selectedGroup === "state_detail.name"
|
||||
? states?.find((s) => s.name === singleGroup)?.color
|
||||
: "#000000";
|
||||
|
||||
return (
|
||||
<SingleBoard
|
||||
key={index}
|
||||
type={type}
|
||||
currentState={currentState}
|
||||
bgColor={bgColor}
|
||||
groupTitle={singleGroup}
|
||||
groupedByIssues={groupedByIssues}
|
||||
selectedGroup={selectedGroup}
|
||||
members={members}
|
||||
handleEditIssue={handleEditIssue}
|
||||
makeIssueCopy={makeIssueCopy}
|
||||
addIssueToState={() => addIssueToState(singleGroup, stateId)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
openIssuesListModal={openIssuesListModal ?? null}
|
||||
orderBy={orderBy}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={removeIssue}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
@ -1,22 +1,17 @@
|
||||
import React from "react";
|
||||
|
||||
// react-beautiful-dnd
|
||||
import { DraggableProvided } from "react-beautiful-dnd";
|
||||
// icons
|
||||
import {
|
||||
ArrowsPointingInIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
PlusIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IProjectMember, NestedKeyOf } from "types";
|
||||
import { IIssue, IProjectMember, IState, NestedKeyOf } from "types";
|
||||
import { getStateGroupIcon } from "components/icons";
|
||||
type Props = {
|
||||
groupedByIssues: {
|
||||
[key: string]: IIssue[];
|
||||
};
|
||||
currentState?: IState | null;
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
groupTitle: string;
|
||||
bgColor?: string;
|
||||
@ -28,6 +23,7 @@ type Props = {
|
||||
|
||||
export const BoardHeader: React.FC<Props> = ({
|
||||
groupedByIssues,
|
||||
currentState,
|
||||
selectedGroup,
|
||||
groupTitle,
|
||||
bgColor,
|
||||
@ -54,22 +50,19 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-between p-3 pb-0 ${
|
||||
className={`flex justify-between px-1 ${
|
||||
!isCollapsed ? "flex-col rounded-md border bg-gray-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
|
||||
<div
|
||||
className={`flex cursor-pointer items-center gap-x-1 rounded-md bg-slate-900 px-2 ${
|
||||
className={`flex cursor-pointer items-center gap-x-3.5 ${
|
||||
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
|
||||
}`}
|
||||
style={{
|
||||
border: `2px solid ${bgColor}`,
|
||||
backgroundColor: `${bgColor}20`,
|
||||
}}
|
||||
>
|
||||
{currentState && getStateGroupIcon(currentState.group, "20", "20", bgColor)}
|
||||
<h2
|
||||
className={`text-[0.9rem] font-medium capitalize`}
|
||||
className={`text-xl font-semibold capitalize`}
|
||||
style={{
|
||||
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
|
||||
}}
|
||||
@ -80,14 +73,16 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
? assignees
|
||||
: addSpaceIfCamelCase(groupTitle)}
|
||||
</h2>
|
||||
<span className="ml-0.5 text-sm text-gray-500">{groupedByIssues[groupTitle].length}</span>
|
||||
<span className="ml-0.5 text-sm bg-gray-100 py-1 px-3 rounded-full">
|
||||
{groupedByIssues[groupTitle].length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center ${!isCollapsed ? "flex-col pb-2" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 text-gray-700 outline-none duration-300 hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
setIsCollapsed((prevData) => !prevData);
|
||||
}}
|
||||
@ -100,7 +95,7 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 text-gray-700 outline-none duration-300 hover:bg-gray-100"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
|
@ -13,11 +13,14 @@ import { BoardHeader, SingleBoardIssue } from "components/core";
|
||||
import { CustomMenu } from "components/ui";
|
||||
// icons
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IProjectMember, NestedKeyOf, UserAuth } from "types";
|
||||
import { IIssue, IProjectMember, IState, NestedKeyOf, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
type?: "issue" | "cycle" | "module";
|
||||
currentState?: IState | null;
|
||||
bgColor?: string;
|
||||
groupTitle: string;
|
||||
groupedByIssues: {
|
||||
@ -26,6 +29,7 @@ type Props = {
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
members: IProjectMember[] | undefined;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
makeIssueCopy: (issue: IIssue) => void;
|
||||
addIssueToState: () => void;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
@ -37,12 +41,14 @@ type Props = {
|
||||
|
||||
export const SingleBoard: React.FC<Props> = ({
|
||||
type,
|
||||
currentState,
|
||||
bgColor,
|
||||
groupTitle,
|
||||
groupedByIssues,
|
||||
selectedGroup,
|
||||
members,
|
||||
handleEditIssue,
|
||||
makeIssueCopy,
|
||||
addIssueToState,
|
||||
handleDeleteIssue,
|
||||
openIssuesListModal,
|
||||
@ -71,10 +77,11 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
<div className={`h-full flex-shrink-0 rounded ${!isCollapsed ? "" : "w-80 border bg-gray-50"}`}>
|
||||
<div className={`h-full flex-shrink-0 rounded ${!isCollapsed ? "" : "w-96 bg-gray-50"}`}>
|
||||
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3"}`}>
|
||||
<BoardHeader
|
||||
addIssueToState={addIssueToState}
|
||||
currentState={currentState}
|
||||
bgColor={bgColor}
|
||||
selectedGroup={selectedGroup}
|
||||
groupTitle={groupTitle}
|
||||
@ -86,7 +93,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`relative mt-3 h-full px-3 pb-3 overflow-y-auto ${
|
||||
className={`relative h-full overflow-y-auto p-1 ${
|
||||
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
|
||||
} ${!isCollapsed ? "hidden" : "block"}`}
|
||||
ref={provided.innerRef}
|
||||
@ -97,14 +104,14 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
<div
|
||||
className={`absolute ${
|
||||
snapshot.isDraggingOver ? "block" : "hidden"
|
||||
} top-0 left-0 h-full w-full bg-indigo-200 opacity-50 pointer-events-none z-[99999998]`}
|
||||
} pointer-events-none top-0 left-0 z-[99999998] h-full w-full bg-gray-100 opacity-50`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute ${
|
||||
snapshot.isDraggingOver ? "block" : "hidden"
|
||||
} top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 text-xs whitespace-nowrap bg-white p-2 rounded pointer-events-none z-[99999999]`}
|
||||
} pointer-events-none top-1/2 left-1/2 z-[99999999] -translate-y-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-white p-2 text-xs`}
|
||||
>
|
||||
This board is ordered by {orderBy}
|
||||
This board is ordered by {replaceUnderscoreIfSnakeCase(orderBy ?? "")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@ -127,6 +134,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
selectedGroup={selectedGroup}
|
||||
properties={properties}
|
||||
editIssue={() => handleEditIssue(issue)}
|
||||
makeIssueCopy={() => makeIssueCopy(issue)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
orderBy={orderBy}
|
||||
handleTrashBox={handleTrashBox}
|
||||
@ -148,21 +156,23 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
{type === "issue" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center rounded p-2 text-xs font-medium outline-none duration-300 hover:bg-gray-100"
|
||||
className="flex items-center gap-2 font-medium text-theme outline-none"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="mr-1 h-3 w-3" />
|
||||
Create
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
</button>
|
||||
) : (
|
||||
<CustomMenu
|
||||
label={
|
||||
<span className="flex items-center gap-1">
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add issue
|
||||
</span>
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 font-medium text-theme outline-none"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
</button>
|
||||
}
|
||||
className="mt-1"
|
||||
optionsPosition="left"
|
||||
noBorder
|
||||
>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@ -24,9 +24,16 @@ import {
|
||||
ViewStateSelect,
|
||||
} from "components/issues/view-select";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
import { ContextMenu, CustomMenu, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ClipboardDocumentCheckIcon,
|
||||
LinkIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
// types
|
||||
import {
|
||||
CycleIssueResponse,
|
||||
@ -47,6 +54,7 @@ type Props = {
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
properties: Properties;
|
||||
editIssue: () => void;
|
||||
makeIssueCopy: () => void;
|
||||
removeIssue?: (() => void) | null;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
orderBy: NestedKeyOf<IIssue> | null;
|
||||
@ -62,12 +70,17 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
selectedGroup,
|
||||
properties,
|
||||
editIssue,
|
||||
makeIssueCopy,
|
||||
removeIssue,
|
||||
handleDeleteIssue,
|
||||
orderBy,
|
||||
handleTrashBox,
|
||||
userAuth,
|
||||
}) => {
|
||||
// context menu
|
||||
const [contextMenu, setContextMenu] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
@ -88,6 +101,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
issue_detail: {
|
||||
...p.issue_detail,
|
||||
...formData,
|
||||
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -109,6 +123,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
issue_detail: {
|
||||
...p.issue_detail,
|
||||
...formData,
|
||||
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -123,7 +138,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === issue.id) return { ...p, ...formData };
|
||||
if (p.id === issue.id)
|
||||
return { ...p, ...formData, assignees: formData.assignees_list ?? p.assignees_list };
|
||||
|
||||
return p;
|
||||
}),
|
||||
@ -146,10 +162,10 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
[workspaceSlug, projectId, cycleId, moduleId, issue]
|
||||
);
|
||||
|
||||
function getStyle(
|
||||
const getStyle = (
|
||||
style: DraggingStyle | NotDraggingStyle | undefined,
|
||||
snapshot: DraggableStateSnapshot
|
||||
) {
|
||||
) => {
|
||||
if (orderBy === "sort_order") return style;
|
||||
if (!snapshot.isDragging) return {};
|
||||
if (!snapshot.isDropAnimating) {
|
||||
@ -160,7 +176,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
...style,
|
||||
transitionDuration: `0.001s`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyText = () => {
|
||||
const originURL =
|
||||
@ -183,107 +199,135 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded border bg-white shadow-sm mb-3 ${
|
||||
snapshot.isDragging ? "border-theme bg-indigo-50 shadow-lg" : ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={getStyle(provided.draggableProps.style, snapshot)}
|
||||
>
|
||||
<div className="group/card relative select-none p-2">
|
||||
{!isNotAllowed && (
|
||||
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
|
||||
{type && !isNotAllowed && (
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
|
||||
{type !== "issue" && removeIssue && (
|
||||
<CustomMenu.MenuItem onClick={removeIssue}>
|
||||
<>Remove from {type}</>
|
||||
<>
|
||||
<ContextMenu
|
||||
position={contextMenuPosition}
|
||||
title="Quick actions"
|
||||
isOpen={contextMenu}
|
||||
setIsOpen={setContextMenu}
|
||||
>
|
||||
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
|
||||
Edit issue
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
|
||||
Make a copy...
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete issue
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
|
||||
Copy issue link
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu>
|
||||
<div
|
||||
className={`mb-3 rounded bg-white shadow ${
|
||||
snapshot.isDragging ? "border-2 border-theme shadow-lg" : ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={getStyle(provided.draggableProps.style, snapshot)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu(true);
|
||||
setContextMenuPosition({ x: e.pageX, y: e.pageY });
|
||||
}}
|
||||
>
|
||||
<div className="group/card relative select-none p-4">
|
||||
{!isNotAllowed && (
|
||||
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
|
||||
{type && !isNotAllowed && (
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
|
||||
{type !== "issue" && removeIssue && (
|
||||
<CustomMenu.MenuItem onClick={removeIssue}>
|
||||
<>Remove from {type}</>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete issue
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete issue
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
Copy issue link
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<a>
|
||||
{properties.key && (
|
||||
<div className="mb-2.5 text-xs font-medium text-gray-700">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
<h5
|
||||
className="text-sm group-hover:text-theme"
|
||||
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
|
||||
>
|
||||
{truncateText(issue.name, 100)}
|
||||
</h5>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="relative mt-2.5 flex flex-wrap items-center gap-2 text-xs">
|
||||
{properties.priority && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<a>
|
||||
{properties.key && (
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
{properties.state && (
|
||||
<ViewStateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.due_date && (
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-xs shadow-sm">
|
||||
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
<h5
|
||||
className="mb-3 text-sm group-hover:text-theme"
|
||||
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
|
||||
>
|
||||
{issue.name}
|
||||
</h5>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="relative flex flex-wrap items-center gap-x-1 gap-y-2 text-xs">
|
||||
{properties.priority && selectedGroup !== "priority" && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.state && selectedGroup !== "state_detail.name" && (
|
||||
<ViewStateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.due_date && (
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
{properties.labels && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.label_details.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||
>
|
||||
{properties.labels && issue.label_details.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.label_details.map((label) => (
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<ViewAssigneeSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
tooltipPosition="left"
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<ViewAssigneeSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
tooltipPosition="left"
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -129,7 +129,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
{filteredIssues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
<h2 className="mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
Select issues to add
|
||||
</h2>
|
||||
)}
|
||||
@ -175,18 +175,6 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
|
||||
{query !== "" && filteredIssues.length === 0 && (
|
||||
<div className="py-14 px-6 text-center sm:px-14">
|
||||
<RectangleStackIcon
|
||||
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="mt-4 text-sm text-gray-900">
|
||||
We couldn{"'"}t find any issue with that term. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Combobox>
|
||||
)}
|
||||
/>
|
||||
|
152
apps/app/components/core/image-picker-popover.tsx
Normal file
152
apps/app/components/core/image-picker-popover.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
|
||||
// next
|
||||
import Image from "next/image";
|
||||
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Tab, Transition, Popover } from "@headlessui/react";
|
||||
|
||||
// services
|
||||
import fileService from "services/file.service";
|
||||
|
||||
// components
|
||||
import { Input, Spinner } from "components/ui";
|
||||
import { PrimaryButton } from "components/ui/button/primary-button";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
|
||||
const tabOptions = [
|
||||
{
|
||||
key: "unsplash",
|
||||
title: "Unsplash",
|
||||
},
|
||||
{
|
||||
key: "upload",
|
||||
title: "Upload",
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
label: string | React.ReactNode;
|
||||
value: string | null;
|
||||
onChange: (data: string) => void;
|
||||
};
|
||||
|
||||
export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchParams, setSearchParams] = useState("");
|
||||
const [formData, setFormData] = useState({
|
||||
search: "",
|
||||
});
|
||||
|
||||
const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchParams}`, () =>
|
||||
fileService.getUnsplashImages(1, searchParams)
|
||||
);
|
||||
|
||||
useOutsideClickDetector(ref, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!images || value !== null) return;
|
||||
onChange(images[0].urls.regular);
|
||||
}, [value, onChange, images]);
|
||||
|
||||
return (
|
||||
<Popover className="relative z-[2]" ref={ref}>
|
||||
<Popover.Button
|
||||
className="rounded-md border border-gray-500 bg-white px-2 py-1 text-xs text-gray-700"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
{label}
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
show={isOpen}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md bg-white shadow-lg">
|
||||
<div className="h-96 w-80 overflow-auto rounded border bg-white p-5 shadow-2xl sm:max-w-2xl md:w-96 lg:w-[40rem]">
|
||||
<Tab.Group>
|
||||
<Tab.List as="span" className="inline-block rounded bg-gray-200 p-1">
|
||||
{tabOptions.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
className={({ selected }) =>
|
||||
`rounded py-1 px-4 text-center text-sm outline-none transition-colors ${
|
||||
selected ? "bg-theme text-white" : "text-black"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{tab.title}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<Tab.Panel className="h-full w-full space-y-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setSearchParams(formData.search);
|
||||
}}
|
||||
className="flex gap-x-2 pt-7"
|
||||
>
|
||||
<Input
|
||||
name="search"
|
||||
className="text-sm"
|
||||
id="search"
|
||||
value={formData.search}
|
||||
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
|
||||
placeholder="Search for images"
|
||||
/>
|
||||
<PrimaryButton className="bg-indigo-600" size="sm">
|
||||
Search
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
{images ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative col-span-2 aspect-video md:col-span-1"
|
||||
>
|
||||
<Image
|
||||
src={image.urls.small}
|
||||
alt={image.alt_description}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
className="cursor-pointer rounded"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onChange(image.urls.regular);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center pt-20">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="flex h-full w-full flex-col items-center justify-center">
|
||||
<p>Coming Soon...</p>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
};
|
@ -9,3 +9,4 @@ export * from "./issues-view";
|
||||
export * from "./link-modal";
|
||||
export * from "./not-authorized-view";
|
||||
export * from "./multi-level-select";
|
||||
export * from "./image-picker-popover";
|
||||
|
@ -269,6 +269,15 @@ export const IssuesView: React.FC<Props> = ({
|
||||
[setCreateIssueModal, setPreloadedData, selectedGroup]
|
||||
);
|
||||
|
||||
const makeIssueCopy = useCallback(
|
||||
(issue: IIssue) => {
|
||||
setCreateIssueModal(true);
|
||||
|
||||
setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" });
|
||||
},
|
||||
[setCreateIssueModal, setPreloadedData]
|
||||
);
|
||||
|
||||
const handleEditIssue = useCallback(
|
||||
(issue: IIssue) => {
|
||||
setEditIssueModal(true);
|
||||
@ -370,14 +379,14 @@ export const IssuesView: React.FC<Props> = ({
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`${
|
||||
trashBox ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
||||
} fixed z-20 top-12 left-1/2 -translate-x-1/2 flex items-center gap-2 bg-red-100 border-2 border-red-500 p-3 text-xs rounded ${
|
||||
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
|
||||
} fixed top-9 right-9 z-20 flex h-28 w-96 items-center justify-center gap-2 rounded border-2 border-red-500 bg-red-100 p-3 text-xs font-medium italic text-red-500 ${
|
||||
snapshot.isDraggingOver ? "bg-red-500 text-white" : ""
|
||||
} duration-200`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
<TrashIcon className="h-3 w-3" />
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Drop issue here to delete
|
||||
</div>
|
||||
)}
|
||||
@ -389,6 +398,7 @@ export const IssuesView: React.FC<Props> = ({
|
||||
states={states}
|
||||
members={members}
|
||||
addIssueToState={addIssueToState}
|
||||
makeIssueCopy={makeIssueCopy}
|
||||
handleEditIssue={handleEditIssue}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
||||
@ -408,6 +418,7 @@ export const IssuesView: React.FC<Props> = ({
|
||||
states={states}
|
||||
members={members}
|
||||
addIssueToState={addIssueToState}
|
||||
makeIssueCopy={makeIssueCopy}
|
||||
handleEditIssue={handleEditIssue}
|
||||
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
|
@ -16,7 +16,7 @@ import type { IIssueLink, ModuleLink } from "types";
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
onFormSubmit: (formData: IIssueLink | ModuleLink) => void;
|
||||
onFormSubmit: (formData: IIssueLink | ModuleLink) => Promise<void>;
|
||||
};
|
||||
|
||||
const defaultValues: ModuleLink = {
|
||||
|
@ -12,6 +12,7 @@ type Props = {
|
||||
states: IState[] | undefined;
|
||||
members: IProjectMember[] | undefined;
|
||||
addIssueToState: (groupTitle: string, stateId: string | null) => void;
|
||||
makeIssueCopy: (issue: IIssue) => void;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
@ -25,6 +26,7 @@ export const AllLists: React.FC<Props> = ({
|
||||
states,
|
||||
members,
|
||||
addIssueToState,
|
||||
makeIssueCopy,
|
||||
openIssuesListModal,
|
||||
handleEditIssue,
|
||||
handleDeleteIssue,
|
||||
@ -50,6 +52,7 @@ export const AllLists: React.FC<Props> = ({
|
||||
selectedGroup={selectedGroup}
|
||||
members={members}
|
||||
addIssueToState={() => addIssueToState(singleGroup, stateId)}
|
||||
makeIssueCopy={makeIssueCopy}
|
||||
handleEditIssue={handleEditIssue}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@ -18,7 +18,14 @@ import {
|
||||
} from "components/issues/view-select";
|
||||
|
||||
// ui
|
||||
import { Tooltip, CustomMenu } from "components/ui";
|
||||
import { Tooltip, CustomMenu, ContextMenu } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ClipboardDocumentCheckIcon,
|
||||
LinkIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
@ -31,6 +38,7 @@ type Props = {
|
||||
issue: IIssue;
|
||||
properties: Properties;
|
||||
editIssue: () => void;
|
||||
makeIssueCopy: () => void;
|
||||
removeIssue?: (() => void) | null;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
userAuth: UserAuth;
|
||||
@ -41,13 +49,20 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
issue,
|
||||
properties,
|
||||
editIssue,
|
||||
makeIssueCopy,
|
||||
removeIssue,
|
||||
handleDeleteIssue,
|
||||
userAuth,
|
||||
}) => {
|
||||
// context menu
|
||||
const [contextMenu, setContextMenu] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const partialUpdateIssue = useCallback(
|
||||
(formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
@ -63,6 +78,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
issue_detail: {
|
||||
...p.issue_detail,
|
||||
...formData,
|
||||
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -84,6 +100,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
issue_detail: {
|
||||
...p.issue_detail,
|
||||
...formData,
|
||||
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -98,7 +115,8 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === issue.id) return { ...p, ...formData };
|
||||
if (p.id === issue.id)
|
||||
return { ...p, ...formData, assignees: formData.assignees_list ?? p.assignees_list };
|
||||
|
||||
return p;
|
||||
}),
|
||||
@ -134,104 +152,136 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
|
||||
<a className="group relative flex items-center gap-2">
|
||||
{properties.key && (
|
||||
<Tooltip
|
||||
tooltipHeading="ID"
|
||||
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
<>
|
||||
<ContextMenu
|
||||
position={contextMenuPosition}
|
||||
title="Quick actions"
|
||||
isOpen={contextMenu}
|
||||
setIsOpen={setContextMenu}
|
||||
>
|
||||
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
|
||||
Edit issue
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
|
||||
Make a copy...
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete issue
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
|
||||
Copy issue link
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu>
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 px-4 py-3 text-sm"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu(true);
|
||||
setContextMenuPosition({ x: e.pageX, y: e.pageY });
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
|
||||
<a className="group relative flex items-center gap-2">
|
||||
{properties.key && (
|
||||
<Tooltip
|
||||
tooltipHeading="ID"
|
||||
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="w-auto max-w-lg overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{issue.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="w-auto max-w-lg text-ellipsis overflow-hidden whitespace-nowrap">
|
||||
{issue.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
|
||||
{properties.priority && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.state && (
|
||||
<ViewStateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.due_date && (
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm">
|
||||
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
{properties.labels && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.label_details.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||
>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
|
||||
{properties.priority && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="right"
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.state && (
|
||||
<ViewStateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="right"
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.due_date && (
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-xs shadow-sm">
|
||||
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
{properties.labels && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.label_details.map((label) => (
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<ViewAssigneeSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{type && !isNotAllowed && (
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
|
||||
{type !== "issue" && removeIssue && (
|
||||
<CustomMenu.MenuItem onClick={removeIssue}>
|
||||
<>Remove from {type}</>
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<ViewAssigneeSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="right"
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{type && !isNotAllowed && (
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
|
||||
{type !== "issue" && removeIssue && (
|
||||
<CustomMenu.MenuItem onClick={removeIssue}>
|
||||
<>Remove from {type}</>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete issue
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete issue
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -23,6 +23,7 @@ type Props = {
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
members: IProjectMember[] | undefined;
|
||||
addIssueToState: () => void;
|
||||
makeIssueCopy: (issue: IIssue) => void;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
@ -37,6 +38,7 @@ export const SingleList: React.FC<Props> = ({
|
||||
selectedGroup,
|
||||
members,
|
||||
addIssueToState,
|
||||
makeIssueCopy,
|
||||
handleEditIssue,
|
||||
handleDeleteIssue,
|
||||
openIssuesListModal,
|
||||
@ -113,6 +115,7 @@ export const SingleList: React.FC<Props> = ({
|
||||
issue={issue}
|
||||
properties={properties}
|
||||
editIssue={() => handleEditIssue(issue)}
|
||||
makeIssueCopy={() => makeIssueCopy(issue)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
removeIssue={() => {
|
||||
removeIssue && removeIssue(issue.bridge);
|
||||
|
@ -83,10 +83,10 @@ export const MultiLevelSelect: React.FC<TMultipleSelectProps> = (props) => {
|
||||
<>
|
||||
{openChildFor?.id === option.id && (
|
||||
<div
|
||||
className={`w-72 h-auto max-h-72 bg-white border border-gray-200 absolute rounded-lg ${
|
||||
className={`absolute h-auto max-h-72 w-72 rounded-lg border bg-white ${
|
||||
direction === "right"
|
||||
? "rounded-tl-none shadow-md left-full translate-x-2"
|
||||
: "rounded-tr-none shadow-md right-full -translate-x-2"
|
||||
? "left-full translate-x-2 rounded-tl-none shadow-md"
|
||||
: "right-full -translate-x-2 rounded-tr-none shadow-md"
|
||||
}`}
|
||||
>
|
||||
{option.children?.map((child) => (
|
||||
@ -118,7 +118,7 @@ export const MultiLevelSelect: React.FC<TMultipleSelectProps> = (props) => {
|
||||
))}
|
||||
|
||||
<div
|
||||
className={`w-0 h-0 absolute border-t-8 border-gray-300 ${
|
||||
className={`absolute h-0 w-0 border-t-8 border-gray-300 ${
|
||||
direction === "right"
|
||||
? "top-0 left-0 -translate-x-2 border-r-8 border-b-8 border-b-transparent border-t-transparent border-l-transparent"
|
||||
: "top-0 right-0 translate-x-2 border-l-8 border-b-8 border-b-transparent border-t-transparent border-r-transparent"
|
||||
|
@ -14,6 +14,7 @@ type Props = {
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
created_by_detail: IUserLite;
|
||||
metadata: any;
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
@ -56,8 +57,8 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
|
||||
<h5 className="w-4/5">{link.title}</h5>
|
||||
<p className="mt-0.5 text-gray-500">
|
||||
Added {timeAgo(link.created_at)}
|
||||
{/* <br />
|
||||
by {link.created_by_detail.email} */}
|
||||
<br />
|
||||
by {link.created_by_detail.email}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
@ -1,17 +1,10 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { XAxis, YAxis, Tooltip, AreaChart, Area, ReferenceLine, TooltipProps} from "recharts";
|
||||
|
||||
//types
|
||||
import { IIssue } from "types";
|
||||
import { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||
// helper
|
||||
import { getDatesInRange, renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
|
||||
@ -43,53 +36,69 @@ const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
|
||||
});
|
||||
return dateWiseData;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload }: TooltipProps<ValueType, NameType>) => {
|
||||
if (active && payload && payload.length) {
|
||||
console.log(payload[0].payload.currentDate);
|
||||
return (
|
||||
<div className="rounded-sm bg-gray-300 p-1 text-xs text-gray-800">
|
||||
<p>{payload[0].payload.currentDate}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const ChartData = getChartData();
|
||||
return (
|
||||
<div className="relative h-[200px] w-full ">
|
||||
<div className="flex justify-start items-start gap-4 text-xs">
|
||||
<div className="flex justify-center items-center gap-1">
|
||||
<span className="h-2 w-2 bg-green-600 rounded-full" />
|
||||
<span>Ideal</span>
|
||||
</div>
|
||||
<div className="flex justify-center items-center gap-1">
|
||||
<span className="h-2 w-2 bg-[#8884d8] rounded-full" />
|
||||
<span>Current</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-full w-full absolute -left-8 py-3 text-xs">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
width={300}
|
||||
height={200}
|
||||
data={ChartData}
|
||||
margin={{
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<XAxis dataKey="currentDate" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="pending"
|
||||
stroke="#8884d8"
|
||||
fill="#98d1fb"
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
<ReferenceLine
|
||||
stroke="#16a34a"
|
||||
strokeDasharray="3 3"
|
||||
segment={[
|
||||
{ x: `${renderShortNumericDateFormat(endDate)}`, y: 0 },
|
||||
{ x: `${renderShortNumericDateFormat(startDate)}`, y: issues.length },
|
||||
]}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="absolute -left-4 flex h-full w-full items-center justify-center text-xs">
|
||||
<AreaChart
|
||||
width={360}
|
||||
height={160}
|
||||
data={ChartData}
|
||||
margin={{
|
||||
top: 12,
|
||||
right: 12,
|
||||
left: 0,
|
||||
bottom: 12,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="linearblue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3F76FF" stopOpacity={0.7} />
|
||||
<stop offset="50%" stopColor="#3F76FF" stopOpacity={0.1} />
|
||||
<stop offset="100%" stopColor="#3F76FF" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="currentDate"
|
||||
stroke="#9ca3af"
|
||||
tick={{ fontSize: "12px", fill: "#1f2937" }}
|
||||
tickSize={10}
|
||||
minTickGap={10}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#9ca3af"
|
||||
tick={{ fontSize: "12px", fill: "#1f2937" }}
|
||||
tickSize={10}
|
||||
minTickGap={10}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="pending"
|
||||
stroke="#8884d8"
|
||||
fill="url(#linearblue)"
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
<ReferenceLine
|
||||
stroke="#16a34a"
|
||||
strokeDasharray="3 3"
|
||||
segment={[
|
||||
{ x: `${renderShortNumericDateFormat(endDate)}`, y: 0 },
|
||||
{ x: `${renderShortNumericDateFormat(startDate)}`, y: issues.length },
|
||||
]}
|
||||
/>
|
||||
</AreaChart>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -13,19 +13,24 @@ import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
// components
|
||||
import { SingleProgressStats } from "components/core";
|
||||
import { LinksList, SingleProgressStats } from "components/core";
|
||||
// ui
|
||||
import { Avatar } from "components/ui";
|
||||
// icons
|
||||
import User from "public/user.png";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, IIssueLabels } from "types";
|
||||
import { IIssue, IIssueLabels, IModule, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
// types
|
||||
type Props = {
|
||||
groupedIssues: any;
|
||||
issues: IIssue[];
|
||||
module?: IModule;
|
||||
setModuleLinkModal?: any;
|
||||
handleDeleteLink?: any;
|
||||
userAuth?: UserAuth;
|
||||
};
|
||||
|
||||
const stateGroupColours: {
|
||||
@ -38,7 +43,14 @@ const stateGroupColours: {
|
||||
completed: "#096e8d",
|
||||
};
|
||||
|
||||
export const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues }) => {
|
||||
export const SidebarProgressStats: React.FC<Props> = ({
|
||||
groupedIssues,
|
||||
issues,
|
||||
module,
|
||||
setModuleLinkModal,
|
||||
handleDeleteLink,
|
||||
userAuth,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
@ -60,14 +72,17 @@ export const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues })
|
||||
|
||||
const currentValue = (tab: string | null) => {
|
||||
switch (tab) {
|
||||
case "Links":
|
||||
return 0;
|
||||
case "Assignees":
|
||||
return 0;
|
||||
case "Labels":
|
||||
return 1;
|
||||
case "States":
|
||||
case "Labels":
|
||||
return 2;
|
||||
case "States":
|
||||
return 3;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
return (
|
||||
@ -76,45 +91,91 @@ export const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues })
|
||||
onChange={(i) => {
|
||||
switch (i) {
|
||||
case 0:
|
||||
return setTab("Assignees");
|
||||
return setTab("Links");
|
||||
case 1:
|
||||
return setTab("Labels");
|
||||
return setTab("Assignees");
|
||||
case 2:
|
||||
return setTab("Labels");
|
||||
case 3:
|
||||
return setTab("States");
|
||||
|
||||
default:
|
||||
return setTab("Assignees");
|
||||
return setTab("States");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab.List
|
||||
as="div"
|
||||
className="flex items-center justify-between w-full rounded bg-gray-100 text-xs"
|
||||
className={`flex w-full items-center justify-between rounded-md bg-gray-100 px-1 py-1.5
|
||||
${module ? "text-xs" : "text-sm"} `}
|
||||
>
|
||||
{module ? (
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-full rounded px-3 py-1 text-gray-900 ${
|
||||
selected ? " bg-theme text-white" : " hover:bg-hover-gray"
|
||||
}`
|
||||
}
|
||||
>
|
||||
Links
|
||||
</Tab>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-1/2 rounded py-1 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}`
|
||||
`w-full rounded px-3 py-1 text-gray-900 ${
|
||||
selected ? " bg-theme text-white" : " hover:bg-hover-gray"
|
||||
}`
|
||||
}
|
||||
>
|
||||
Assignees
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-1/2 rounded py-1 ${selected ? "bg-gray-300 font-semibold" : "hover:bg-gray-200 "}`
|
||||
`w-full rounded px-3 py-1 text-gray-900 ${
|
||||
selected ? " bg-theme text-white" : " hover:bg-hover-gray"
|
||||
}`
|
||||
}
|
||||
>
|
||||
Labels
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-1/2 rounded py-1 ${selected ? "bg-gray-300 font-semibold" : "hover:bg-gray-200 "}`
|
||||
`w-full rounded px-3 py-1 text-gray-900 ${
|
||||
selected ? " bg-theme text-white" : " hover:bg-hover-gray"
|
||||
}`
|
||||
}
|
||||
>
|
||||
States
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels className="flex items-center justify-between w-full">
|
||||
<Tab.Panel as="div" className="w-full flex flex-col ">
|
||||
<Tab.Panels className="flex w-full items-center justify-between p-1">
|
||||
{module ? (
|
||||
<Tab.Panel as="div" className="flex w-full flex-col text-xs ">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-start gap-2 rounded px-4 py-2 hover:bg-theme/5"
|
||||
onClick={() => setModuleLinkModal(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" /> <span>Add Link</span>
|
||||
</button>
|
||||
<div className="mt-2 space-y-2 hover:bg-theme/5">
|
||||
{userAuth && module.link_module && module.link_module.length > 0 ? (
|
||||
<LinksList
|
||||
links={module.link_module}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
<Tab.Panel as="div" className="flex w-full flex-col text-xs ">
|
||||
{members?.map((member, index) => {
|
||||
const totalArray = issues?.filter((i) => i.assignees?.includes(member.member.id));
|
||||
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
|
||||
@ -161,7 +222,7 @@ export const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues })
|
||||
""
|
||||
)}
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as="div" className="w-full flex flex-col ">
|
||||
<Tab.Panel as="div" className="flex w-full flex-col ">
|
||||
{issueLabels?.map((issue, index) => {
|
||||
const totalArray = issues?.filter((i) => i.labels?.includes(issue.id));
|
||||
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
|
||||
@ -170,15 +231,15 @@ export const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues })
|
||||
<SingleProgressStats
|
||||
key={index}
|
||||
title={
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-2 w-2 rounded-full "
|
||||
className="block h-3 w-3 rounded-full "
|
||||
style={{
|
||||
backgroundColor: issue.color,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs capitalize">{issue.name}</span>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
completed={completeArray.length}
|
||||
total={totalArray.length}
|
||||
@ -187,20 +248,20 @@ export const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues })
|
||||
}
|
||||
})}
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as="div" className="w-full flex flex-col ">
|
||||
<Tab.Panel as="div" className="flex w-full flex-col ">
|
||||
{Object.keys(groupedIssues).map((group, index) => (
|
||||
<SingleProgressStats
|
||||
key={index}
|
||||
title={
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-2 w-2 rounded-full "
|
||||
className="block h-3 w-3 rounded-full "
|
||||
style={{
|
||||
backgroundColor: stateGroupColours[group],
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs capitalize">{group}</span>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
completed={groupedIssues[group].length}
|
||||
total={issues.length}
|
||||
|
@ -13,10 +13,10 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
|
||||
completed,
|
||||
total,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between w-full py-3 text-xs border-b-[1px] border-gray-200">
|
||||
<div className="flex items-center justify-start w-1/2 gap-2">{title}</div>
|
||||
<div className="flex items-center justify-end w-1/2 gap-1 px-2">
|
||||
<div className="flex h-5 justify-center items-center gap-1 ">
|
||||
<div className="flex w-full items-center justify-between py-3 text-xs">
|
||||
<div className="flex w-1/2 items-center justify-start gap-2">{title}</div>
|
||||
<div className="flex w-1/2 items-center justify-end gap-1 px-2">
|
||||
<div className="flex h-5 items-center justify-center gap-1 ">
|
||||
<span className="h-4 w-4 ">
|
||||
<ProgressBar value={completed} maxValue={total} />
|
||||
</span>
|
||||
|
92
apps/app/components/cycles/completed-cycles-list.tsx
Normal file
92
apps/app/components/cycles/completed-cycles-list.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import cyclesService from "services/cycles.service";
|
||||
// components
|
||||
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
|
||||
// icons
|
||||
import { CompletedCycleIcon } from "components/icons";
|
||||
// types
|
||||
import { ICycle, SelectCycleType } from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_COMPLETE_LIST } from "constants/fetch-keys";
|
||||
import { Loader } from "components/ui";
|
||||
|
||||
export interface CompletedCyclesListProps {
|
||||
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
|
||||
}
|
||||
|
||||
export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({
|
||||
setCreateUpdateCycleModal,
|
||||
setSelectedCycle,
|
||||
}) => {
|
||||
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
|
||||
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectCycleType>();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: completedCycles } = useSWR(
|
||||
workspaceSlug && projectId ? CYCLE_COMPLETE_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => cyclesService.getCompletedCycles(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const handleDeleteCycle = (cycle: ICycle) => {
|
||||
setSelectedCycleForDelete({ ...cycle, actionType: "delete" });
|
||||
setCycleDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleEditCycle = (cycle: ICycle) => {
|
||||
setSelectedCycle({ ...cycle, actionType: "edit" });
|
||||
setCreateUpdateCycleModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteCycleModal
|
||||
isOpen={
|
||||
cycleDeleteModal &&
|
||||
!!selectedCycleForDelete &&
|
||||
selectedCycleForDelete.actionType === "delete"
|
||||
}
|
||||
setIsOpen={setCycleDeleteModal}
|
||||
data={selectedCycleForDelete}
|
||||
/>
|
||||
{completedCycles ? (
|
||||
completedCycles.completed_cycles.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
||||
{completedCycles.completed_cycles.map((cycle) => (
|
||||
<SingleCycleCard
|
||||
key={cycle.id}
|
||||
cycle={cycle}
|
||||
handleDeleteCycle={() => handleDeleteCycle(cycle)}
|
||||
handleEditCycle={() => handleEditCycle(cycle)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
||||
<CompletedCycleIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No completed cycles yet. Create with{" "}
|
||||
<pre className="inline rounded bg-gray-200 px-2 py-1">Q</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Loader.Item height="200px" />
|
||||
<Loader.Item height="200px" />
|
||||
<Loader.Item height="200px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,72 +0,0 @@
|
||||
// react
|
||||
import { useState } from "react";
|
||||
// components
|
||||
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
|
||||
// types
|
||||
import { ICycle, SelectCycleType } from "types";
|
||||
import { CompletedCycleIcon, CurrentCycleIcon, UpcomingCycleIcon } from "components/icons";
|
||||
|
||||
type TCycleStatsViewProps = {
|
||||
cycles: ICycle[];
|
||||
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
|
||||
type: "current" | "upcoming" | "completed";
|
||||
};
|
||||
|
||||
export const CyclesListView: React.FC<TCycleStatsViewProps> = ({
|
||||
cycles,
|
||||
setCreateUpdateCycleModal,
|
||||
setSelectedCycle,
|
||||
type,
|
||||
}) => {
|
||||
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
|
||||
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectCycleType>();
|
||||
|
||||
const handleDeleteCycle = (cycle: ICycle) => {
|
||||
setSelectedCycleForDelete({ ...cycle, actionType: "delete" });
|
||||
setCycleDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleEditCycle = (cycle: ICycle) => {
|
||||
setSelectedCycle({ ...cycle, actionType: "edit" });
|
||||
setCreateUpdateCycleModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteCycleModal
|
||||
isOpen={
|
||||
cycleDeleteModal &&
|
||||
!!selectedCycleForDelete &&
|
||||
selectedCycleForDelete.actionType === "delete"
|
||||
}
|
||||
setIsOpen={setCycleDeleteModal}
|
||||
data={selectedCycleForDelete}
|
||||
/>
|
||||
{cycles.length > 0 ? (
|
||||
cycles.map((cycle) => (
|
||||
<SingleCycleCard
|
||||
key={cycle.id}
|
||||
cycle={cycle}
|
||||
handleDeleteCycle={() => handleDeleteCycle(cycle)}
|
||||
handleEditCycle={() => handleEditCycle(cycle)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
||||
{type === "upcoming" ? (
|
||||
<UpcomingCycleIcon height="56" width="56" />
|
||||
) : type === "completed" ? (
|
||||
<CompletedCycleIcon height="56" width="56" />
|
||||
) : (
|
||||
<CurrentCycleIcon height="56" width="56" />
|
||||
)}
|
||||
<h3 className="text-gray-500">
|
||||
No {type} {type === "current" ? "cycle" : "cycles"} yet. Create with{" "}
|
||||
<pre className="inline rounded bg-gray-200 px-2 py-1">Q</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
82
apps/app/components/cycles/cycles-list.tsx
Normal file
82
apps/app/components/cycles/cycles-list.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { useState } from "react";
|
||||
|
||||
// components
|
||||
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
|
||||
// icons
|
||||
import { CompletedCycleIcon, CurrentCycleIcon, UpcomingCycleIcon } from "components/icons";
|
||||
// types
|
||||
import { ICycle, SelectCycleType } from "types";
|
||||
import { Loader } from "components/ui";
|
||||
|
||||
type TCycleStatsViewProps = {
|
||||
cycles: ICycle[] | undefined;
|
||||
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
|
||||
type: "current" | "upcoming" | "draft";
|
||||
};
|
||||
|
||||
export const CyclesList: React.FC<TCycleStatsViewProps> = ({
|
||||
cycles,
|
||||
setCreateUpdateCycleModal,
|
||||
setSelectedCycle,
|
||||
type,
|
||||
}) => {
|
||||
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
|
||||
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectCycleType>();
|
||||
|
||||
const handleDeleteCycle = (cycle: ICycle) => {
|
||||
setSelectedCycleForDelete({ ...cycle, actionType: "delete" });
|
||||
setCycleDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleEditCycle = (cycle: ICycle) => {
|
||||
setSelectedCycle({ ...cycle, actionType: "edit" });
|
||||
setCreateUpdateCycleModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteCycleModal
|
||||
isOpen={
|
||||
cycleDeleteModal &&
|
||||
!!selectedCycleForDelete &&
|
||||
selectedCycleForDelete.actionType === "delete"
|
||||
}
|
||||
setIsOpen={setCycleDeleteModal}
|
||||
data={selectedCycleForDelete}
|
||||
/>
|
||||
{cycles ? (
|
||||
cycles.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
||||
{cycles.map((cycle) => (
|
||||
<SingleCycleCard
|
||||
key={cycle.id}
|
||||
cycle={cycle}
|
||||
handleDeleteCycle={() => handleDeleteCycle(cycle)}
|
||||
handleEditCycle={() => handleEditCycle(cycle)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
||||
{type === "upcoming" ? (
|
||||
<UpcomingCycleIcon height="56" width="56" />
|
||||
) : type === "draft" ? (
|
||||
<CompletedCycleIcon height="56" width="56" />
|
||||
) : (
|
||||
<CurrentCycleIcon height="56" width="56" />
|
||||
)}
|
||||
<h3 className="text-gray-500">
|
||||
No {type} {type === "current" ? "cycle" : "cycles"} yet. Create with{" "}
|
||||
<pre className="inline rounded bg-gray-200 px-2 py-1">Q</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Loader.Item height="300px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,11 +1,18 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// toast
|
||||
import useToast from "hooks/use-toast";
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Button, CustomDatePicker, CustomSelect, Input, TextArea } from "components/ui";
|
||||
// types
|
||||
import { ICycle } from "types";
|
||||
// services
|
||||
import cyclesService from "services/cycles.service";
|
||||
// helper
|
||||
import { getDateRangeStatus } from "helpers/date-time.helper";
|
||||
|
||||
type Props = {
|
||||
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
|
||||
@ -17,17 +24,24 @@ type Props = {
|
||||
const defaultValues: Partial<ICycle> = {
|
||||
name: "",
|
||||
description: "",
|
||||
status: "draft",
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
};
|
||||
|
||||
export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status, data }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const [isDateValid, setIsDateValid] = useState(true);
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
reset,
|
||||
} = useForm<ICycle>({
|
||||
defaultValues,
|
||||
@ -41,6 +55,35 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
|
||||
});
|
||||
};
|
||||
|
||||
const cycleStatus =
|
||||
data?.start_date && data?.end_date
|
||||
? getDateRangeStatus(data?.start_date, data?.end_date) : "";
|
||||
|
||||
const dateChecker = async (payload: any) => {
|
||||
await cyclesService
|
||||
.cycleDateCheck(workspaceSlug as string, projectId as string, payload)
|
||||
.then((res) => {
|
||||
if (res.status) {
|
||||
setIsDateValid(true);
|
||||
} else {
|
||||
setIsDateValid(false);
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message:
|
||||
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
const checkEmptyDate =
|
||||
(watch("start_date") === "" && watch("end_date") === "") ||
|
||||
(!watch("start_date") && !watch("end_date"));
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
@ -84,30 +127,7 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-gray-500">Status</h6>
|
||||
<Controller
|
||||
name="status"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<CustomSelect
|
||||
{...field}
|
||||
label={<span className="capitalize">{field.value ?? "Select Status"}</span>}
|
||||
input
|
||||
>
|
||||
{[
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Started", value: "started" },
|
||||
{ label: "Completed", value: "completed" },
|
||||
].map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<div className="w-full">
|
||||
<h6 className="text-gray-500">Start Date</h6>
|
||||
@ -115,12 +135,19 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
rules={{ required: "Start date is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomDatePicker
|
||||
renderAs="input"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
watch("end_date") && cycleStatus != "current"
|
||||
? dateChecker({
|
||||
start_date: val,
|
||||
end_date: watch("end_date"),
|
||||
})
|
||||
: "";
|
||||
}}
|
||||
error={errors.start_date ? true : false}
|
||||
/>
|
||||
)}
|
||||
@ -136,12 +163,19 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
|
||||
<Controller
|
||||
control={control}
|
||||
name="end_date"
|
||||
rules={{ required: "End date is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomDatePicker
|
||||
renderAs="input"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
watch("start_date") && cycleStatus != "current"
|
||||
? dateChecker({
|
||||
start_date: watch("start_date"),
|
||||
end_date: val,
|
||||
})
|
||||
: "";
|
||||
}}
|
||||
error={errors.end_date ? true : false}
|
||||
/>
|
||||
)}
|
||||
@ -158,7 +192,18 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
|
||||
<Button theme="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className={
|
||||
checkEmptyDate
|
||||
? "cursor-pointer"
|
||||
: isDateValid
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed"
|
||||
}
|
||||
disabled={isSubmitting || checkEmptyDate ? false : isDateValid ? false : true}
|
||||
>
|
||||
{status
|
||||
? isSubmitting
|
||||
? "Updating Cycle..."
|
||||
|
@ -1,4 +1,5 @@
|
||||
export * from "./cycles-list-view";
|
||||
export * from "./completed-cycles-list";
|
||||
export * from "./cycles-list";
|
||||
export * from "./delete-cycle-modal";
|
||||
export * from "./form";
|
||||
export * from "./modal";
|
||||
|
@ -12,10 +12,16 @@ import cycleService from "services/cycles.service";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { CycleForm } from "components/cycles";
|
||||
// helper
|
||||
import { getDateRangeStatus } from "helpers/date-time.helper";
|
||||
// types
|
||||
import type { ICycle } from "types";
|
||||
// fetch keys
|
||||
import { CYCLE_LIST } from "constants/fetch-keys";
|
||||
import {
|
||||
CYCLE_COMPLETE_LIST,
|
||||
CYCLE_CURRENT_AND_UPCOMING_LIST,
|
||||
CYCLE_DRAFT_LIST,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
type CycleModalProps = {
|
||||
isOpen: boolean;
|
||||
@ -37,7 +43,19 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
|
||||
await cycleService
|
||||
.createCycle(workspaceSlug as string, projectId as string, payload)
|
||||
.then((res) => {
|
||||
mutate(CYCLE_LIST(projectId as string));
|
||||
switch (getDateRangeStatus(res.start_date, res.end_date)) {
|
||||
case "completed":
|
||||
mutate(CYCLE_COMPLETE_LIST(projectId as string));
|
||||
break;
|
||||
case "current":
|
||||
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
|
||||
break;
|
||||
case "upcoming":
|
||||
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
|
||||
break;
|
||||
default:
|
||||
mutate(CYCLE_DRAFT_LIST(projectId as string));
|
||||
}
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
@ -59,7 +77,19 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
|
||||
await cycleService
|
||||
.updateCycle(workspaceSlug as string, projectId as string, cycleId, payload)
|
||||
.then((res) => {
|
||||
mutate(CYCLE_LIST(projectId as string));
|
||||
switch (getDateRangeStatus(res.start_date, res.end_date)) {
|
||||
case "completed":
|
||||
mutate(CYCLE_COMPLETE_LIST(projectId as string));
|
||||
break;
|
||||
case "current":
|
||||
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
|
||||
break;
|
||||
case "upcoming":
|
||||
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
|
||||
break;
|
||||
default:
|
||||
mutate(CYCLE_DRAFT_LIST(projectId as string));
|
||||
}
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
@ -113,7 +143,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<CycleForm
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
handleClose={handleClose}
|
||||
|
@ -6,20 +6,22 @@ import Image from "next/image";
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Disclosure, Popover, Transition } from "@headlessui/react";
|
||||
import DatePicker from "react-datepicker";
|
||||
// icons
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
ChartPieIcon,
|
||||
LinkIcon,
|
||||
Squares2X2Icon,
|
||||
ArrowLongRightIcon,
|
||||
TrashIcon,
|
||||
UserIcon,
|
||||
DocumentDuplicateIcon,
|
||||
UserCircleIcon,
|
||||
ChevronDownIcon,
|
||||
DocumentIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { CustomSelect, Loader, ProgressBar } from "components/ui";
|
||||
import { CustomMenu, Loader, ProgressBar } from "components/ui";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
@ -29,38 +31,41 @@ import { SidebarProgressStats } from "components/core";
|
||||
import ProgressChart from "components/core/sidebar/progress-chart";
|
||||
import { DeleteCycleModal } from "components/cycles";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helper";
|
||||
import { groupBy } from "helpers/array.helper";
|
||||
import { renderDateFormat, renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
import { renderDateFormat, renderShortDate } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { CycleIssueResponse, ICycle, IIssue } from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_DETAILS } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { CYCLE_STATUS } from "constants/cycle";
|
||||
|
||||
type Props = {
|
||||
issues: IIssue[];
|
||||
cycle: ICycle | undefined;
|
||||
isOpen: boolean;
|
||||
cycleIssues: CycleIssueResponse[];
|
||||
cycleStatus: string;
|
||||
};
|
||||
|
||||
export const CycleDetailsSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cycleIssues }) => {
|
||||
export const CycleDetailsSidebar: React.FC<Props> = ({
|
||||
issues,
|
||||
cycle,
|
||||
isOpen,
|
||||
cycleIssues,
|
||||
cycleStatus,
|
||||
}) => {
|
||||
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
|
||||
const [startDateRange, setStartDateRange] = useState<Date | null>(new Date());
|
||||
const [endDateRange, setEndDateRange] = useState<Date | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||
|
||||
const [startDateRange, setStartDateRange] = useState<Date | null>(new Date());
|
||||
const [endDateRange, setEndDateRange] = useState<Date | null>(null);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const defaultValues: Partial<ICycle> = {
|
||||
start_date: new Date().toString(),
|
||||
end_date: new Date().toString(),
|
||||
status: cycle?.status,
|
||||
};
|
||||
|
||||
const groupedIssues = {
|
||||
@ -72,7 +77,7 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cy
|
||||
...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"),
|
||||
};
|
||||
|
||||
const { reset, watch, control } = useForm({
|
||||
const { reset } = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
@ -96,6 +101,25 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cy
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyText = () => {
|
||||
const originURL =
|
||||
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Cycle link copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (cycle)
|
||||
reset({
|
||||
@ -106,236 +130,291 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cy
|
||||
const isStartValid = new Date(`${cycle?.start_date}`) <= new Date();
|
||||
const isEndValid = new Date(`${cycle?.end_date}`) >= new Date(`${cycle?.start_date}`);
|
||||
|
||||
const progressPercentage = cycleIssues
|
||||
? Math.round((groupedIssues.completed.length / cycleIssues?.length) * 100)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<DeleteCycleModal isOpen={cycleDeleteModal} setIsOpen={setCycleDeleteModal} data={cycle} />
|
||||
<div
|
||||
className={`fixed top-0 ${
|
||||
isOpen ? "right-0" : "-right-[24rem]"
|
||||
} z-20 h-full w-[24rem] overflow-y-auto border-l bg-gray-50 p-5 duration-300`}
|
||||
} z-20 h-full w-[24rem] overflow-y-auto border-l bg-gray-50 py-5 duration-300`}
|
||||
>
|
||||
{cycle ? (
|
||||
<>
|
||||
<div className="flex gap-1 text-sm my-2">
|
||||
<div className="flex items-center ">
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
render={({ field: { value } }) => (
|
||||
<CustomSelect
|
||||
label={
|
||||
<span
|
||||
className={`flex items-center gap-1 text-left capitalize p-1 text-xs h-full w-full text-gray-900`}
|
||||
>
|
||||
<Squares2X2Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{watch("status")}
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
onChange={(value: any) => {
|
||||
submitChanges({ status: value });
|
||||
}}
|
||||
>
|
||||
{CYCLE_STATUS.map((option) => (
|
||||
<CustomSelect.Option key={option.value} value={option.value}>
|
||||
<span className="text-xs">{option.label}</span>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-center items-center gap-2 rounded-md border bg-transparent h-full p-2 px-4 text-xs font-medium text-gray-900 hover:bg-gray-100 hover:text-gray-900 focus:outline-none">
|
||||
<Popover className="flex justify-center items-center relative rounded-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center ${open ? "bg-gray-100" : ""}`}
|
||||
>
|
||||
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0 mr-2" />
|
||||
<span>
|
||||
{renderShortNumericDateFormat(`${cycle.start_date}`)
|
||||
? renderShortNumericDateFormat(`${cycle.start_date}`)
|
||||
: "N/A"}
|
||||
</span>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 -left-10 z-20 transform overflow-hidden">
|
||||
<DatePicker
|
||||
selected={startDateRange}
|
||||
onChange={(date) => {
|
||||
submitChanges({
|
||||
start_date: renderDateFormat(date),
|
||||
});
|
||||
setStartDateRange(date);
|
||||
}}
|
||||
selectsStart
|
||||
startDate={startDateRange}
|
||||
endDate={endDateRange}
|
||||
inline
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
<Popover className="flex justify-center items-center relative rounded-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center ${open ? "bg-gray-100" : ""}`}
|
||||
>
|
||||
<span>
|
||||
-{" "}
|
||||
{renderShortNumericDateFormat(`${cycle.end_date}`)
|
||||
? renderShortNumericDateFormat(`${cycle.end_date}`)
|
||||
: "N/A"}
|
||||
</span>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 -right-20 z-20 transform overflow-hidden">
|
||||
<DatePicker
|
||||
selected={endDateRange}
|
||||
onChange={(date) => {
|
||||
submitChanges({
|
||||
end_date: renderDateFormat(date),
|
||||
});
|
||||
setEndDateRange(date);
|
||||
}}
|
||||
selectsEnd
|
||||
startDate={startDateRange}
|
||||
endDate={endDateRange}
|
||||
minDate={startDateRange}
|
||||
inline
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pb-3">
|
||||
<h4 className="text-sm font-medium">{cycle.name}</h4>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border p-2 shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
onClick={() =>
|
||||
copyTextToClipboard(
|
||||
`https://app.plane.so/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`
|
||||
)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Cycle link copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Some error occurred",
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-50 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
onClick={() => setCycleDeleteModal(true)}
|
||||
>
|
||||
<TrashIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y-2 divide-gray-100 text-xs">
|
||||
<div className="py-1">
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
|
||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Owned by</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2 flex items-center gap-1">
|
||||
{cycle.owned_by &&
|
||||
(cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
|
||||
<div className="h-5 w-5 rounded-full border-2 border-transparent">
|
||||
<Image
|
||||
src={cycle.owned_by.avatar}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded-full"
|
||||
alt={cycle.owned_by?.first_name}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
|
||||
{cycle.owned_by?.first_name && cycle.owned_by.first_name !== ""
|
||||
? cycle.owned_by.first_name.charAt(0)
|
||||
: cycle.owned_by?.email.charAt(0)}
|
||||
</div>
|
||||
))}
|
||||
{cycle.owned_by.first_name !== ""
|
||||
? cycle.owned_by.first_name
|
||||
: cycle.owned_by.email}
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-center">
|
||||
<div className="flex gap-2.5 px-7 text-sm">
|
||||
<div className="flex items-center ">
|
||||
<span
|
||||
className={`flex items-center rounded border-[0.5px] border-gray-200 bg-gray-100 px-2.5 py-1.5 text-center text-sm capitalize text-gray-800 `}
|
||||
>
|
||||
{capitalizeFirstLetter(cycleStatus)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
|
||||
<ChartPieIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Progress</p>
|
||||
<div className="relative flex h-full w-52 items-center justify-center gap-2 text-sm text-gray-800">
|
||||
<Popover className="flex h-full items-center justify-center rounded-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex h-full items-center gap-1 rounded border-[0.5px] border-gray-200 bg-gray-100 px-2.5 py-1.5 text-gray-800 ${
|
||||
open ? "bg-gray-100" : ""
|
||||
}`}
|
||||
>
|
||||
<CalendarDaysIcon className="h-3 w-3" />
|
||||
<span>{renderShortDate(new Date(`${cycle?.start_date}`))}</span>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
|
||||
<DatePicker
|
||||
selected={startDateRange}
|
||||
onChange={(date) => {
|
||||
submitChanges({
|
||||
start_date: renderDateFormat(date),
|
||||
});
|
||||
setStartDateRange(date);
|
||||
}}
|
||||
selectsStart
|
||||
startDate={startDateRange}
|
||||
endDate={endDateRange}
|
||||
maxDate={endDateRange}
|
||||
shouldCloseOnSelect
|
||||
inline
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
<span>
|
||||
<ArrowLongRightIcon className="h-3 w-3" />
|
||||
</span>
|
||||
<Popover className="flex h-full items-center justify-center rounded-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center gap-1 rounded border-[0.5px] border-gray-200 bg-gray-100 px-2.5 py-1.5 text-gray-800 ${
|
||||
open ? "bg-gray-100" : ""
|
||||
}`}
|
||||
>
|
||||
<CalendarDaysIcon className="h-3 w-3 " />
|
||||
|
||||
<span>{renderShortDate(new Date(`${cycle?.end_date}`))}</span>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
|
||||
<DatePicker
|
||||
selected={endDateRange}
|
||||
onChange={(date) => {
|
||||
submitChanges({
|
||||
end_date: renderDateFormat(date),
|
||||
});
|
||||
setEndDateRange(date);
|
||||
}}
|
||||
selectsEnd
|
||||
startDate={startDateRange}
|
||||
endDate={endDateRange}
|
||||
// minDate={startDateRange}
|
||||
|
||||
inline
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 px-7 py-6">
|
||||
<div className="flex flex-col items-start justify-start gap-2 ">
|
||||
<div className="flex items-center justify-start gap-2 ">
|
||||
<h4 className="text-xl font-semibold text-gray-900">{cycle.name}</h4>
|
||||
<CustomMenu width="lg" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
<span className="flex items-center justify-start gap-2 text-gray-800">
|
||||
<DocumentDuplicateIcon className="h-4 w-4" />
|
||||
<span>Copy Link</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}>
|
||||
<span className="flex items-center justify-start gap-2 text-gray-800">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:basis-1/2">
|
||||
<div className="grid flex-shrink-0 place-items-center">
|
||||
|
||||
<span className="whitespace-normal text-sm leading-5 text-black">
|
||||
{cycle.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<div className="flex w-40 items-center justify-start gap-2">
|
||||
<UserCircleIcon className="h-5 w-5 text-gray-400" />
|
||||
<span>Lead</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
|
||||
<Image
|
||||
src={cycle.owned_by.avatar}
|
||||
height={12}
|
||||
width={12}
|
||||
className="rounded-full"
|
||||
alt={cycle.owned_by.first_name}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-800 capitalize text-white">
|
||||
{cycle.owned_by.first_name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-900">{cycle.owned_by.first_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<div className="flex w-40 items-center justify-start gap-2">
|
||||
<ChartPieIcon className="h-5 w-5 text-gray-400" />
|
||||
<span>Progress</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 text-gray-800">
|
||||
<span className="h-4 w-4">
|
||||
<ProgressBar
|
||||
value={groupedIssues.completed.length}
|
||||
maxValue={cycleIssues?.length}
|
||||
/>
|
||||
</span>
|
||||
{groupedIssues.completed.length}/{cycleIssues?.length}
|
||||
</div>
|
||||
{groupedIssues.completed.length}/{cycleIssues?.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center w-full gap-2 ">
|
||||
{isStartValid && isEndValid ? (
|
||||
<div className="relative h-[200px] w-full ">
|
||||
<ProgressChart
|
||||
issues={issues}
|
||||
start={cycle?.start_date ?? ""}
|
||||
end={cycle?.end_date ?? ""}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{issues.length > 0 ? (
|
||||
<SidebarProgressStats issues={issues} groupedIssues={groupedIssues} />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-gray-300 px-7 py-6 ">
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<div
|
||||
className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2 ">
|
||||
<div className="flex items-center justify-start gap-2 text-sm">
|
||||
<span className="font-medium text-gray-500">Progress</span>
|
||||
{!open && cycleIssues && progressPercentage ? (
|
||||
<span className="rounded bg-[#09A953]/10 px-1.5 py-0.5 text-xs text-[#09A953]">
|
||||
{progressPercentage ? `${progressPercentage}%` : ""}
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Disclosure.Button>
|
||||
<ChevronDownIcon
|
||||
className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
<Transition show={open}>
|
||||
<Disclosure.Panel>
|
||||
{isStartValid && isEndValid ? (
|
||||
<div className=" h-full w-full py-4">
|
||||
<div className="flex items-start justify-between gap-4 py-2 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>
|
||||
<DocumentIcon className="h-3 w-3 text-gray-500" />
|
||||
</span>
|
||||
<span>
|
||||
Pending Issues -{" "}
|
||||
{cycleIssues?.length - groupedIssues.completed.length}{" "}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-gray-900">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
|
||||
<span>Ideal</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
|
||||
<span>Current</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-40 w-80">
|
||||
<ProgressChart
|
||||
issues={issues}
|
||||
start={cycle?.start_date ?? ""}
|
||||
end={cycle?.end_date ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-gray-300 px-7 py-6 ">
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<div
|
||||
className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2 ">
|
||||
<div className="flex items-center justify-start gap-2 text-sm">
|
||||
<span className="font-medium text-gray-500">Other Information</span>
|
||||
</div>
|
||||
|
||||
<Disclosure.Button>
|
||||
<ChevronDownIcon
|
||||
className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
<Transition show={open}>
|
||||
<Disclosure.Panel>
|
||||
{issues.length > 0 ? (
|
||||
<div className=" h-full w-full py-4">
|
||||
<SidebarProgressStats issues={issues} groupedIssues={groupedIssues} />
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
@ -4,26 +4,38 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import cyclesService from "services/cycles.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Button, CustomMenu } from "components/ui";
|
||||
import { CustomMenu, LinearProgressIndicator, Tooltip } from "components/ui";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
|
||||
import { UserIcon } from "@heroicons/react/24/outline";
|
||||
import { CyclesIcon } from "components/icons";
|
||||
import { ChevronDownIcon, PencilIcon, StarIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
import { getDateRangeStatus, renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||
import { groupBy } from "helpers/array.helper";
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { capitalizeFirstLetter, copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
// types
|
||||
import { CycleIssueResponse, ICycle } from "types";
|
||||
import {
|
||||
CompletedCyclesResponse,
|
||||
CurrentAndUpcomingCyclesResponse,
|
||||
CycleIssueResponse,
|
||||
DraftCyclesResponse,
|
||||
ICycle,
|
||||
} from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_ISSUES } from "constants/fetch-keys";
|
||||
import {
|
||||
CYCLE_COMPLETE_LIST,
|
||||
CYCLE_CURRENT_AND_UPCOMING_LIST,
|
||||
CYCLE_DRAFT_LIST,
|
||||
CYCLE_ISSUES,
|
||||
CYCLE_LIST,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
type TSingleStatProps = {
|
||||
cycle: ICycle;
|
||||
@ -34,11 +46,11 @@ type TSingleStatProps = {
|
||||
const stateGroupColours: {
|
||||
[key: string]: string;
|
||||
} = {
|
||||
backlog: "#3f76ff",
|
||||
unstarted: "#ff9e9e",
|
||||
started: "#d687ff",
|
||||
cancelled: "#ff5353",
|
||||
completed: "#096e8d",
|
||||
backlog: "#DEE2E6",
|
||||
unstarted: "#26B5CE",
|
||||
started: "#F7AE59",
|
||||
cancelled: "#D687FF",
|
||||
completed: "#09A953",
|
||||
};
|
||||
|
||||
export const SingleCycleCard: React.FC<TSingleStatProps> = (props) => {
|
||||
@ -67,6 +79,130 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = (props) => {
|
||||
...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"),
|
||||
};
|
||||
|
||||
const handleAddToFavorites = () => {
|
||||
if (!workspaceSlug && !projectId && !cycle) return;
|
||||
|
||||
cyclesService
|
||||
.addCycleToFavorites(workspaceSlug as string, projectId as string, {
|
||||
cycle: cycle.id,
|
||||
})
|
||||
.then(() => {
|
||||
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
|
||||
|
||||
if (cycleStatus === "current" || cycleStatus === "upcoming")
|
||||
mutate<CurrentAndUpcomingCyclesResponse>(
|
||||
CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string),
|
||||
(prevData) => ({
|
||||
current_cycle: (prevData?.current_cycle ?? []).map((c) => ({
|
||||
...c,
|
||||
is_favorite: c.id === cycle.id ? true : c.is_favorite,
|
||||
})),
|
||||
upcoming_cycle: (prevData?.upcoming_cycle ?? []).map((c) => ({
|
||||
...c,
|
||||
is_favorite: c.id === cycle.id ? true : c.is_favorite,
|
||||
})),
|
||||
}),
|
||||
false
|
||||
);
|
||||
else if (cycleStatus === "completed")
|
||||
mutate<CompletedCyclesResponse>(
|
||||
CYCLE_COMPLETE_LIST(projectId as string),
|
||||
(prevData) => ({
|
||||
completed_cycles: (prevData?.completed_cycles ?? []).map((c) => ({
|
||||
...c,
|
||||
is_favorite: c.id === cycle.id ? true : c.is_favorite,
|
||||
})),
|
||||
}),
|
||||
false
|
||||
);
|
||||
else
|
||||
mutate<DraftCyclesResponse>(
|
||||
CYCLE_DRAFT_LIST(projectId as string),
|
||||
(prevData) => ({
|
||||
draft_cycles: (prevData?.draft_cycles ?? []).map((c) => ({
|
||||
...c,
|
||||
is_favorite: c.id === cycle.id ? true : c.is_favorite,
|
||||
})),
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Successfully added the cycle to favorites.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!workspaceSlug || !cycle) return;
|
||||
|
||||
cyclesService
|
||||
.removeCycleFromFavorites(workspaceSlug as string, projectId as string, cycle.id)
|
||||
.then(() => {
|
||||
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
|
||||
|
||||
if (cycleStatus === "current" || cycleStatus === "upcoming")
|
||||
mutate<CurrentAndUpcomingCyclesResponse>(
|
||||
CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string),
|
||||
(prevData) => ({
|
||||
current_cycle: (prevData?.current_cycle ?? []).map((c) => ({
|
||||
...c,
|
||||
is_favorite: c.id === cycle.id ? false : c.is_favorite,
|
||||
})),
|
||||
upcoming_cycle: (prevData?.upcoming_cycle ?? []).map((c) => ({
|
||||
...c,
|
||||
is_favorite: c.id === cycle.id ? false : c.is_favorite,
|
||||
})),
|
||||
}),
|
||||
false
|
||||
);
|
||||
else if (cycleStatus === "completed")
|
||||
mutate<CompletedCyclesResponse>(
|
||||
CYCLE_COMPLETE_LIST(projectId as string),
|
||||
(prevData) => ({
|
||||
completed_cycles: (prevData?.completed_cycles ?? []).map((c) => ({
|
||||
...c,
|
||||
is_favorite: c.id === cycle.id ? false : c.is_favorite,
|
||||
})),
|
||||
}),
|
||||
false
|
||||
);
|
||||
else
|
||||
mutate<DraftCyclesResponse>(
|
||||
CYCLE_DRAFT_LIST(projectId as string),
|
||||
(prevData) => ({
|
||||
draft_cycles: (prevData?.draft_cycles ?? []).map((c) => ({
|
||||
...c,
|
||||
is_favorite: c.id === cycle.id ? false : c.is_favorite,
|
||||
})),
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Successfully removed the cycle from favorites.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the cycle from favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyText = () => {
|
||||
const originURL =
|
||||
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
@ -82,100 +218,148 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = (props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const progressIndicatorData = Object.keys(groupedIssues).map((group, index) => ({
|
||||
id: index,
|
||||
name: capitalizeFirstLetter(group),
|
||||
value:
|
||||
cycleIssues && cycleIssues.length > 0
|
||||
? (groupedIssues[group].length / cycleIssues.length) * 100
|
||||
: 0,
|
||||
color: stateGroupColours[group],
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border bg-white p-3">
|
||||
<div className="grid grid-cols-9 gap-2 divide-x">
|
||||
<div className="col-span-3 flex flex-col space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId as string}/cycles/${cycle.id}`}>
|
||||
<a>
|
||||
<h2 className="font-medium w-full max-w-[175px] lg:max-w-[225px] xl:max-w-[300px] text-ellipsis overflow-hidden">
|
||||
{cycle.name}
|
||||
</h2>
|
||||
</a>
|
||||
</Link>
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={handleEditCycle}>Edit cycle</CustomMenu.MenuItem>
|
||||
<div className="h-full w-full">
|
||||
<div className="flex flex-col rounded-[10px] bg-white text-xs shadow">
|
||||
<div className="flex h-full flex-col gap-4 rounded-b-[10px] px-5 py-5">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
|
||||
<a className="w-full">
|
||||
<Tooltip tooltipContent={cycle.name} position="top-left">
|
||||
<h3 className="text-xl font-semibold leading-5 ">
|
||||
{truncateText(cycle.name, 75)}
|
||||
</h3>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
{cycle.is_favorite ? (
|
||||
<button onClick={handleRemoveFromFavorites}>
|
||||
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleAddToFavorites}>
|
||||
<StarIcon className="h-4 w-4 " color="#858E96" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start gap-5">
|
||||
<div className="flex items-start gap-1 ">
|
||||
<CalendarDaysIcon className="h-4 w-4 text-gray-900" />
|
||||
<span className="text-gray-400">Start :</span>
|
||||
<span>{renderShortDateWithYearFormat(startDate)}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-1 ">
|
||||
<CalendarDaysIcon className="h-4 w-4 text-gray-900" />
|
||||
<span className="text-gray-400">End :</span>
|
||||
<span>{renderShortDateWithYearFormat(endDate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-col rounded-b-[10px]">
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
|
||||
<Image
|
||||
src={cycle.owned_by.avatar}
|
||||
height={16}
|
||||
width={16}
|
||||
className="rounded-full"
|
||||
alt={cycle.owned_by.first_name}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-800 capitalize text-white">
|
||||
{cycle.owned_by.first_name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-900">{cycle.owned_by.first_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center ">
|
||||
<button
|
||||
onClick={handleEditCycle}
|
||||
className="flex cursor-pointer items-center rounded p-1 duration-300 hover:bg-gray-100"
|
||||
>
|
||||
<span>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<CustomMenu width="auto" verticalEllipsis>
|
||||
<CustomMenu.MenuItem onClick={handleDeleteCycle}>Delete cycle</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>Copy cycle link</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-x-2 gap-y-3 text-xs">
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<CalendarDaysIcon className="h-4 w-4" />
|
||||
Cycle dates
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
{renderShortNumericDateFormat(startDate)} - {renderShortNumericDateFormat(endDate)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<UserIcon className="h-4 w-4" />
|
||||
Created by
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-2">
|
||||
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
|
||||
<Image
|
||||
src={cycle.owned_by.avatar}
|
||||
height={16}
|
||||
width={16}
|
||||
className="rounded-full"
|
||||
alt={cycle.owned_by.first_name}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid h-5 w-5 place-items-center rounded-full bg-gray-700 capitalize text-white">
|
||||
{cycle.owned_by.first_name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
{cycle.owned_by.first_name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full items-end">
|
||||
<Button
|
||||
theme="secondary"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() =>
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`)
|
||||
}
|
||||
</div>
|
||||
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<div
|
||||
className={`flex h-full w-full flex-col border-t border-gray-200 bg-gray-100 ${
|
||||
open ? "" : "flex-row"
|
||||
}`}
|
||||
>
|
||||
<CyclesIcon className="h-3 w-3" />
|
||||
Open Cycle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-3 px-5">
|
||||
<h4 className="text-sm tracking-widest">PROGRESS</h4>
|
||||
<div className="space-y-3 text-xs">
|
||||
{Object.keys(groupedIssues).map((group) => (
|
||||
<div key={group} className="flex items-center gap-2">
|
||||
<div className="flex basis-2/3 items-center gap-2">
|
||||
<span
|
||||
className="block h-2 w-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: stateGroupColours[group],
|
||||
}}
|
||||
<div className="flex w-full items-center gap-2 px-5 py-4 ">
|
||||
<span> Progress </span>
|
||||
<LinearProgressIndicator data={progressIndicatorData} />
|
||||
<Disclosure.Button>
|
||||
<ChevronDownIcon
|
||||
className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h6 className="text-xs capitalize">{group}</h6>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
{groupedIssues[group].length}{" "}
|
||||
<span className="text-gray-500">
|
||||
-{" "}
|
||||
{cycleIssues && cycleIssues.length > 0
|
||||
? `${Math.round(
|
||||
(groupedIssues[group].length / cycleIssues.length) * 100
|
||||
)}%`
|
||||
: "0%"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Transition show={open}>
|
||||
<Disclosure.Panel>
|
||||
<div className="overflow-hidden rounded-b-md bg-white p-3 shadow">
|
||||
<div className="col-span-2 space-y-3 px-5">
|
||||
<div className="space-y-3 text-xs">
|
||||
{Object.keys(groupedIssues).map((group) => (
|
||||
<div key={group} className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-2 w-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: stateGroupColours[group],
|
||||
}}
|
||||
/>
|
||||
<h6 className="text-xs capitalize">{group}</h6>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
{groupedIssues[group].length}{" "}
|
||||
<span className="text-gray-500">
|
||||
-{" "}
|
||||
{cycleIssues && cycleIssues.length > 0
|
||||
? `${Math.round(
|
||||
(groupedIssues[group].length / cycleIssues.length) * 100
|
||||
)}%`
|
||||
: "0%"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -44,7 +44,7 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
|
||||
return (
|
||||
<Popover className="relative z-[1]" ref={ref}>
|
||||
<Popover.Button
|
||||
className="rounded-md border border-gray-300 p-2 outline-none sm:text-sm"
|
||||
className="rounded-full bg-gray-100 p-2 outline-none sm:text-sm"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
{label}
|
||||
@ -58,10 +58,10 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Popover.Panel className="absolute z-10 mt-2 w-80 rounded-md bg-white shadow-lg">
|
||||
<Popover.Panel className="absolute z-10 mt-2 w-80 rounded-lg bg-white shadow-lg">
|
||||
<div className="h-72 w-80 overflow-auto rounded border bg-white p-2 shadow-2xl">
|
||||
<Tab.Group as="div" className="flex h-full w-full flex-col">
|
||||
<Tab.List className="flex-0 -mx-2 flex justify-around gap-1 rounded border-b p-1">
|
||||
<Tab.List className="flex-0 -mx-2 flex justify-around gap-1 border-b p-1">
|
||||
{tabOptions.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
@ -75,16 +75,16 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<Tab.Panel className="h-full w-full">
|
||||
<Tab.Panels className="flex-1 overflow-y-auto">
|
||||
<Tab.Panel>
|
||||
{recentEmojis.length > 0 && (
|
||||
<div className="w-full py-2">
|
||||
<h3 className="mb-2 text-lg">Recent Emojis</h3>
|
||||
<div className="py-2">
|
||||
<h3 className="mb-2">Recent Emojis</h3>
|
||||
<div className="grid grid-cols-9 gap-2">
|
||||
{recentEmojis.map((emoji) => (
|
||||
<button
|
||||
type="button"
|
||||
className="select-none text-xl"
|
||||
className="select-none text-lg hover:bg-hover-gray"
|
||||
key={emoji}
|
||||
onClick={() => {
|
||||
onChange(emoji);
|
||||
@ -97,13 +97,13 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="py-3">
|
||||
<h3 className="mb-2 text-lg">All Emojis</h3>
|
||||
<div>
|
||||
<h3 className="mb-2">All Emojis</h3>
|
||||
<div className="grid grid-cols-9 gap-2">
|
||||
{emojis.map((emoji) => (
|
||||
<button
|
||||
type="button"
|
||||
className="select-none text-xl"
|
||||
className="select-none text-lg hover:bg-hover-gray"
|
||||
key={emoji}
|
||||
onClick={() => {
|
||||
onChange(emoji);
|
||||
|
20
apps/app/components/icons/assignment-clipboard-icon.tsx
Normal file
20
apps/app/components/icons/assignment-clipboard-icon.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const AssignmentClipboardIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M2.125 19.25C1.74306 19.25 1.4184 19.1163 1.15104 18.8489C0.883681 18.5816 0.75 18.2569 0.75 17.875V4.12499C0.75 3.74305 0.883681 3.41839 1.15104 3.15103C1.4184 2.88367 1.74306 2.74999 2.125 2.74999H6.82292C6.89931 2.21527 7.14375 1.77603 7.55625 1.43228C7.96875 1.08853 8.45 0.916656 9 0.916656C9.55 0.916656 10.0312 1.08853 10.4438 1.43228C10.8562 1.77603 11.1007 2.21527 11.1771 2.74999H15.875C16.2569 2.74999 16.5816 2.88367 16.849 3.15103C17.1163 3.41839 17.25 3.74305 17.25 4.12499V17.875C17.25 18.2569 17.1163 18.5816 16.849 18.8489C16.5816 19.1163 16.2569 19.25 15.875 19.25H2.125ZM2.125 17.875H15.875V4.12499H2.125V17.875ZM4.41667 15.5833H10.6729V14.2083H4.41667V15.5833ZM4.41667 11.6875H13.5833V10.3125H4.41667V11.6875ZM4.41667 7.79166H13.5833V6.41666H4.41667V7.79166ZM9 3.73541C9.21389 3.73541 9.40104 3.6552 9.56146 3.49478C9.72188 3.33436 9.80208 3.14721 9.80208 2.93332C9.80208 2.71943 9.72188 2.53228 9.56146 2.37186C9.40104 2.21145 9.21389 2.13124 9 2.13124C8.78611 2.13124 8.59896 2.21145 8.43854 2.37186C8.27812 2.53228 8.19792 2.71943 8.19792 2.93332C8.19792 3.14721 8.27812 3.33436 8.43854 3.49478C8.59896 3.6552 8.78611 3.73541 9 3.73541ZM2.125 17.875V4.12499V17.875Z" />
|
||||
</svg>
|
||||
);
|
21
apps/app/components/icons/backlog-state-icon.tsx
Normal file
21
apps/app/components/icons/backlog-state-icon.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const BacklogStateIcon: React.FC<Props> = ({
|
||||
width = "20",
|
||||
height = "20",
|
||||
className,
|
||||
color = "#858e96",
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="10" cy="10" r="9" stroke={color} strokeLinecap="round" strokeDasharray="4 4" />
|
||||
</svg>
|
||||
);
|
78
apps/app/components/icons/cancelled-state-icon.tsx
Normal file
78
apps/app/components/icons/cancelled-state-icon.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const CancelledStateIcon: React.FC<Props> = ({
|
||||
width = "20",
|
||||
height = "20",
|
||||
className,
|
||||
color = "#f2655a",
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 84.36 84.36"
|
||||
>
|
||||
<g id="Layer_2" data-name="Layer 2">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.45,7.69a39.74,39.74,0,0,1,43.43.54"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M76.67,20.45a39.76,39.76,0,0,1-.53,43.43"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M63.92,76.67a39.78,39.78,0,0,1-43.44-.53"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.69,63.92a39.75,39.75,0,0,1,.54-43.44"
|
||||
/>
|
||||
<circle className="cls-2" fill={color} cx="42.18" cy="42.18" r="31.04" />
|
||||
<path
|
||||
className="cls-3"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke="#ffffff"
|
||||
strokeLinecap="square"
|
||||
strokeMiterlimit={10}
|
||||
d="M32.64,32.44q9.54,9.75,19.09,19.48"
|
||||
/>
|
||||
<path
|
||||
className="cls-3"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke="#ffffff"
|
||||
strokeLinecap="square"
|
||||
strokeMiterlimit={10}
|
||||
d="M32.64,51.92,51.73,32.44"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
69
apps/app/components/icons/completed-state-icon.tsx
Normal file
69
apps/app/components/icons/completed-state-icon.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const CompletedStateIcon: React.FC<Props> = ({
|
||||
width = "20",
|
||||
height = "20",
|
||||
className,
|
||||
color = "#438af3",
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 84.36 84.36"
|
||||
>
|
||||
<g id="Layer_2" data-name="Layer 2">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.45,7.69a39.74,39.74,0,0,1,43.43.54"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M76.67,20.45a39.76,39.76,0,0,1-.53,43.43"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M63.92,76.67a39.78,39.78,0,0,1-43.44-.53"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.69,63.92a39.75,39.75,0,0,1,.54-43.44"
|
||||
/>
|
||||
<circle className="cls-2" fill={color} cx="42.18" cy="42.18" r="31.04" />
|
||||
<path
|
||||
className="cls-3"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke="#ffffff"
|
||||
strokeLinecap="square"
|
||||
strokeMiterlimit={10}
|
||||
d="M30.45,43.75l6.61,6.61L53.92,34"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
25
apps/app/components/icons/contrast-icon.tsx
Normal file
25
apps/app/components/icons/contrast-icon.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const ContrastIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
color = "#858E96",
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="9" cy="9" r="5.4375" stroke={color} strokeLinecap="round" />
|
||||
<path
|
||||
fill={color}
|
||||
d="M9 5.81247C9 5.39825 9.33876 5.05526 9.74548 5.13368C10.0057 5.18385 10.2608 5.26029 10.5068 5.36219C10.9845 5.56007 11.4186 5.8501 11.7842 6.21574C12.1499 6.58137 12.4399 7.01543 12.6378 7.49315C12.8357 7.97087 12.9375 8.48289 12.9375 8.99997C12.9375 9.51705 12.8357 10.0291 12.6378 10.5068C12.4399 10.9845 12.1499 11.4186 11.7842 11.7842C11.4186 12.1498 10.9845 12.4399 10.5068 12.6377C10.2608 12.7396 10.0057 12.8161 9.74548 12.8663C9.33876 12.9447 9 12.6017 9 12.1875L9 5.81247Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
24
apps/app/components/icons/grid-view-icons.tsx
Normal file
24
apps/app/components/icons/grid-view-icons.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const GridViewIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
color = "#858E96",
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill={color}
|
||||
d="M2.125 8.3125C1.74688 8.3125 1.42318 8.17786 1.15391 7.90859C0.884635 7.63932 0.75 7.31563 0.75 6.9375V2.125C0.75 1.74688 0.884635 1.42318 1.15391 1.15391C1.42318 0.884635 1.74688 0.75 2.125 0.75H6.9375C7.31563 0.75 7.63932 0.884635 7.90859 1.15391C8.17786 1.42318 8.3125 1.74688 8.3125 2.125V6.9375C8.3125 7.31563 8.17786 7.63932 7.90859 7.90859C7.63932 8.17786 7.31563 8.3125 6.9375 8.3125H2.125ZM2.125 17.25C1.74688 17.25 1.42318 17.1154 1.15391 16.8461C0.884635 16.5768 0.75 16.2531 0.75 15.875V11.0625C0.75 10.6844 0.884635 10.3607 1.15391 10.0914C1.42318 9.82214 1.74688 9.6875 2.125 9.6875H6.9375C7.31563 9.6875 7.63932 9.82214 7.90859 10.0914C8.17786 10.3607 8.3125 10.6844 8.3125 11.0625V15.875C8.3125 16.2531 8.17786 16.5768 7.90859 16.8461C7.63932 17.1154 7.31563 17.25 6.9375 17.25H2.125ZM11.0625 8.3125C10.6844 8.3125 10.3607 8.17786 10.0914 7.90859C9.82214 7.63932 9.6875 7.31563 9.6875 6.9375V2.125C9.6875 1.74688 9.82214 1.42318 10.0914 1.15391C10.3607 0.884635 10.6844 0.75 11.0625 0.75H15.875C16.2531 0.75 16.5768 0.884635 16.8461 1.15391C17.1154 1.42318 17.25 1.74688 17.25 2.125V6.9375C17.25 7.31563 17.1154 7.63932 16.8461 7.90859C16.5768 8.17786 16.2531 8.3125 15.875 8.3125H11.0625ZM11.0625 17.25C10.6844 17.25 10.3607 17.1154 10.0914 16.8461C9.82214 16.5768 9.6875 16.2531 9.6875 15.875V11.0625C9.6875 10.6844 9.82214 10.3607 10.0914 10.0914C10.3607 9.82214 10.6844 9.6875 11.0625 9.6875H15.875C16.2531 9.6875 16.5768 9.82214 16.8461 10.0914C17.1154 10.3607 17.25 10.6844 17.25 11.0625V15.875C17.25 16.2531 17.1154 16.5768 16.8461 16.8461C16.5768 17.1154 16.2531 17.25 15.875 17.25H11.0625ZM2.125 6.9375H6.9375V2.125H2.125V6.9375ZM11.0625 6.9375H15.875V2.125H11.0625V6.9375ZM11.0625 15.875H15.875V11.0625H11.0625V15.875ZM2.125 15.875H6.9375V11.0625H2.125V15.875Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
@ -2,21 +2,26 @@ import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const HeartbeatIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 12H7.5L9 6L13 18L15 9L16.5 12H21"
|
||||
stroke="black"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const HeartbeatIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
color = "#858E96",
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2 8H5L6 4L8.66667 12L10 6L11 8H14"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
@ -1,12 +1,15 @@
|
||||
export * from "./attachment-icon";
|
||||
export * from "./backlog-state-icon";
|
||||
export * from "./blocked-icon";
|
||||
export * from "./blocker-icon";
|
||||
export * from "./bolt-icon";
|
||||
export * from "./calendar-month-icon";
|
||||
export * from "./cancel-icon";
|
||||
export * from "./cancelled-state-icon";
|
||||
export * from "./clipboard-icon";
|
||||
export * from "./comment-icon";
|
||||
export * from "./completed-cycle-icon";
|
||||
export * from "./completed-state-icon";
|
||||
export * from "./current-cycle-icon";
|
||||
export * from "./cycle-icon";
|
||||
export * from "./discord-icon";
|
||||
@ -16,6 +19,7 @@ export * from "./ellipsis-horizontal-icon";
|
||||
export * from "./external-link-icon";
|
||||
export * from "./github-icon";
|
||||
export * from "./heartbeat-icon";
|
||||
export * from "./started-state-icon";
|
||||
export * from "./layer-diagonal-icon";
|
||||
export * from "./lock-icon";
|
||||
export * from "./menu-icon";
|
||||
@ -23,9 +27,17 @@ export * from "./plus-icon";
|
||||
export * from "./question-mark-circle-icon";
|
||||
export * from "./setting-icon";
|
||||
export * from "./signal-cellular-icon";
|
||||
export * from "./started-state-icon";
|
||||
export * from "./state-group-icon";
|
||||
export * from "./tag-icon";
|
||||
export * from "./tune-icon";
|
||||
export * from "./unstarted-state-icon";
|
||||
export * from "./upcoming-cycle-icon";
|
||||
export * from "./user-group-icon";
|
||||
export * from "./user-icon-circle";
|
||||
export * from "./user-icon";
|
||||
export * from "./grid-view-icons";
|
||||
export * from "./assignment-clipboard-icon";
|
||||
export * from "./tick-mark-icon";
|
||||
export * from "./contrast-icon";
|
||||
export * from "./people-group-icon";
|
||||
|
@ -6,19 +6,21 @@ export const LayerDiagonalIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
className,
|
||||
color = "black",
|
||||
color = "#858E96",
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.6004 4.20111C12.6005 4.10308 12.5766 4.00653 12.5307 3.91989C12.4849 3.83325 12.4185 3.75916 12.3374 3.7041C12.2563 3.64905 12.1629 3.6147 12.0655 3.60407C11.968 3.59343 11.8695 3.60684 11.7784 3.64311L4.73799 6.43191C4.4024 6.56473 4.11449 6.79536 3.91163 7.09387C3.70877 7.39239 3.60033 7.74499 3.60039 8.10591V14.9963C3.60037 15.0942 3.6243 15.1906 3.67009 15.2771C3.71587 15.3636 3.78212 15.4376 3.86306 15.4926C3.94401 15.5476 4.03718 15.582 4.13446 15.5928C4.23175 15.6035 4.33018 15.5903 4.42119 15.5543L4.80039 15.4043V16.6943C4.52882 16.7903 4.23817 16.8198 3.95286 16.7802C3.66755 16.7405 3.39592 16.633 3.16077 16.4667C2.92563 16.3003 2.73385 16.0799 2.60153 15.8241C2.46922 15.5682 2.40024 15.2844 2.40039 14.9963V8.10591C2.40029 7.50438 2.58101 6.91671 2.91912 6.41919C3.25722 5.92166 3.73707 5.53727 4.29639 5.31591L11.3368 2.52711C11.6011 2.42232 11.8864 2.38163 12.1695 2.40837C12.4526 2.43511 12.7252 2.52852 12.9652 2.68095C13.2052 2.83337 13.4057 3.04049 13.5503 3.28532C13.6948 3.53015 13.7793 3.80574 13.7968 4.08951L12.5992 4.56231V4.20111H12.6004ZM16.2004 6.60111C16.2003 6.50318 16.1762 6.40677 16.1303 6.32029C16.0844 6.23381 16.018 6.15988 15.9369 6.10496C15.8558 6.05005 15.7625 6.01581 15.6652 6.00523C15.5678 5.99466 15.4694 6.00808 15.3784 6.04431L7.95879 8.98311C7.73506 9.07165 7.54312 9.22541 7.40788 9.42442C7.27264 9.62343 7.20035 9.8585 7.20039 10.0991V17.3975C7.20037 17.4954 7.2243 17.5918 7.27009 17.6783C7.31587 17.7648 7.38212 17.8388 7.46306 17.8938C7.54401 17.9488 7.63718 17.9832 7.73446 17.994C7.83175 18.0047 7.93018 17.9915 8.02119 17.9555L9.60039 17.3279V18.6191L8.46399 19.0691C8.1911 19.1773 7.89587 19.2172 7.60405 19.1852C7.31223 19.1531 7.03267 19.0502 6.78974 18.8854C6.54681 18.7206 6.34788 18.4988 6.2103 18.2395C6.07272 17.9801 6.00065 17.6911 6.00039 17.3975V10.0979C6.00031 9.61668 6.14489 9.14655 6.41537 8.74853C6.68585 8.35051 7.06974 8.043 7.51719 7.86591L14.9368 4.92831C15.2096 4.82033 15.5047 4.78068 15.7964 4.81283C16.088 4.84497 16.3674 4.94793 16.6102 5.11273C16.8529 5.27753 17.0517 5.49919 17.1893 5.75839C17.3268 6.01759 17.3989 6.30648 17.3992 6.59991V6.72351L16.2004 7.19991V6.59991V6.60111ZM19.5796 8.44311C19.6705 8.40713 19.7688 8.39391 19.866 8.4046C19.9632 8.4153 20.0563 8.44958 20.1372 8.50447C20.2181 8.55936 20.2844 8.63319 20.3303 8.71954C20.3761 8.80589 20.4002 8.90213 20.4004 8.99991V16.9487C20.4002 17.0688 20.3639 17.1861 20.2963 17.2853C20.2287 17.3846 20.1329 17.4613 20.0212 17.5055L12.8212 20.3579C12.7302 20.3939 12.6317 20.4071 12.5345 20.3964C12.4372 20.3856 12.344 20.3512 12.2631 20.2962C12.1821 20.2412 12.1159 20.1672 12.0701 20.0807C12.0243 19.9942 12.0004 19.8978 12.0004 19.7999V11.8523C12.0004 11.732 12.0365 11.6145 12.1041 11.515C12.1718 11.4155 12.2677 11.3386 12.3796 11.2943L19.5796 8.44311ZM21.6004 8.99991C21.6002 8.70638 21.5283 8.41735 21.3909 8.15799C21.2535 7.89863 21.0547 7.67681 20.8119 7.51187C20.5691 7.34693 20.2896 7.24386 19.9979 7.21166C19.7061 7.17946 19.4109 7.21909 19.138 7.32711L11.938 10.1783C11.6024 10.3111 11.3145 10.5418 11.1116 10.8403C10.9088 11.1388 10.8003 11.4914 10.8004 11.8523V19.7999C10.8003 20.0935 10.8721 20.3827 11.0095 20.6422C11.1468 20.9018 11.3456 21.1237 11.5884 21.2888C11.8312 21.4539 12.1108 21.5571 12.4026 21.5893C12.6945 21.6216 12.9898 21.582 13.2628 21.4739L20.4628 18.6215C20.7982 18.4888 21.086 18.2583 21.2888 17.96C21.4917 17.6618 21.6002 17.3094 21.6004 16.9487V8.99991Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.40034 2.80076C8.40041 2.73541 8.38446 2.67104 8.35389 2.61328C8.32332 2.55552 8.27907 2.50613 8.225 2.46942C8.17093 2.43272 8.1087 2.40982 8.04373 2.40273C7.97877 2.39564 7.91306 2.40458 7.85234 2.42876L3.15874 4.28796C2.93501 4.3765 2.74307 4.53026 2.60783 4.72927C2.47259 4.92828 2.4003 5.16335 2.40034 5.40396V9.99756C2.40033 10.0628 2.41628 10.1271 2.44681 10.1847C2.47733 10.2424 2.5215 10.2917 2.57546 10.3284C2.62942 10.3651 2.69154 10.388 2.75639 10.3952C2.82125 10.4024 2.88687 10.3936 2.94754 10.3696L3.20034 10.2696V11.1296C3.01929 11.1936 2.82553 11.2132 2.63532 11.1868C2.44512 11.1604 2.26403 11.0887 2.10726 10.9778C1.9505 10.8669 1.82265 10.72 1.73444 10.5494C1.64623 10.3788 1.60024 10.1896 1.60034 9.99756V5.40396C1.60027 5.00294 1.72076 4.61116 1.94616 4.27948C2.17156 3.9478 2.49146 3.69153 2.86434 3.54396L7.55794 1.68476C7.73414 1.6149 7.92438 1.58777 8.11308 1.6056C8.30178 1.62343 8.48358 1.6857 8.64358 1.78732C8.80358 1.88894 8.93723 2.02701 9.03359 2.19023C9.12994 2.35345 9.18627 2.53718 9.19794 2.72636L8.39954 3.04156V2.80076H8.40034ZM10.8003 4.40076C10.8003 4.33548 10.7842 4.2712 10.7536 4.21355C10.723 4.15589 10.6787 4.10661 10.6247 4.07C10.5706 4.03339 10.5084 4.01056 10.4435 4.00351C10.3786 3.99646 10.313 4.0054 10.2523 4.02956L5.30594 5.98876C5.15679 6.04779 5.02883 6.15029 4.93867 6.28297C4.84851 6.41564 4.80031 6.57235 4.80034 6.73276V11.5984C4.80033 11.6636 4.81628 11.7279 4.84681 11.7855C4.87733 11.8432 4.9215 11.8925 4.97546 11.9292C5.02942 11.9659 5.09154 11.9888 5.15639 11.996C5.22125 12.0032 5.28687 11.9944 5.34754 11.9704L6.40034 11.552V12.4128L5.64274 12.7128C5.46081 12.7849 5.264 12.8115 5.06945 12.7901C4.8749 12.7688 4.68853 12.7002 4.52658 12.5903C4.36462 12.4804 4.232 12.3326 4.14028 12.1597C4.04856 11.9868 4.00052 11.7941 4.00034 11.5984V6.73196C4.00029 6.41114 4.09668 6.09772 4.277 5.83237C4.45732 5.56703 4.71324 5.36202 5.01154 5.24396L9.95794 3.28556C10.1398 3.21357 10.3366 3.18714 10.531 3.20857C10.7254 3.23 10.9117 3.29864 11.0735 3.40851C11.2354 3.51838 11.3679 3.66615 11.4596 3.83895C11.5513 4.01175 11.5993 4.20434 11.5995 4.39996V4.48236L10.8003 4.79996V4.39996V4.40076ZM13.0531 5.62876C13.1138 5.60477 13.1793 5.59596 13.2441 5.60309C13.3089 5.61022 13.371 5.63307 13.4249 5.66967C13.4788 5.70626 13.523 5.75548 13.5536 5.81305C13.5842 5.87061 13.6002 5.93478 13.6003 5.99996V11.2992C13.6002 11.3792 13.576 11.4574 13.531 11.5236C13.4859 11.5898 13.422 11.6409 13.3475 11.6704L8.54754 13.572C8.48687 13.596 8.42125 13.6048 8.35639 13.5976C8.29154 13.5904 8.22942 13.5675 8.17546 13.5308C8.1215 13.4941 8.07733 13.4448 8.04681 13.3871C8.01628 13.3295 8.00033 13.2652 8.00034 13.2V7.90156C8.00033 7.82136 8.02443 7.743 8.06951 7.67666C8.11459 7.61033 8.17857 7.55907 8.25314 7.52956L13.0531 5.62876V5.62876ZM14.4003 5.99996C14.4002 5.80428 14.3523 5.61159 14.2607 5.43868C14.1691 5.26577 14.0365 5.11789 13.8747 5.00793C13.7128 4.89797 13.5265 4.82926 13.332 4.80779C13.1375 4.78633 12.9407 4.81275 12.7587 4.88476L7.95874 6.78556C7.73502 6.8741 7.54307 7.02786 7.40783 7.22687C7.27259 7.42588 7.2003 7.66095 7.20034 7.90156V13.2C7.20031 13.3957 7.24816 13.5885 7.33973 13.7615C7.4313 13.9345 7.56381 14.0825 7.72569 14.1926C7.88758 14.3026 8.07393 14.3714 8.26849 14.3929C8.46306 14.4144 8.65993 14.388 8.84194 14.316L13.6419 12.4144C13.8655 12.3259 14.0574 12.1722 14.1926 11.9734C14.3279 11.7745 14.4002 11.5396 14.4003 11.2992V5.99996Z"
|
||||
fill={color}
|
||||
stroke={color}
|
||||
strokeWidth="0.25"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
24
apps/app/components/icons/people-group-icon.tsx
Normal file
24
apps/app/components/icons/people-group-icon.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const PeopleGroupIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "16",
|
||||
color = "#858E96",
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 20 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill={color}
|
||||
d="M12.2951 6.66667C13.1001 6.66667 13.7534 7.18933 13.7534 7.83333V10.9993C13.7534 11.7952 13.3582 12.5584 12.6548 13.1211C11.9514 13.6839 10.9974 14 10.0026 14C9.0078 14 8.05376 13.6839 7.35034 13.1211C6.64692 12.5584 6.25175 11.7952 6.25175 10.9993V7.83333C6.25175 7.18933 6.90425 6.66667 7.71008 6.66667H12.2951ZM12.2951 7.66667H7.71008C7.65483 7.66667 7.60184 7.68423 7.56277 7.71548C7.5237 7.74674 7.50175 7.78913 7.50175 7.83333V10.9993C7.50175 11.5299 7.76523 12.0388 8.23422 12.414C8.70322 12.7892 9.33932 13 10.0026 13C10.6658 13 11.3019 12.7892 11.7709 12.414C12.2399 12.0388 12.5034 11.5299 12.5034 10.9993V7.83333C12.5034 7.78913 12.4815 7.74674 12.4424 7.71548C12.4033 7.68423 12.3503 7.66667 12.2951 7.66667ZM3.12508 6.66667H5.94258C5.64858 6.95073 5.46903 7.29937 5.42758 7.66667H3.12508C3.06983 7.66667 3.01684 7.68423 2.97777 7.71548C2.9387 7.74674 2.91675 7.78913 2.91675 7.83333V9.99933C2.91669 10.2513 2.988 10.4999 3.12531 10.7266C3.26262 10.9533 3.46236 11.1522 3.70952 11.3083C3.95669 11.4644 4.24486 11.5737 4.55239 11.6279C4.85991 11.6821 5.17879 11.6799 5.48508 11.6213C5.55591 11.9573 5.68508 12.278 5.86258 12.576C5.36865 12.6817 4.85094 12.6951 4.34949 12.6152C3.84804 12.5353 3.37629 12.3642 2.9707 12.1151C2.56512 11.866 2.23657 11.5457 2.01047 11.1788C1.78436 10.8119 1.66676 10.4084 1.66675 9.99933V7.83333C1.66675 7.18933 2.32008 6.66667 3.12508 6.66667ZM14.0626 6.66667H16.8751C17.6801 6.66667 18.3334 7.18933 18.3334 7.83333V10C18.3335 10.4088 18.2162 10.8121 17.9904 11.1788C17.7646 11.5455 17.4365 11.8658 17.0314 12.1149C16.6262 12.364 16.155 12.5353 15.6539 12.6155C15.1529 12.6956 14.6355 12.6826 14.1417 12.5773C14.3201 12.2787 14.4492 11.958 14.5209 11.622C14.8268 11.6798 15.1451 11.6815 15.452 11.627C15.7588 11.5725 16.0463 11.4631 16.2928 11.307C16.5393 11.151 16.7384 10.9524 16.8754 10.726C17.0123 10.4997 17.0834 10.2515 17.0834 10V7.83333C17.0834 7.78913 17.0615 7.74674 17.0224 7.71548C16.9833 7.68423 16.9303 7.66667 16.8751 7.66667H14.5776C14.5361 7.29937 14.3566 6.95073 14.0626 6.66667ZM10.0001 2C10.6631 2 11.299 2.21071 11.7678 2.58579C12.2367 2.96086 12.5001 3.46957 12.5001 4C12.5001 4.53043 12.2367 5.03914 11.7678 5.41421C11.299 5.78929 10.6631 6 10.0001 6C9.33704 6 8.70115 5.78929 8.23231 5.41421C7.76347 5.03914 7.50008 4.53043 7.50008 4C7.50008 3.46957 7.76347 2.96086 8.23231 2.58579C8.70115 2.21071 9.33704 2 10.0001 2ZM15.4167 2.66667C15.9693 2.66667 16.4992 2.84226 16.8899 3.15482C17.2806 3.46738 17.5001 3.89131 17.5001 4.33333C17.5001 4.77536 17.2806 5.19928 16.8899 5.51184C16.4992 5.82441 15.9693 6 15.4167 6C14.8642 6 14.3343 5.82441 13.9436 5.51184C13.5529 5.19928 13.3334 4.77536 13.3334 4.33333C13.3334 3.89131 13.5529 3.46738 13.9436 3.15482C14.3343 2.84226 14.8642 2.66667 15.4167 2.66667ZM4.58341 2.66667C5.13595 2.66667 5.66585 2.84226 6.05655 3.15482C6.44725 3.46738 6.66675 3.89131 6.66675 4.33333C6.66675 4.77536 6.44725 5.19928 6.05655 5.51184C5.66585 5.82441 5.13595 6 4.58341 6C4.03088 6 3.50098 5.82441 3.11028 5.51184C2.71957 5.19928 2.50008 4.77536 2.50008 4.33333C2.50008 3.89131 2.71957 3.46738 3.11028 3.15482C3.50098 2.84226 4.03088 2.66667 4.58341 2.66667ZM10.0001 3C9.66856 3 9.35062 3.10536 9.1162 3.29289C8.88178 3.48043 8.75008 3.73478 8.75008 4C8.75008 4.26522 8.88178 4.51957 9.1162 4.70711C9.35062 4.89464 9.66856 5 10.0001 5C10.3316 5 10.6495 4.89464 10.884 4.70711C11.1184 4.51957 11.2501 4.26522 11.2501 4C11.2501 3.73478 11.1184 3.48043 10.884 3.29289C10.6495 3.10536 10.3316 3 10.0001 3ZM15.4167 3.66667C15.1957 3.66667 14.9838 3.7369 14.8275 3.86193C14.6712 3.98695 14.5834 4.15652 14.5834 4.33333C14.5834 4.51014 14.6712 4.67971 14.8275 4.80474C14.9838 4.92976 15.1957 5 15.4167 5C15.6378 5 15.8497 4.92976 16.006 4.80474C16.1623 4.67971 16.2501 4.51014 16.2501 4.33333C16.2501 4.15652 16.1623 3.98695 16.006 3.86193C15.8497 3.7369 15.6378 3.66667 15.4167 3.66667ZM4.58341 3.66667C4.3624 3.66667 4.15044 3.7369 3.99416 3.86193C3.83788 3.98695 3.75008 4.15652 3.75008 4.33333C3.75008 4.51014 3.83788 4.67971 3.99416 4.80474C4.15044 4.92976 4.3624 5 4.58341 5C4.80443 5 5.01639 4.92976 5.17267 4.80474C5.32895 4.67971 5.41675 4.51014 5.41675 4.33333C5.41675 4.15652 5.32895 3.98695 5.17267 3.86193C5.01639 3.7369 4.80443 3.66667 4.58341 3.66667Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
@ -2,18 +2,23 @@ import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const SettingIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14.0794 1.5C14.2382 1.50001 14.3929 1.55041 14.5212 1.64394C14.6495 1.73748 14.7448 1.86933 14.7934 2.0205L15.6184 4.584C15.9649 4.7535 16.2964 4.944 16.6129 5.1585L19.2469 4.5915C19.4022 4.55834 19.564 4.57534 19.7091 4.64003C19.8541 4.70473 19.9748 4.81379 20.0539 4.9515L22.1329 8.55C22.2123 8.68763 22.2459 8.84693 22.2289 9.0049C22.212 9.16288 22.1452 9.31139 22.0384 9.429L20.2309 11.424C20.2572 11.8065 20.2572 12.1905 20.2309 12.573L22.0384 14.571C22.1452 14.6886 22.212 14.8371 22.2289 14.9951C22.2459 15.1531 22.2123 15.3124 22.1329 15.45L20.0539 19.05C19.9746 19.1874 19.8538 19.2962 19.7088 19.3606C19.5638 19.425 19.4021 19.4418 19.2469 19.4085L16.6129 18.8415C16.2979 19.0545 15.9649 19.2465 15.6199 19.416L14.7934 21.9795C14.7448 22.1307 14.6495 22.2625 14.5212 22.3561C14.3929 22.4496 14.2382 22.5 14.0794 22.5H9.92141C9.76262 22.5 9.60793 22.4496 9.47962 22.3561C9.35131 22.2625 9.256 22.1307 9.20741 21.9795L8.38391 19.4175C8.03834 19.2485 7.70502 19.0555 7.38641 18.84L4.75391 19.4085C4.5986 19.4417 4.43678 19.4247 4.29176 19.36C4.14673 19.2953 4.02598 19.1862 3.94691 19.0485L1.86791 15.45C1.78852 15.3124 1.75489 15.1531 1.77188 14.9951C1.78886 14.8371 1.85558 14.6886 1.96241 14.571L3.76991 12.573C3.74372 12.1914 3.74372 11.8086 3.76991 11.427L1.96241 9.429C1.85558 9.31139 1.78886 9.16288 1.77188 9.0049C1.75489 8.84693 1.78852 8.68763 1.86791 8.55L3.94691 4.95C4.0262 4.81256 4.14704 4.70381 4.29205 4.63939C4.43706 4.57497 4.59876 4.55821 4.75391 4.5915L7.38641 5.16C7.70441 4.9455 8.03741 4.752 8.38391 4.5825L9.20891 2.0205C9.25734 1.86982 9.3522 1.73832 9.47991 1.64482C9.60762 1.55133 9.76163 1.50064 9.91991 1.5H14.0779H14.0794ZM13.5304 3H10.4704L9.61841 5.6505L9.04391 5.931C8.76148 6.06921 8.48885 6.22657 8.22791 6.402L7.69691 6.762L4.97291 6.174L3.44291 8.826L5.31041 10.893L5.26541 11.529C5.24385 11.8426 5.24385 12.1574 5.26541 12.471L5.31041 13.107L3.43991 15.174L4.97141 17.826L7.69541 17.2395L8.22641 17.598C8.48735 17.7734 8.75998 17.9308 9.04241 18.069L9.61691 18.3495L10.4704 21H13.5334L14.3884 18.348L14.9614 18.069C15.2435 17.9311 15.5157 17.7737 15.7759 17.598L16.3054 17.2395L19.0309 17.826L20.5609 15.174L18.6919 13.107L18.7369 12.471C18.7585 12.1569 18.7585 11.8416 18.7369 11.5275L18.6919 10.8915L20.5624 8.826L19.0309 6.174L16.3054 6.759L15.7759 6.402C15.5157 6.22622 15.2435 6.06884 14.9614 5.931L14.3884 5.652L13.5319 3H13.5304ZM12.0004 7.5C13.1939 7.5 14.3385 7.97411 15.1824 8.81802C16.0263 9.66193 16.5004 10.8065 16.5004 12C16.5004 13.1935 16.0263 14.3381 15.1824 15.182C14.3385 16.0259 13.1939 16.5 12.0004 16.5C10.8069 16.5 9.66234 16.0259 8.81843 15.182C7.97451 14.3381 7.50041 13.1935 7.50041 12C7.50041 10.8065 7.97451 9.66193 8.81843 8.81802C9.66234 7.97411 10.8069 7.5 12.0004 7.5ZM12.0004 9C11.2048 9 10.4417 9.31607 9.87909 9.87868C9.31648 10.4413 9.00041 11.2044 9.00041 12C9.00041 12.7956 9.31648 13.5587 9.87909 14.1213C10.4417 14.6839 11.2048 15 12.0004 15C12.7961 15 13.5591 14.6839 14.1217 14.1213C14.6843 13.5587 15.0004 12.7956 15.0004 12C15.0004 11.2044 14.6843 10.4413 14.1217 9.87868C13.5591 9.31607 12.7961 9 12.0004 9Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const SettingIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
color = "black",
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill={color}
|
||||
d="M12.9062 1.375C13.0518 1.375 13.1936 1.42121 13.3112 1.50695C13.4288 1.59269 13.5162 1.71355 13.5607 1.85212L14.317 4.202C14.6346 4.35737 14.9385 4.532 15.2286 4.72862L17.6431 4.20888C17.7854 4.17848 17.9338 4.19406 18.0667 4.25336C18.1997 4.31267 18.3103 4.41264 18.3828 4.53888L20.2886 7.8375C20.3614 7.96366 20.3922 8.10968 20.3766 8.2545C20.361 8.39931 20.2999 8.53544 20.202 8.64325L18.5451 10.472C18.5692 10.8227 18.5692 11.1746 18.5451 11.5252L20.202 13.3567C20.2999 13.4646 20.361 13.6007 20.3766 13.7455C20.3922 13.8903 20.3614 14.0363 20.2886 14.1625L18.3828 17.4625C18.3101 17.5885 18.1994 17.6882 18.0664 17.7472C17.9335 17.8063 17.7853 17.8216 17.6431 17.7911L15.2286 17.2714C14.9398 17.4666 14.6346 17.6426 14.3183 17.798L13.5607 20.1479C13.5162 20.2864 13.4288 20.4073 13.3112 20.4931C13.1936 20.5788 13.0518 20.625 12.9062 20.625H9.0947C8.94915 20.625 8.80735 20.5788 8.68973 20.4931C8.57211 20.4073 8.48474 20.2864 8.4402 20.1479L7.68533 17.7994C7.36856 17.6445 7.06302 17.4676 6.77095 17.27L4.35783 17.7911C4.21547 17.8215 4.06713 17.8059 3.93419 17.7466C3.80125 17.6873 3.69057 17.5874 3.61808 17.4611L1.71233 14.1625C1.63956 14.0363 1.60873 13.8903 1.6243 13.7455C1.63987 13.6007 1.70103 13.4646 1.79895 13.3567L3.45583 11.5252C3.43183 11.1755 3.43183 10.8245 3.45583 10.4748L1.79895 8.64325C1.70103 8.53544 1.63987 8.39931 1.6243 8.2545C1.60873 8.10968 1.63956 7.96366 1.71233 7.8375L3.61808 4.5375C3.69077 4.41151 3.80154 4.31183 3.93446 4.25278C4.06739 4.19373 4.21562 4.17836 4.35783 4.20888L6.77095 4.73C7.06245 4.53338 7.3677 4.356 7.68533 4.20063L8.44158 1.85212C8.48597 1.714 8.57293 1.59346 8.69 1.50776C8.80706 1.42205 8.94824 1.37559 9.09333 1.375H12.9048H12.9062ZM12.403 2.75H9.59795L8.81695 5.17963L8.29033 5.43675C8.03144 5.56344 7.78152 5.70769 7.54233 5.8685L7.05558 6.1985L4.55858 5.6595L3.15608 8.0905L4.86795 9.98525L4.8267 10.5682C4.80695 10.8557 4.80695 11.1443 4.8267 11.4318L4.86795 12.0147L3.15333 13.9095L4.5572 16.3405L7.0542 15.8029L7.54095 16.1315C7.78015 16.2923 8.03007 16.4366 8.28895 16.5632L8.81558 16.8204L9.59795 19.25H12.4057L13.1895 16.819L13.7147 16.5632C13.9733 16.4369 14.2228 16.2926 14.4613 16.1315L14.9467 15.8029L17.4451 16.3405L18.8476 13.9095L17.1343 12.0147L17.1756 11.4318C17.1954 11.1438 17.1954 10.8548 17.1756 10.5669L17.1343 9.98388L18.849 8.0905L17.4451 5.6595L14.9467 6.19575L14.4613 5.8685C14.2228 5.70737 13.9733 5.5631 13.7147 5.43675L13.1895 5.181L12.4043 2.75H12.403ZM11.0005 6.875C12.0945 6.875 13.1437 7.3096 13.9173 8.08318C14.6909 8.85677 15.1255 9.90598 15.1255 11C15.1255 12.094 14.6909 13.1432 13.9173 13.9168C13.1437 14.6904 12.0945 15.125 11.0005 15.125C9.90644 15.125 8.85723 14.6904 8.08364 13.9168C7.31005 13.1432 6.87545 12.094 6.87545 11C6.87545 9.90598 7.31005 8.85677 8.08364 8.08318C8.85723 7.3096 9.90644 6.875 11.0005 6.875ZM11.0005 8.25C10.2711 8.25 9.57164 8.53973 9.05591 9.05546C8.54018 9.57118 8.25045 10.2707 8.25045 11C8.25045 11.7293 8.54018 12.4288 9.05591 12.9445C9.57164 13.4603 10.2711 13.75 11.0005 13.75C11.7298 13.75 12.4293 13.4603 12.945 12.9445C13.4607 12.4288 13.7505 11.7293 13.7505 11C13.7505 10.2707 13.4607 9.57118 12.945 9.05546C12.4293 8.53973 11.7298 8.25 11.0005 8.25Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
77
apps/app/components/icons/started-state-icon.tsx
Normal file
77
apps/app/components/icons/started-state-icon.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const StartedStateIcon: React.FC<Props> = ({
|
||||
width = "20",
|
||||
height = "20",
|
||||
className,
|
||||
color = "#fbb040",
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 83.36 83.36"
|
||||
>
|
||||
<g id="Layer_2" data-name="Layer 2">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M20,7.19a39.74,39.74,0,0,1,43.43.54"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M76.17,20a39.76,39.76,0,0,1-.53,43.43"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M63.42,76.17A39.78,39.78,0,0,1,20,75.64"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M7.19,63.42A39.75,39.75,0,0,1,7.73,20"
|
||||
/>
|
||||
<path
|
||||
className="cls-2"
|
||||
fill={color}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M42.32,41.21q9.57-14.45,19.13-28.9a35.8,35.8,0,0,0-39.09,0Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-2"
|
||||
fill={color}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M42.32,41.7,61.45,70.6a35.75,35.75,0,0,1-39.09,0Z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
29
apps/app/components/icons/state-group-icon.tsx
Normal file
29
apps/app/components/icons/state-group-icon.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import {
|
||||
BacklogStateIcon,
|
||||
CancelledStateIcon,
|
||||
CompletedStateIcon,
|
||||
StartedStateIcon,
|
||||
UnstartedStateIcon,
|
||||
} from "components/icons";
|
||||
|
||||
export const getStateGroupIcon = (
|
||||
stateGroup: "backlog" | "unstarted" | "started" | "completed" | "cancelled",
|
||||
width = "20",
|
||||
height = "20",
|
||||
color?: string
|
||||
) => {
|
||||
switch (stateGroup) {
|
||||
case "backlog":
|
||||
return <BacklogStateIcon width={width} height={height} color={color} />;
|
||||
case "unstarted":
|
||||
return <UnstartedStateIcon width={width} height={height} color={color} />;
|
||||
case "started":
|
||||
return <StartedStateIcon width={width} height={height} color={color} />;
|
||||
case "completed":
|
||||
return <CompletedStateIcon width={width} height={height} color={color} />;
|
||||
case "cancelled":
|
||||
return <CancelledStateIcon width={width} height={height} color={color} />;
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
24
apps/app/components/icons/tick-mark-icon.tsx
Normal file
24
apps/app/components/icons/tick-mark-icon.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const TickMarkIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
color = "#858E96",
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill={color}
|
||||
d="M19.0653 7.06348C19.2051 7.52373 19.3125 8.00065 19.3875 8.49423C19.4625 8.98783 19.5 9.48976 19.5 10C19.5 11.3333 19.2544 12.5779 18.7634 13.7337C18.2724 14.8894 17.5977 15.8964 16.7394 16.7548C15.881 17.6131 14.874 18.2852 13.7182 18.7711C12.5625 19.257 11.323 19.5 9.99998 19.5C8.66664 19.5 7.42209 19.257 6.26633 18.7711C5.11058 18.2852 4.10353 17.6131 3.2452 16.7548C2.38687 15.8964 1.71475 14.8894 1.22885 13.7337C0.74295 12.5779 0.5 11.3333 0.5 10C0.5 8.67694 0.74295 7.43752 1.22885 6.28176C1.71475 5.12599 2.38687 4.11894 3.2452 3.26061C4.10353 2.40227 5.11058 1.7276 6.26633 1.23658C7.42209 0.745547 8.66664 0.500031 9.99998 0.500031C11.0577 0.500031 12.0529 0.656764 12.9856 0.970231C13.9182 1.2837 14.7852 1.7103 15.5865 2.25003C15.7186 2.34106 15.7939 2.46254 15.8125 2.61446C15.8311 2.76637 15.7865 2.90643 15.6788 3.03463C15.5775 3.15643 15.4477 3.22598 15.2894 3.24328C15.1311 3.2606 14.9827 3.22374 14.8442 3.13271C14.1506 2.67374 13.3965 2.30931 12.5817 2.03943C11.767 1.76956 10.9064 1.63463 9.99998 1.63463C7.64101 1.63463 5.65703 2.43911 4.04805 4.04808C2.43908 5.65706 1.6346 7.64104 1.6346 10C1.6346 12.359 2.43908 14.3429 4.04805 15.9519C5.65703 17.5609 7.64101 18.3654 9.99998 18.3654C12.3589 18.3654 14.3429 17.5609 15.9519 15.9519C17.5609 14.3429 18.3654 12.359 18.3654 10C18.3654 9.56411 18.3371 9.14487 18.2807 8.74231C18.2243 8.33974 18.1397 7.94038 18.0269 7.54423C17.9897 7.40833 17.9942 7.26603 18.0404 7.11733C18.0865 6.96861 18.1724 6.86221 18.298 6.79811C18.4429 6.70836 18.5919 6.6856 18.7451 6.72983C18.8983 6.77405 19.0051 6.88526 19.0653 7.06348ZM8.0192 13.7077L5.12308 10.7962C5.01153 10.6846 4.95736 10.5462 4.96058 10.3808C4.96378 10.2154 5.02628 10.0718 5.14808 9.95001C5.26601 9.83207 5.40607 9.77311 5.56825 9.77311C5.73042 9.77311 5.87561 9.83207 6.00383 9.95001L8.52498 12.4962L18.2384 2.78273C18.35 2.67118 18.4868 2.61445 18.649 2.61253C18.8112 2.61061 18.9564 2.67055 19.0846 2.79233C19.2128 2.92053 19.2769 3.06572 19.2769 3.22791C19.2769 3.39007 19.2128 3.53526 19.0846 3.66348L9.03075 13.7077C8.88715 13.8577 8.71599 13.9327 8.51728 13.9327C8.31856 13.9327 8.15253 13.8577 8.0192 13.7077Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
59
apps/app/components/icons/unstarted-state-icon.tsx
Normal file
59
apps/app/components/icons/unstarted-state-icon.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const UnstartedStateIcon: React.FC<Props> = ({
|
||||
width = "20",
|
||||
height = "20",
|
||||
className,
|
||||
color = "#858e96",
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 84.36 84.36"
|
||||
>
|
||||
<g id="Layer_2" data-name="Layer 2">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M20.45,7.69a39.74,39.74,0,0,1,43.43.54"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M76.67,20.45a39.76,39.76,0,0,1-.53,43.43"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M63.92,76.67a39.78,39.78,0,0,1-43.44-.53"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M7.69,63.92a39.75,39.75,0,0,1,.54-43.44"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
@ -96,7 +96,7 @@ export const AddComment: React.FC = () => {
|
||||
setValue("comment_json", jsonValue);
|
||||
setValue("comment_html", htmlValue);
|
||||
}}
|
||||
placeholder="Enter Your comment..."
|
||||
// placeholder="Enter Your comment..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -1,11 +1,9 @@
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { FC, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// lodash
|
||||
import debounce from "lodash.debounce";
|
||||
// components
|
||||
import { Loader, TextArea } from "components/ui";
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
@ -27,7 +25,7 @@ export interface IssueDescriptionFormValues {
|
||||
|
||||
export interface IssueDetailsProps {
|
||||
issue: IIssue;
|
||||
handleFormSubmit: (value: IssueDescriptionFormValues) => void;
|
||||
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>;
|
||||
userAuth: UserAuth;
|
||||
}
|
||||
|
||||
@ -36,6 +34,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
handleFormSubmit,
|
||||
userAuth,
|
||||
}) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [characterLimit, setCharacterLimit] = useState(false);
|
||||
|
||||
const {
|
||||
@ -53,10 +52,10 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
});
|
||||
|
||||
const handleDescriptionFormSubmit = useCallback(
|
||||
(formData: Partial<IIssue>) => {
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!formData.name || formData.name.length === 0 || formData.name.length > 255) return;
|
||||
|
||||
handleFormSubmit({
|
||||
await handleFormSubmit({
|
||||
name: formData.name ?? "",
|
||||
description: formData.description ?? "",
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
@ -65,17 +64,19 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
[handleFormSubmit]
|
||||
);
|
||||
|
||||
const debounceHandler = useMemo(
|
||||
() => debounce(handleSubmit(handleDescriptionFormSubmit), 2000),
|
||||
[handleSubmit, handleDescriptionFormSubmit]
|
||||
);
|
||||
useEffect(() => {
|
||||
const alertUser = (e: BeforeUnloadEvent) => {
|
||||
console.log("beforeunload");
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
return "Are you sure you want to leave?";
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
debounceHandler.cancel();
|
||||
},
|
||||
[debounceHandler]
|
||||
);
|
||||
window.addEventListener("beforeunload", alertUser);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", alertUser);
|
||||
};
|
||||
}, [isSubmitting]);
|
||||
|
||||
// reset form values
|
||||
useEffect(() => {
|
||||
@ -95,19 +96,29 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
placeholder="Enter issue name"
|
||||
value={watch("name")}
|
||||
onFocus={() => setCharacterLimit(true)}
|
||||
onBlur={() => setCharacterLimit(false)}
|
||||
onBlur={() => {
|
||||
setCharacterLimit(false);
|
||||
|
||||
setIsSubmitting(true);
|
||||
handleSubmit(handleDescriptionFormSubmit)()
|
||||
.then(() => {
|
||||
setIsSubmitting(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setValue("name", e.target.value);
|
||||
debounceHandler();
|
||||
}}
|
||||
required={true}
|
||||
className="block px-3 py-2 text-xl
|
||||
w-full overflow-hidden resize-none min-h-10
|
||||
rounded border-none bg-transparent ring-0 focus:ring-1 focus:ring-theme outline-none"
|
||||
className="min-h-10 block w-full resize-none
|
||||
overflow-hidden rounded border-none bg-transparent
|
||||
px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-theme"
|
||||
role="textbox"
|
||||
/>
|
||||
{characterLimit && (
|
||||
<div className="absolute bottom-0 right-0 text-xs bg-white p-1 rounded pointer-events-none z-[2]">
|
||||
<div className="pointer-events-none absolute bottom-0 right-0 z-[2] rounded bg-white p-1 text-xs">
|
||||
<span
|
||||
className={`${
|
||||
watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
|
||||
@ -123,13 +134,21 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
<RemirrorRichTextEditor
|
||||
value={watch("description")}
|
||||
placeholder="Describe the issue..."
|
||||
onJSONChange={(json) => {
|
||||
setValue("description", json);
|
||||
debounceHandler();
|
||||
onBlur={() => {
|
||||
setIsSubmitting(true);
|
||||
handleSubmit(handleDescriptionFormSubmit)()
|
||||
.then(() => {
|
||||
setIsSubmitting(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
}}
|
||||
onJSONChange={(json) => setValue("description", json)}
|
||||
onHTMLChange={(html) => setValue("description_html", html)}
|
||||
editable={!isNotAllowed}
|
||||
/>
|
||||
<div className="text-right text-sm text-gray-500">{isSubmitting && "Saving..."}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -14,13 +14,14 @@ import {
|
||||
IssuePrioritySelect,
|
||||
IssueProjectSelect,
|
||||
IssueStateSelect,
|
||||
IssueDateSelect,
|
||||
} from "components/issues/select";
|
||||
import { CycleSelect as IssueCycleSelect } from "components/cycles/select";
|
||||
import { CreateStateModal } from "components/states";
|
||||
import { CreateUpdateCycleModal } from "components/cycles";
|
||||
import { CreateLabelModal } from "components/labels";
|
||||
// ui
|
||||
import { Button, CustomDatePicker, CustomMenu, Input, Loader } from "components/ui";
|
||||
import { Button, CustomMenu, Input, Loader } from "components/ui";
|
||||
import { PrimaryButton } from "components/ui/button/primary-button";
|
||||
// icons
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
@ -95,7 +96,6 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
setFocus,
|
||||
} = useForm<IIssue>({
|
||||
defaultValues,
|
||||
mode: "all",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
@ -157,7 +157,7 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
||||
<h3 className="text-xl font-semibold leading-6 text-gray-900">
|
||||
{status ? "Update" : "Create"} Issue
|
||||
</h3>
|
||||
</div>
|
||||
@ -190,11 +190,11 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
<div>
|
||||
<Input
|
||||
id="name"
|
||||
label="Title"
|
||||
name="name"
|
||||
onChange={handleTitleChange}
|
||||
className="resize-none"
|
||||
placeholder="Enter title"
|
||||
className="resize-none text-xl"
|
||||
placeholder="Title"
|
||||
mode="transparent"
|
||||
autoComplete="off"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
@ -220,7 +220,7 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
</span>
|
||||
?
|
||||
</a>
|
||||
</Link>{" "}
|
||||
</Link>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@ -235,9 +235,6 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={"description"} className="mb-2 text-gray-500">
|
||||
Description
|
||||
</label>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
@ -246,7 +243,7 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
value={value}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Enter Your Text..."
|
||||
placeholder="Description"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -266,16 +263,16 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="cycle"
|
||||
name="priority"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueCycleSelect projectId={projectId} value={value} onChange={onChange} />
|
||||
<IssuePrioritySelect value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
name="assignees"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssuePrioritySelect value={value} onChange={onChange} />
|
||||
<IssueAssigneeSelect projectId={projectId} value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
@ -295,21 +292,10 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomDatePicker
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="max-w-[7rem]"
|
||||
/>
|
||||
<IssueDateSelect value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignees"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueAssigneeSelect projectId={projectId} value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
<IssueParentSelect
|
||||
control={control}
|
||||
isOpen={parentIssueListModalOpen}
|
||||
@ -345,7 +331,7 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<div className="-mx-5 mt-5 flex items-center justify-between gap-2 border-t px-5 pt-5">
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1"
|
||||
onClick={() => setCreateMore((prevData) => !prevData)}
|
||||
@ -372,15 +358,15 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
<Button type="button" theme="secondary" onClick={handleClose}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<PrimaryButton type="submit" size="sm" loading={isSubmitting}>
|
||||
{status
|
||||
? isSubmitting
|
||||
? "Updating Issue..."
|
||||
: "Update Issue"
|
||||
: isSubmitting
|
||||
? "Creating Issue..."
|
||||
: "Create Issue"}
|
||||
</Button>
|
||||
? "Adding Issue..."
|
||||
: "Add Issue"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -86,7 +86,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
}, [handleClose]);
|
||||
|
||||
const addIssueToCycle = async (issueId: string, cycleId: string) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
@ -231,7 +231,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<div className="mt-10 flex min-h-full items-start justify-center p-4 text-center sm:p-0 md:mt-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
@ -105,7 +105,7 @@ export const MyIssuesListItem: React.FC<Props> = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="w-auto max-w-lg text-ellipsis overflow-hidden whitespace-nowrap">
|
||||
<span className="w-auto max-w-lg overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{issue.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
@ -135,7 +135,7 @@ export const MyIssuesListItem: React.FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-xs shadow-sm">
|
||||
{issue?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,122 +1,75 @@
|
||||
import { useState, FC, Fragment } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Transition, Combobox } from "@headlessui/react";
|
||||
// services
|
||||
import projectServices from "services/project.service";
|
||||
// ui
|
||||
import { AssigneesList, Avatar } from "components/ui";
|
||||
// fetch keys
|
||||
import { AssigneesList, Avatar, CustomSearchSelect } from "components/ui";
|
||||
// icons
|
||||
import { UserGroupIcon } from "@heroicons/react/24/outline";
|
||||
// fetch-keys
|
||||
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
export type IssueAssigneeSelectProps = {
|
||||
export type Props = {
|
||||
projectId: string;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
};
|
||||
|
||||
export const IssueAssigneeSelect: FC<IssueAssigneeSelectProps> = ({
|
||||
projectId,
|
||||
value = [],
|
||||
onChange,
|
||||
}) => {
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
export const IssueAssigneeSelect: React.FC<Props> = ({ projectId, value = [], onChange }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
// fetching project members
|
||||
const { data: people } = useSWR(
|
||||
const { data: members } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectServices.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const options = people?.map((person) => ({
|
||||
value: person.member.id,
|
||||
display:
|
||||
person.member.first_name && person.member.first_name !== ""
|
||||
? person.member.first_name
|
||||
: person.member.email,
|
||||
}));
|
||||
|
||||
const filteredOptions =
|
||||
query === ""
|
||||
? options
|
||||
: options?.filter((option) => option.display.toLowerCase().includes(query.toLowerCase()));
|
||||
const options =
|
||||
members?.map((member) => ({
|
||||
value: member.member.id,
|
||||
query:
|
||||
(member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email) +
|
||||
" " +
|
||||
member.member.last_name ?? "",
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar user={member.member} />
|
||||
{member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email}
|
||||
</div>
|
||||
),
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
<CustomSearchSelect
|
||||
value={value}
|
||||
onChange={(val) => onChange(val)}
|
||||
className="relative flex-shrink-0"
|
||||
multiple
|
||||
>
|
||||
{({ open }: any) => (
|
||||
<>
|
||||
<Combobox.Button className="flex items-center cursor-pointer gap-1 rounded-md">
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{value && Array.isArray(value) ? <AssigneesList userIds={value} length={10} /> : null}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
label={
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
{value && value.length > 0 && Array.isArray(value) ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<AssigneesList userIds={value} length={3} showLength={false} />
|
||||
<span className="text-gray-500">{value.length} Assignees</span>
|
||||
</div>
|
||||
</Combobox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Combobox.Options
|
||||
className={`absolute z-10 mt-1 max-h-32 min-w-[8rem] overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none text-xs`}
|
||||
>
|
||||
<Combobox.Input
|
||||
className="w-full border-b bg-transparent p-2 text-xs focus:outline-none"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
/>
|
||||
<div className="py-1">
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`${active ? "bg-indigo-50" : ""} ${
|
||||
selected ? "bg-indigo-50 font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate px-2 py-1 text-gray-900`
|
||||
}
|
||||
value={option.value}
|
||||
>
|
||||
{people && (
|
||||
<>
|
||||
<Avatar
|
||||
user={people?.find((p) => p.member.id === option.value)?.member}
|
||||
/>
|
||||
{option.display}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 px-2">No assignees found</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 px-2">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Combobox>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-gray-500">Assignee</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
multiple
|
||||
noChevron
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
70
apps/app/components/issues/select/date.tsx
Normal file
70
apps/app/components/issues/select/date.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { CalendarDaysIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// react-datepicker
|
||||
import DatePicker from "react-datepicker";
|
||||
// import "react-datepicker/dist/react-datepicker.css";
|
||||
import { renderDateFormat } from "helpers/date-time.helper";
|
||||
|
||||
type Props = {
|
||||
value: string | null;
|
||||
onChange: (val: string | null) => void;
|
||||
};
|
||||
|
||||
export const IssueDateSelect: React.FC<Props> = ({ value, onChange }) => (
|
||||
<Popover className="relative flex items-center justify-center rounded-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={({ open }) =>
|
||||
`flex cursor-pointer items-center rounded-md border text-xs shadow-sm duration-200
|
||||
${
|
||||
open
|
||||
? "border-theme bg-theme/5 outline-none ring-1 ring-theme "
|
||||
: "hover:bg-theme/5 "
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2 px-3 py-1.5 text-xs">
|
||||
{value ? (
|
||||
<>
|
||||
<span className="text-gray-600">{value}</span>
|
||||
<button onClick={() => onChange(null)}>
|
||||
<XMarkIcon className="h-3 w-3 text-gray-600" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0 " />
|
||||
<span className="text-gray-500">Due Date</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 -left-10 z-20 transform overflow-hidden">
|
||||
<DatePicker
|
||||
selected={value ? new Date(value) : null}
|
||||
onChange={(val) => {
|
||||
if (!val) onChange("");
|
||||
else onChange(renderDateFormat(val));
|
||||
}}
|
||||
dateFormat="dd-MM-yyyy"
|
||||
inline
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
@ -4,3 +4,4 @@ export * from "./parent";
|
||||
export * from "./priority";
|
||||
export * from "./project";
|
||||
export * from "./state";
|
||||
export * from "./date";
|
||||
|
@ -7,13 +7,20 @@ import useSWR from "swr";
|
||||
// headless ui
|
||||
import { Combobox, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { PlusIcon, RectangleGroupIcon, TagIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
CheckIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PlusIcon,
|
||||
RectangleGroupIcon,
|
||||
TagIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import issuesServices from "services/issues.service";
|
||||
// types
|
||||
import type { IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
import { IssueLabelsList } from "components/ui";
|
||||
|
||||
type Props = {
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@ -52,36 +59,57 @@ export const IssueLabelSelect: React.FC<Props> = ({ setIsOpen, value, onChange,
|
||||
>
|
||||
{({ open }: any) => (
|
||||
<>
|
||||
<Combobox.Label className="sr-only">Labels</Combobox.Label>
|
||||
<Combobox.Button
|
||||
className={`flex cursor-pointer items-center gap-1 rounded-md border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500`}
|
||||
className={({ open }) =>
|
||||
`flex cursor-pointer items-center rounded-md border text-xs shadow-sm duration-200
|
||||
${
|
||||
open
|
||||
? "border-theme bg-theme/5 outline-none ring-1 ring-theme "
|
||||
: "hover:bg-theme/5 "
|
||||
}`
|
||||
}
|
||||
>
|
||||
<TagIcon className="h-3 w-3 text-gray-500" />
|
||||
<span className={`flex items-center gap-2 ${!value ? "" : "text-gray-900"}`}>
|
||||
{Array.isArray(value)
|
||||
? value.map((v) => issueLabels?.find((l) => l.id === v)?.name).join(", ") ||
|
||||
"Labels"
|
||||
: issueLabels?.find((l) => l.id === value)?.name || "Labels"}
|
||||
</span>
|
||||
{value && value.length > 0 ? (
|
||||
<span className="flex items-center justify-center gap-2 px-3 py-1 text-xs">
|
||||
<IssueLabelsList
|
||||
labels={value.map((v) => issueLabels?.find((l) => l.id === v)?.color) ?? []}
|
||||
length={3}
|
||||
showLength
|
||||
/>
|
||||
<span className=" text-gray-600">{value.length} Labels</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2 px-3 py-1.5 text-xs">
|
||||
<TagIcon className="h-3 w-3 text-gray-500" />
|
||||
<span className=" text-gray-500">Label</span>
|
||||
</span>
|
||||
)}
|
||||
</Combobox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Combobox.Options
|
||||
className={`absolute z-10 mt-1 max-h-32 min-w-[8rem] overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none text-xs`}
|
||||
className={`absolute z-10 mt-1 max-h-52 min-w-[8rem] overflow-auto rounded-md border-none
|
||||
bg-white px-2 py-2 text-xs shadow-md focus:outline-none`}
|
||||
>
|
||||
<Combobox.Input
|
||||
className="w-full border-b bg-transparent p-2 text-xs focus:outline-none"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
/>
|
||||
<div className="py-1">
|
||||
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] bg-gray-100 px-2">
|
||||
<MagnifyingGlassIcon className="h-3 w-3 text-gray-500" />
|
||||
<Combobox.Input
|
||||
className="w-full bg-transparent py-1 px-2 text-xs text-gray-500 focus:outline-none"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search for label..."
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-1.5">
|
||||
{issueLabels && filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((label) => {
|
||||
@ -92,47 +120,75 @@ export const IssueLabelSelect: React.FC<Props> = ({ setIsOpen, value, onChange,
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={label.id}
|
||||
className={({ active, selected }) =>
|
||||
`${active ? "bg-indigo-50" : ""} ${
|
||||
selected ? "bg-indigo-50 font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-gray-200" : ""
|
||||
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-gray-600`
|
||||
}
|
||||
value={label.id}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
{({ selected }) => (
|
||||
<div className="flex w-full justify-between gap-2 rounded">
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<span
|
||||
className="h-3 w-3 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label.color && label.color !== ""
|
||||
? label.color
|
||||
: "#000",
|
||||
}}
|
||||
/>
|
||||
<span>{label.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center rounded p-1">
|
||||
<CheckIcon
|
||||
className={`h-3 w-3 ${
|
||||
selected ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<div className="bg-gray-50 border-y border-gray-400">
|
||||
<div className="flex select-none font-medium items-center gap-2 truncate p-2 text-gray-900">
|
||||
<div className="border-y border-gray-400 bg-gray-50">
|
||||
<div className="flex select-none items-center gap-2 truncate p-2 font-medium text-gray-900">
|
||||
<RectangleGroupIcon className="h-3 w-3" /> {label.name}
|
||||
</div>
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<Combobox.Option
|
||||
key={child.id}
|
||||
className={({ active, selected }) =>
|
||||
`${active ? "bg-indigo-50" : ""} ${
|
||||
selected ? "bg-indigo-50 font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-gray-200" : ""
|
||||
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-gray-600`
|
||||
}
|
||||
value={child.id}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child?.color ?? "black",
|
||||
}}
|
||||
/>
|
||||
{child.name}
|
||||
{({ selected }) => (
|
||||
<div className="flex w-full justify-between gap-2 rounded">
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<span
|
||||
className="h-3 w-3 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child?.color ?? "black",
|
||||
}}
|
||||
/>
|
||||
<span>{child.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center rounded p-1">
|
||||
<CheckIcon
|
||||
className={`h-3 w-3 ${
|
||||
selected ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</div>
|
||||
@ -140,18 +196,20 @@ export const IssueLabelSelect: React.FC<Props> = ({ setIsOpen, value, onChange,
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 px-2">No labels found</p>
|
||||
<p className="px-2 text-xs text-gray-500">No labels found</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 px-2">Loading...</p>
|
||||
<p className="px-2 text-xs text-gray-500">Loading...</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex select-none w-full items-center gap-2 p-2 text-gray-400 outline-none hover:bg-indigo-50 hover:text-gray-900"
|
||||
className="flex w-full select-none items-center rounded py-2 px-1 hover:bg-gray-200"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-3 w-3 text-gray-400" aria-hidden="true" />
|
||||
<span className="text-xs whitespace-nowrap">Create label</span>
|
||||
<span className="flex items-center justify-start gap-1">
|
||||
<PlusIcon className="h-4 w-4 text-gray-600" aria-hidden="true" />
|
||||
<span className="text-gray-600">Create New Label</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { CustomSelect } from "components/ui";
|
||||
// icons
|
||||
import { getPriorityIcon } from "components/icons/priority-icon";
|
||||
// constants
|
||||
@ -13,43 +13,30 @@ type Props = {
|
||||
};
|
||||
|
||||
export const IssuePrioritySelect: React.FC<Props> = ({ value, onChange }) => (
|
||||
<Listbox as="div" className="relative" value={value} onChange={onChange}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Listbox.Button className="flex cursor-pointer items-center gap-1 rounded-md border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<span className="text-gray-500 grid place-items-center">{getPriorityIcon(value)}</span>
|
||||
<div className="flex items-center gap-2 capitalize">{value ?? "Priority"}</div>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute mt-1 max-h-32 min-w-[8rem] overflow-y-auto whitespace-nowrap bg-white shadow-lg text-xs z-10 rounded-md py-1 ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="py-1">
|
||||
{PRIORITIES.map((priority) => (
|
||||
<Listbox.Option
|
||||
key={priority}
|
||||
className={({ selected, active }) =>
|
||||
`${selected ? "bg-indigo-50 font-medium" : ""} ${
|
||||
active ? "bg-indigo-50" : ""
|
||||
} relative cursor-pointer select-none p-2 text-gray-900`
|
||||
}
|
||||
value={priority}
|
||||
>
|
||||
<span className="flex items-center gap-2 capitalize">
|
||||
{getPriorityIcon(priority)}
|
||||
{priority ?? "None"}
|
||||
</span>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={
|
||||
<div className="flex items-center justify-center gap-2 text-xs">
|
||||
<span className="flex items-center">
|
||||
{getPriorityIcon(value, `${value ? "text-xs" : "text-xs text-gray-500"}`)}
|
||||
</span>
|
||||
<span className={`${value ? "text-gray-600" : "text-gray-500"} capitalize`}>
|
||||
{value ?? "Priority"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
onChange={onChange}
|
||||
noChevron
|
||||
>
|
||||
{PRIORITIES.map((priority) => (
|
||||
<CustomSelect.Option key={priority} value={priority}>
|
||||
<div className="flex w-full justify-between gap-2 rounded">
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<span>{getPriorityIcon(priority)}</span>
|
||||
<span className="capitalize">{priority ?? "None"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
);
|
||||
|
@ -1,11 +1,9 @@
|
||||
import { FC, Fragment } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { CustomSelect } from "components/ui";
|
||||
// icons
|
||||
import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline";
|
||||
// services
|
||||
@ -19,7 +17,7 @@ export interface IssueProjectSelectProps {
|
||||
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
}
|
||||
|
||||
export const IssueProjectSelect: FC<IssueProjectSelectProps> = ({
|
||||
export const IssueProjectSelect: React.FC<IssueProjectSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
setActiveProject,
|
||||
@ -34,71 +32,35 @@ export const IssueProjectSelect: FC<IssueProjectSelectProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Listbox
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
setActiveProject(val);
|
||||
}}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="relative flex cursor-pointer items-center gap-1 rounded-md border bg-white px-2 py-1 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm">
|
||||
<ClipboardDocumentListIcon className="h-3 w-3" />
|
||||
<span className="block truncate">
|
||||
{projects?.find((i) => i.id === value)?.identifier ?? "Project"}
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="py-1">
|
||||
{projects ? (
|
||||
projects.length > 0 ? (
|
||||
projects.map((project) => (
|
||||
<Listbox.Option
|
||||
key={project.id}
|
||||
className={({ active, selected }) =>
|
||||
`${active ? "bg-indigo-50" : ""} ${
|
||||
selected ? "bg-indigo-50 font-medium" : ""
|
||||
} cursor-pointer select-none p-2 text-gray-900`
|
||||
}
|
||||
value={project.id}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span
|
||||
className={`${
|
||||
selected ? "font-medium" : "font-normal"
|
||||
} block truncate`}
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-400">No projects found!</p>
|
||||
)
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 px-2">Loading...</div>
|
||||
)}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
</>
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={
|
||||
<>
|
||||
<ClipboardDocumentListIcon className="h-3 w-3" />
|
||||
<span className="block truncate">
|
||||
{projects?.find((i) => i.id === value)?.identifier ?? "Project"}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
onChange={(val: string) => {
|
||||
onChange(val);
|
||||
setActiveProject(val);
|
||||
}}
|
||||
noChevron
|
||||
>
|
||||
{projects ? (
|
||||
projects.length > 0 ? (
|
||||
projects.map((project) => (
|
||||
<CustomSelect.Option key={project.id} value={project.id}>
|
||||
<>{project.name}</>
|
||||
</CustomSelect.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-400">No projects found!</p>
|
||||
)
|
||||
) : (
|
||||
<div className="px-2 text-sm text-gray-500">Loading...</div>
|
||||
)}
|
||||
</CustomSelect>
|
||||
);
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@ -6,10 +6,11 @@ import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import stateService from "services/state.service";
|
||||
// headless ui
|
||||
import { Squares2X2Icon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { CustomSearchSelect } from "components/ui";
|
||||
// icons
|
||||
import { Combobox, Transition } from "@headlessui/react";
|
||||
import { PlusIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
import { getStateGroupIcon } from "components/icons";
|
||||
// helpers
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
// fetch keys
|
||||
@ -24,8 +25,6 @@ type Props = {
|
||||
|
||||
export const IssueStateSelect: React.FC<Props> = ({ setIsOpen, value, onChange, projectId }) => {
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
@ -39,103 +38,41 @@ export const IssueStateSelect: React.FC<Props> = ({ setIsOpen, value, onChange,
|
||||
|
||||
const options = states?.map((state) => ({
|
||||
value: state.id,
|
||||
display: state.name,
|
||||
color: state.color,
|
||||
query: state.name,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
{getStateGroupIcon(state.group, "16", "16", state.color)}
|
||||
{state.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const filteredOptions =
|
||||
query === ""
|
||||
? options
|
||||
: options?.filter((option) => option.display.toLowerCase().includes(query.toLowerCase()));
|
||||
const selectedOption = states?.find((s) => s.id === value);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
<CustomSearchSelect
|
||||
value={value}
|
||||
onChange={(val: any) => onChange(val)}
|
||||
className="relative flex-shrink-0"
|
||||
>
|
||||
{({ open }: any) => (
|
||||
<>
|
||||
<Combobox.Label className="sr-only">State</Combobox.Label>
|
||||
<Combobox.Button
|
||||
className={`flex cursor-pointer items-center gap-1 rounded-md border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500`}
|
||||
>
|
||||
<Squares2X2Icon className="h-3 w-3 text-gray-500" />
|
||||
<span className={`flex items-center gap-2 ${!value ? "" : "text-gray-900"}`}>
|
||||
{value && value !== "" ? (
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: options?.find((option) => option.value === value)?.color,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{options?.find((option) => option.value === value)?.display || "State"}
|
||||
</span>
|
||||
</Combobox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Combobox.Options
|
||||
className={`absolute z-10 mt-1 max-h-32 min-w-[8rem] overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none text-xs`}
|
||||
>
|
||||
<Combobox.Input
|
||||
className="w-full border-b bg-transparent p-2 text-xs focus:outline-none"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
/>
|
||||
<div className="py-1">
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`${active ? "bg-indigo-50" : ""} ${
|
||||
selected ? "bg-indigo-50 font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
}
|
||||
value={option.value}
|
||||
>
|
||||
{states && (
|
||||
<>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: option.color,
|
||||
}}
|
||||
/>
|
||||
{option.display}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 px-2">No states found</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 px-2">Loading...</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex select-none w-full items-center gap-2 p-2 text-gray-400 hover:bg-indigo-50 hover:text-gray-900"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-3 w-3 text-gray-400" aria-hidden="true" />
|
||||
<span className="text-xs whitespace-nowrap">Create state</span>
|
||||
</button>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Combobox>
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
label={
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Squares2X2Icon className="h-4 w-4" />
|
||||
{selectedOption &&
|
||||
getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)}
|
||||
{selectedOption?.name ?? "State"}
|
||||
</div>
|
||||
}
|
||||
footerOption={
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center gap-2 rounded px-1 py-1.5 text-xs text-gray-500 hover:bg-hover-gray"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" aria-hidden="true" />
|
||||
Create New State
|
||||
</button>
|
||||
}
|
||||
noChevron
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -1,41 +1,57 @@
|
||||
import React from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import { UserGroupIcon } from "@heroicons/react/24/outline";
|
||||
import workspaceService from "services/workspace.service";
|
||||
// hooks
|
||||
import projectService from "services/project.service";
|
||||
// ui
|
||||
import { AssigneesList } from "components/ui/avatar";
|
||||
import { Spinner } from "components/ui";
|
||||
import { CustomSearchSelect } from "components/ui";
|
||||
import { AssigneesList, Avatar } from "components/ui/avatar";
|
||||
// icons
|
||||
import { UserGroupIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, UserAuth } from "types";
|
||||
// constants
|
||||
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||
import { UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
value: string[];
|
||||
onChange: (val: string[]) => void;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const SidebarAssigneeSelect: React.FC<Props> = ({ control, submitChanges, userAuth }) => {
|
||||
export const SidebarAssigneeSelect: React.FC<Props> = ({ value, onChange, userAuth }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: people } = useSWR(
|
||||
workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug as string) : null,
|
||||
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
|
||||
const { data: members } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const options =
|
||||
members?.map((member) => ({
|
||||
value: member.member.id,
|
||||
query:
|
||||
(member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email) +
|
||||
" " +
|
||||
member.member.last_name ?? "",
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar user={member.member} />
|
||||
{member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email}
|
||||
</div>
|
||||
),
|
||||
})) ?? [];
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
@ -45,93 +61,24 @@ export const SidebarAssigneeSelect: React.FC<Props> = ({ control, submitChanges,
|
||||
<p>Assignees</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignees_list"
|
||||
render={({ field: { value } }) => (
|
||||
<Listbox
|
||||
as="div"
|
||||
value={value}
|
||||
multiple={true}
|
||||
onChange={(value: any) => {
|
||||
submitChanges({ assignees_list: value });
|
||||
}}
|
||||
className="flex-shrink-0"
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{({ open }) => (
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
className={`flex w-full ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center gap-1 text-xs`}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{value && Array.isArray(value) ? (
|
||||
<AssigneesList userIds={value} length={10} />
|
||||
) : null}
|
||||
</div>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Listbox.Options className="absolute left-0 z-10 mt-1 max-h-48 w-full overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="py-1">
|
||||
{people ? (
|
||||
people.length > 0 ? (
|
||||
people.map((option) => (
|
||||
<Listbox.Option
|
||||
key={option.member.id}
|
||||
className={({ active, selected }) =>
|
||||
`${active || selected ? "bg-indigo-50" : ""} ${
|
||||
selected ? "font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
}
|
||||
value={option.member.id}
|
||||
>
|
||||
{option.member.avatar && option.member.avatar !== "" ? (
|
||||
<div className="relative h-4 w-4">
|
||||
<Image
|
||||
src={option.member.avatar}
|
||||
alt="avatar"
|
||||
className="rounded-full"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 capitalize text-white">
|
||||
{option.member.first_name && option.member.first_name !== ""
|
||||
? option.member.first_name.charAt(0)
|
||||
: option.member.email.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
{option.member.first_name && option.member.first_name !== ""
|
||||
? option.member.first_name
|
||||
: option.member.email}
|
||||
</Listbox.Option>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center">No assignees found</div>
|
||||
)
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
<CustomSearchSelect
|
||||
value={value}
|
||||
label={
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
{value && value.length > 0 && Array.isArray(value) ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<AssigneesList userIds={value} length={3} showLength={false} />
|
||||
<span className="text-gray-500">{value.length} Assignees</span>
|
||||
</div>
|
||||
) : (
|
||||
"No assignees"
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
multiple
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -65,26 +65,21 @@ export const SidebarCycleSelect: React.FC<Props> = ({
|
||||
<div className="space-y-1 sm:basis-1/2">
|
||||
<CustomSelect
|
||||
label={
|
||||
<Tooltip
|
||||
position="top-right"
|
||||
tooltipHeading="Cycle"
|
||||
tooltipContent={issueCycle ? issueCycle.cycle_detail.name : "None"}
|
||||
<span
|
||||
className={`w-full max-w-[125px] truncate text-left sm:block ${
|
||||
issueCycle ? "" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={` w-full max-w-[125px] truncate text-left sm:block ${
|
||||
issueCycle ? "" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{issueCycle ? issueCycle.cycle_detail.name : "None"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{issueCycle ? issueCycle.cycle_detail.name : "None"}
|
||||
</span>
|
||||
}
|
||||
value={issueCycle?.cycle_detail.id}
|
||||
onChange={(value: any) => {
|
||||
value === null
|
||||
!value
|
||||
? removeIssueFromCycle(issueCycle?.id ?? "", issueCycle?.cycle ?? "")
|
||||
: handleCycleChange(cycles?.find((c) => c.id === value) as ICycle);
|
||||
}}
|
||||
width="w-full"
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{cycles ? (
|
||||
@ -97,11 +92,7 @@ export const SidebarCycleSelect: React.FC<Props> = ({
|
||||
</Tooltip>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
<CustomSelect.Option value={null} className="capitalize">
|
||||
<Tooltip position="left-bottom" tooltipContent="None">
|
||||
<span className="w-full max-w-[125px] truncate">None</span>
|
||||
</Tooltip>
|
||||
</CustomSelect.Option>
|
||||
<CustomSelect.Option value={null}>None</CustomSelect.Option>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">No cycles found</div>
|
||||
|
@ -64,26 +64,21 @@ export const SidebarModuleSelect: React.FC<Props> = ({
|
||||
<div className="space-y-1 sm:basis-1/2">
|
||||
<CustomSelect
|
||||
label={
|
||||
<Tooltip
|
||||
position="top-right"
|
||||
tooltipHeading="Module"
|
||||
tooltipContent={modules?.find((m) => m.id === issueModule?.module)?.name ?? "None"}
|
||||
<span
|
||||
className={`w-full max-w-[125px] truncate text-left sm:block ${
|
||||
issueModule ? "" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-full max-w-[125px] truncate text-left sm:block ${
|
||||
issueModule ? "" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{modules?.find((m) => m.id === issueModule?.module)?.name ?? "None"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{modules?.find((m) => m.id === issueModule?.module)?.name ?? "None"}
|
||||
</span>
|
||||
}
|
||||
value={issueModule?.module_detail?.id}
|
||||
onChange={(value: any) => {
|
||||
value === null
|
||||
!value
|
||||
? removeIssueFromModule(issueModule?.id ?? "", issueModule?.module ?? "")
|
||||
: handleModuleChange(modules?.find((m) => m.id === value) as IModule);
|
||||
}}
|
||||
width="w-full"
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{modules ? (
|
||||
@ -96,11 +91,7 @@ export const SidebarModuleSelect: React.FC<Props> = ({
|
||||
</Tooltip>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
<CustomSelect.Option value={null} className="capitalize">
|
||||
<Tooltip position="left-bottom" tooltipContent="None">
|
||||
<span className="w-full max-w-[125px] truncate"> None </span>
|
||||
</Tooltip>
|
||||
</CustomSelect.Option>
|
||||
<CustomSelect.Option value={null}>None</CustomSelect.Option>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">No modules found</div>
|
||||
|
@ -1,24 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
// react-hook-form
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
// ui
|
||||
import { CustomSelect } from "components/ui";
|
||||
// icons
|
||||
import { ChartBarIcon } from "@heroicons/react/24/outline";
|
||||
import { getPriorityIcon } from "components/icons/priority-icon";
|
||||
// types
|
||||
import { IIssue, UserAuth } from "types";
|
||||
import { UserAuth } from "types";
|
||||
// constants
|
||||
import { PRIORITIES } from "constants/project";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
value: string | null;
|
||||
onChange: (val: string) => void;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const SidebarPrioritySelect: React.FC<Props> = ({ control, submitChanges, userAuth }) => {
|
||||
export const SidebarPrioritySelect: React.FC<Props> = ({ value, onChange, userAuth }) => {
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
@ -28,38 +26,31 @@ export const SidebarPrioritySelect: React.FC<Props> = ({ control, submitChanges,
|
||||
<p>Priority</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value } }) => (
|
||||
<CustomSelect
|
||||
label={
|
||||
<span
|
||||
className={`flex items-center gap-2 text-left capitalize ${
|
||||
value ? "" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{getPriorityIcon(value && value !== "" ? value ?? "" : "None", "text-sm")}
|
||||
{value && value !== "" ? value : "None"}
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
onChange={(value: any) => {
|
||||
submitChanges({ priority: value });
|
||||
}}
|
||||
disabled={isNotAllowed}
|
||||
<CustomSelect
|
||||
label={
|
||||
<span
|
||||
className={`flex items-center gap-2 text-left capitalize ${
|
||||
value ? "" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{PRIORITIES.map((option) => (
|
||||
<CustomSelect.Option key={option} value={option} className="capitalize">
|
||||
<>
|
||||
{getPriorityIcon(option, "text-sm")}
|
||||
{option ?? "None"}
|
||||
</>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
{getPriorityIcon(value && value !== "" ? value ?? "" : "None", "text-sm")}
|
||||
{value && value !== "" ? value : "None"}
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
width="w-full"
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{PRIORITIES.map((option) => (
|
||||
<CustomSelect.Option key={option} value={option} className="capitalize">
|
||||
<>
|
||||
{getPriorityIcon(option, "text-sm")}
|
||||
{option ?? "None"}
|
||||
</>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -4,28 +4,28 @@ import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
// services
|
||||
import stateService from "services/state.service";
|
||||
// ui
|
||||
import { Spinner, CustomSelect } from "components/ui";
|
||||
// icons
|
||||
import { Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
import { getStateGroupIcon } from "components/icons";
|
||||
// helpers
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, UserAuth } from "types";
|
||||
import { UserAuth } from "types";
|
||||
// constants
|
||||
import { STATE_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const SidebarStateSelect: React.FC<Props> = ({ control, submitChanges, userAuth }) => {
|
||||
export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, userAuth }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
@ -37,6 +37,8 @@ export const SidebarStateSelect: React.FC<Props> = ({ control, submitChanges, us
|
||||
);
|
||||
const states = getStatesList(stateGroups ?? {});
|
||||
|
||||
const selectedState = states?.find((s) => s.id === value);
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
@ -46,60 +48,40 @@ export const SidebarStateSelect: React.FC<Props> = ({ control, submitChanges, us
|
||||
<p>State</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field: { value } }) => (
|
||||
<CustomSelect
|
||||
label={
|
||||
<span
|
||||
className={`flex items-center gap-2 text-left ${value ? "" : "text-gray-900"}`}
|
||||
>
|
||||
{value ? (
|
||||
<>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: states?.find((option) => option.id === value)?.color,
|
||||
}}
|
||||
/>
|
||||
{states?.find((option) => option.id === value)?.name}
|
||||
</>
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
onChange={(value: any) => {
|
||||
submitChanges({ state: value });
|
||||
}}
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{states ? (
|
||||
states.length > 0 ? (
|
||||
states.map((option) => (
|
||||
<CustomSelect.Option key={option.id} value={option.id}>
|
||||
<>
|
||||
{option.color && (
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: option.color }}
|
||||
/>
|
||||
)}
|
||||
{option.name}
|
||||
</>
|
||||
</CustomSelect.Option>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center">No states found</div>
|
||||
)
|
||||
) : (
|
||||
<Spinner />
|
||||
<CustomSelect
|
||||
label={
|
||||
<div className={`flex items-center gap-2 text-left ${value ? "" : "text-gray-900"}`}>
|
||||
{getStateGroupIcon(
|
||||
selectedState?.group ?? "backlog",
|
||||
"16",
|
||||
"16",
|
||||
selectedState?.color ?? ""
|
||||
)}
|
||||
</CustomSelect>
|
||||
{addSpaceIfCamelCase(selectedState?.name ?? "")}
|
||||
</div>
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
width="w-full"
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{states ? (
|
||||
states.length > 0 ? (
|
||||
states.map((state) => (
|
||||
<CustomSelect.Option key={state.id} value={state.id}>
|
||||
<>
|
||||
{getStateGroupIcon(state.group, "16", "16", state.color)}
|
||||
{state.name}
|
||||
</>
|
||||
</CustomSelect.Option>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center">No states found</div>
|
||||
)
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
/>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -149,16 +149,12 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
const handleCreateLink = async (formData: IIssueLink) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetail) return;
|
||||
|
||||
const previousLinks = issueDetail?.issue_link.map((l) => ({ title: l.title, url: l.url }));
|
||||
|
||||
const payload: Partial<IIssue> = {
|
||||
links_list: [...(previousLinks ?? []), formData],
|
||||
};
|
||||
const payload = { metadata: {}, ...formData };
|
||||
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueDetail.id, payload)
|
||||
.createIssueLink(workspaceSlug as string, projectId as string, issueDetail.id, payload)
|
||||
.then((res) => {
|
||||
mutate(ISSUE_DETAILS(issueDetail.id as string));
|
||||
mutate(ISSUE_DETAILS(issueDetail.id));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
@ -171,17 +167,15 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
const updatedLinks = issueDetail.issue_link.filter((l) => l.id !== linkId);
|
||||
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueDetail.id as string),
|
||||
ISSUE_DETAILS(issueDetail.id),
|
||||
(prevData) => ({ ...(prevData as IIssue), issue_link: updatedLinks }),
|
||||
false
|
||||
);
|
||||
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueDetail.id, {
|
||||
links_list: updatedLinks,
|
||||
})
|
||||
.deleteIssueLink(workspaceSlug as string, projectId as string, issueDetail.id, linkId)
|
||||
.then((res) => {
|
||||
mutate(ISSUE_DETAILS(issueDetail.id as string));
|
||||
mutate(ISSUE_DETAILS(issueDetail.id));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
@ -223,7 +217,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
isOpen={deleteIssueModal}
|
||||
data={issueDetail ?? null}
|
||||
/>
|
||||
<div className="w-full divide-y-2 divide-gray-100 sticky top-5">
|
||||
<div className="sticky top-5 w-full divide-y-2 divide-gray-100">
|
||||
<div className="flex items-center justify-between pb-3">
|
||||
<h4 className="text-sm font-medium">
|
||||
{issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}
|
||||
@ -249,20 +243,38 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
</div>
|
||||
<div className="divide-y-2 divide-gray-100">
|
||||
<div className="py-1">
|
||||
<SidebarStateSelect
|
||||
<Controller
|
||||
control={control}
|
||||
submitChanges={submitChanges}
|
||||
userAuth={userAuth}
|
||||
name="state"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarStateSelect
|
||||
value={value}
|
||||
onChange={(val: string) => submitChanges({ state: val })}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SidebarAssigneeSelect
|
||||
<Controller
|
||||
control={control}
|
||||
submitChanges={submitChanges}
|
||||
userAuth={userAuth}
|
||||
name="assignees_list"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarAssigneeSelect
|
||||
value={value}
|
||||
onChange={(val: string[]) => submitChanges({ assignees_list: val })}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SidebarPrioritySelect
|
||||
<Controller
|
||||
control={control}
|
||||
submitChanges={submitChanges}
|
||||
userAuth={userAuth}
|
||||
name="priority"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarPrioritySelect
|
||||
value={value}
|
||||
onChange={(val: string) => submitChanges({ priority: val })}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
@ -448,8 +460,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<div className="bg-gray-50 border-y border-gray-400">
|
||||
<div className="flex select-none font-medium items-center gap-2 truncate p-2 text-gray-900">
|
||||
<div className="border-y border-gray-400 bg-gray-50">
|
||||
<div className="flex select-none items-center gap-2 truncate p-2 font-medium text-gray-900">
|
||||
<RectangleGroupIcon className="h-3 w-3" />{" "}
|
||||
{label.name}
|
||||
</div>
|
||||
|
@ -4,12 +4,12 @@ import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
// ui
|
||||
import { AssigneesList, Avatar, Tooltip } from "components/ui";
|
||||
import { AssigneesList, Avatar, CustomSearchSelect, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { UserGroupIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// fetch-keys
|
||||
@ -18,6 +18,7 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>) => void;
|
||||
position?: "left" | "right";
|
||||
selfPositioned?: boolean;
|
||||
tooltipPosition?: "left" | "right";
|
||||
isNotAllowed: boolean;
|
||||
@ -26,6 +27,7 @@ type Props = {
|
||||
export const ViewAssigneeSelect: React.FC<Props> = ({
|
||||
issue,
|
||||
partialUpdateIssue,
|
||||
position = "left",
|
||||
selfPositioned = false,
|
||||
tooltipPosition = "right",
|
||||
isNotAllowed,
|
||||
@ -40,9 +42,27 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
|
||||
: null
|
||||
);
|
||||
|
||||
const options =
|
||||
members?.map((member) => ({
|
||||
value: member.member.id,
|
||||
query:
|
||||
(member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email) +
|
||||
" " +
|
||||
member.member.last_name ?? "",
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar user={member.member} />
|
||||
{member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email}
|
||||
</div>
|
||||
),
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
as="div"
|
||||
<CustomSearchSelect
|
||||
value={issue.assignees}
|
||||
onChange={(data: any) => {
|
||||
const newData = issue.assignees ?? [];
|
||||
@ -50,69 +70,46 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
|
||||
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
|
||||
else newData.push(data);
|
||||
|
||||
partialUpdateIssue({ assignees_list: newData });
|
||||
partialUpdateIssue({ assignees_list: data });
|
||||
}}
|
||||
className={`group ${!selfPositioned ? "relative" : ""} flex-shrink-0`}
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{({ open }) => (
|
||||
<div>
|
||||
<Listbox.Button>
|
||||
<Tooltip
|
||||
position={`top-${tooltipPosition}`}
|
||||
tooltipHeading="Assignees"
|
||||
tooltipContent={
|
||||
issue.assignee_details.length > 0
|
||||
? issue.assignee_details
|
||||
.map((assignee) =>
|
||||
assignee?.first_name !== "" ? assignee?.first_name : assignee?.email
|
||||
)
|
||||
.join(", ")
|
||||
: "No Assignee"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`flex ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center gap-1 text-xs`}
|
||||
>
|
||||
<AssigneesList userIds={issue.assignees ?? []} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
options={options}
|
||||
label={
|
||||
<Tooltip
|
||||
position={`top-${tooltipPosition}`}
|
||||
tooltipHeading="Assignees"
|
||||
tooltipContent={
|
||||
issue.assignee_details.length > 0
|
||||
? issue.assignee_details
|
||||
.map((assignee) =>
|
||||
assignee?.first_name !== "" ? assignee?.first_name : assignee?.email
|
||||
)
|
||||
.join(", ")
|
||||
: "No Assignee"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`flex ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center gap-2 text-gray-500`}
|
||||
>
|
||||
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-48 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg min-w-full ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{members?.map((member) => (
|
||||
<Listbox.Option
|
||||
key={member.member.id}
|
||||
className={({ active, selected }) =>
|
||||
`flex items-center gap-x-1 cursor-pointer select-none p-2 whitespace-nowrap ${
|
||||
active ? "bg-indigo-50" : ""
|
||||
} ${
|
||||
selected || issue.assignees?.includes(member.member.id)
|
||||
? "bg-indigo-50 font-medium"
|
||||
: "font-normal"
|
||||
}`
|
||||
}
|
||||
value={member.member.id}
|
||||
>
|
||||
<Avatar user={member.member} />
|
||||
{member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
{issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<AssigneesList userIds={issue.assignees} length={3} showLength={false} />
|
||||
<span className="text-gray-500">{issue.assignees.length} Assignees</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-gray-500">Assignee</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
multiple
|
||||
noChevron
|
||||
position={position}
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -12,6 +12,7 @@ import { PRIORITIES } from "constants/project";
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>) => void;
|
||||
position?: "left" | "right";
|
||||
selfPositioned?: boolean;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
@ -19,40 +20,44 @@ type Props = {
|
||||
export const ViewPrioritySelect: React.FC<Props> = ({
|
||||
issue,
|
||||
partialUpdateIssue,
|
||||
position = "left",
|
||||
selfPositioned = false,
|
||||
isNotAllowed,
|
||||
}) => (
|
||||
<CustomSelect
|
||||
label={
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={issue.priority ?? "None"}>
|
||||
<span>
|
||||
{getPriorityIcon(
|
||||
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
|
||||
"text-sm"
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
}
|
||||
value={issue.state}
|
||||
onChange={(data: string) => {
|
||||
partialUpdateIssue({ priority: data });
|
||||
}}
|
||||
onChange={(data: string) => partialUpdateIssue({ priority: data })}
|
||||
maxHeight="md"
|
||||
buttonClassName={`flex ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center gap-x-2 rounded px-2 py-0.5 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
|
||||
issue.priority === "urgent"
|
||||
? "bg-red-100 text-red-600 hover:bg-red-100"
|
||||
: issue.priority === "high"
|
||||
? "bg-orange-100 text-orange-500 hover:bg-orange-100"
|
||||
: issue.priority === "medium"
|
||||
? "bg-yellow-100 text-yellow-500 hover:bg-yellow-100"
|
||||
: issue.priority === "low"
|
||||
? "bg-green-100 text-green-500 hover:bg-green-100"
|
||||
: "bg-gray-100"
|
||||
} border-none`}
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-6 w-6 place-items-center rounded ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
|
||||
issue.priority === "urgent"
|
||||
? "bg-red-100 text-red-600 hover:bg-red-100"
|
||||
: issue.priority === "high"
|
||||
? "bg-orange-100 text-orange-500 hover:bg-orange-100"
|
||||
: issue.priority === "medium"
|
||||
? "bg-yellow-100 text-yellow-500 hover:bg-yellow-100"
|
||||
: issue.priority === "low"
|
||||
? "bg-green-100 text-green-500 hover:bg-green-100"
|
||||
: "bg-gray-100"
|
||||
} border-none`}
|
||||
>
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={issue.priority ?? "None"}>
|
||||
<span>
|
||||
{getPriorityIcon(
|
||||
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
|
||||
"text-sm"
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</button>
|
||||
}
|
||||
noChevron
|
||||
disabled={isNotAllowed}
|
||||
position={position}
|
||||
selfPositioned={selfPositioned}
|
||||
>
|
||||
{PRIORITIES?.map((priority) => (
|
||||
|
@ -5,7 +5,9 @@ import useSWR from "swr";
|
||||
// services
|
||||
import stateService from "services/state.service";
|
||||
// ui
|
||||
import { CustomSelect, Tooltip } from "components/ui";
|
||||
import { CustomSearchSelect, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { getStateGroupIcon } from "components/icons";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
@ -17,6 +19,7 @@ import { STATE_LIST } from "constants/fetch-keys";
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>) => void;
|
||||
position?: "left" | "right";
|
||||
selfPositioned?: boolean;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
@ -24,6 +27,7 @@ type Props = {
|
||||
export const ViewStateSelect: React.FC<Props> = ({
|
||||
issue,
|
||||
partialUpdateIssue,
|
||||
position = "left",
|
||||
selfPositioned = false,
|
||||
isNotAllowed,
|
||||
}) => {
|
||||
@ -38,50 +42,39 @@ export const ViewStateSelect: React.FC<Props> = ({
|
||||
);
|
||||
const states = getStatesList(stateGroups ?? {});
|
||||
|
||||
const options = states?.map((state) => ({
|
||||
value: state.id,
|
||||
query: state.name,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
{getStateGroupIcon(state.group, "16", "16", state.color)}
|
||||
{state.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const selectedOption = states?.find((s) => s.id === issue.state);
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
label={
|
||||
<>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: states?.find((s) => s.id === issue.state)?.color,
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
tooltipHeading="State"
|
||||
tooltipContent={addSpaceIfCamelCase(
|
||||
states?.find((s) => s.id === issue.state)?.name ?? ""
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{addSpaceIfCamelCase(states?.find((s) => s.id === issue.state)?.name ?? "")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
<CustomSearchSelect
|
||||
value={issue.state}
|
||||
onChange={(data: string) => {
|
||||
partialUpdateIssue({ state: data });
|
||||
}}
|
||||
maxHeight="md"
|
||||
noChevron
|
||||
onChange={(data: string) => partialUpdateIssue({ state: data })}
|
||||
options={options}
|
||||
label={
|
||||
<Tooltip
|
||||
tooltipHeading="State"
|
||||
tooltipContent={addSpaceIfCamelCase(selectedOption?.name ?? "")}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
{selectedOption &&
|
||||
getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)}
|
||||
{selectedOption?.name ?? "State"}
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
position={position}
|
||||
disabled={isNotAllowed}
|
||||
selfPositioned={selfPositioned}
|
||||
>
|
||||
{states?.map((state) => (
|
||||
<CustomSelect.Option key={state.id} value={state.id}>
|
||||
<>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: state.color,
|
||||
}}
|
||||
/>
|
||||
{addSpaceIfCamelCase(state.name)}
|
||||
</>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
noChevron
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -109,7 +109,7 @@ export const CreateUpdateLabelInline: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md border p-3 md:w-2/3 ${
|
||||
className={`flex items-center gap-2 rounded-[10px] border bg-white p-5 ${
|
||||
labelForm ? "" : "hidden"
|
||||
}`}
|
||||
>
|
||||
|
@ -92,7 +92,7 @@ export const LabelsListModal: React.FC<Props> = ({ isOpen, handleClose, parent }
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
||||
<Combobox>
|
||||
<div className="relative m-1">
|
||||
<MagnifyingGlassIcon
|
||||
@ -144,7 +144,7 @@ export const LabelsListModal: React.FC<Props> = ({ isOpen, handleClose, parent }
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="block flex-shrink-0 h-1.5 w-1.5 rounded-full"
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color,
|
||||
}}
|
||||
|
@ -59,32 +59,34 @@ export const SingleLabelGroup: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Disclosure as="div" className="rounded-md border p-3 text-gray-900 md:w-2/3" defaultOpen>
|
||||
<Disclosure as="div" className="rounded-[10px] border bg-white p-5 text-gray-900" defaultOpen>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 cursor-pointer">
|
||||
<Disclosure.Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex cursor-pointer items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
<RectangleGroupIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<h6 className="font-medium text-gray-600">{label.name}</h6>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
|
||||
Add more labels
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => editLabel(label)}>Edit</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => handleLabelDelete(label.id)}>
|
||||
Delete
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
<Disclosure.Button>
|
||||
<span>
|
||||
<ChevronDownIcon
|
||||
className={`h-4 w-4 text-gray-500 ${!open ? "-rotate-90 transform" : ""}`}
|
||||
className={`h-4 w-4 text-gray-500 ${!open ? "rotate-90 transform" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<RectangleGroupIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<h6 className="text-sm">{label.name}</h6>
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
|
||||
Add more labels
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => editLabel(label)}>Edit</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => handleLabelDelete(label.id)}>
|
||||
Delete
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
show={open}
|
||||
@ -96,22 +98,22 @@ export const SingleLabelGroup: React.FC<Props> = ({
|
||||
leaveTo="transform opacity-0"
|
||||
>
|
||||
<Disclosure.Panel>
|
||||
<div className="mt-2 ml-4">
|
||||
<div className="mt-3 ml-6 space-y-3">
|
||||
{labelChildren.map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="group pl-4 py-1 flex items-center justify-between rounded text-sm hover:bg-gray-100"
|
||||
className="group flex items-center justify-between rounded-md border p-2 text-sm"
|
||||
>
|
||||
<h5 className="flex items-center gap-2">
|
||||
<h5 className="flex items-center gap-3 text-gray-600">
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child.color,
|
||||
}}
|
||||
/>
|
||||
{child.name}
|
||||
</h5>
|
||||
<div className="opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
|
||||
<div className="pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => removeFromGroup(child)}>
|
||||
Remove from group
|
||||
|
@ -18,16 +18,16 @@ export const SingleLabel: React.FC<Props> = ({
|
||||
editLabel,
|
||||
handleLabelDelete,
|
||||
}) => (
|
||||
<div className="gap-2 space-y-3 divide-y rounded-md border p-3 md:w-2/3">
|
||||
<div className="gap-2 space-y-3 divide-y rounded-[10px] border bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="h-3 w-3 flex-shrink-0 rounded-full"
|
||||
className="h-3.5 w-3.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
<h6 className="text-sm">{label.name}</h6>
|
||||
<h6 className="font-medium text-gray-600">{label.name}</h6>
|
||||
</div>
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
|
||||
|
@ -114,8 +114,20 @@ export const ModuleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, sta
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ModuleStatusSelect control={control} error={errors.status} />
|
||||
<ModuleLeadSelect control={control} />
|
||||
<ModuleMembersSelect control={control} />
|
||||
<Controller
|
||||
control={control}
|
||||
name="lead"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ModuleLeadSelect value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="members"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ModuleMembersSelect value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -76,15 +76,12 @@ export const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, da
|
||||
.then((res) => {
|
||||
mutate<IModule[]>(
|
||||
MODULE_LIST(projectId as string),
|
||||
(prevData) => {
|
||||
const newData = prevData?.map((item) => {
|
||||
if (item.id === res.id) {
|
||||
return res;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return newData;
|
||||
},
|
||||
(prevData) =>
|
||||
prevData?.map((p) => {
|
||||
if (p.id === res.id) return { ...p, ...payload };
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
handleClose();
|
||||
@ -109,6 +106,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, da
|
||||
|
||||
const payload: Partial<IModule> = {
|
||||
...formData,
|
||||
members_list: formData.members,
|
||||
};
|
||||
|
||||
if (!data) await createModule(payload);
|
||||
|
@ -1,57 +1,78 @@
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import Image from "next/image";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, Control } from "react-hook-form";
|
||||
// services
|
||||
import projectServices from "services/project.service";
|
||||
// ui
|
||||
import SearchListbox from "components/search-listbox";
|
||||
import { Avatar, CustomSearchSelect } from "components/ui";
|
||||
// icons
|
||||
import { UserIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IModule } from "types";
|
||||
import User from "public/user.png";
|
||||
// fetch-keys
|
||||
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
control: Control<IModule, any>;
|
||||
value: string | null;
|
||||
onChange: () => void;
|
||||
};
|
||||
|
||||
export const ModuleLeadSelect: React.FC<Props> = ({ control }) => {
|
||||
export const ModuleLeadSelect: React.FC<Props> = ({ value, onChange }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: people } = useSWR(
|
||||
const { data: members } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectServices.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const options =
|
||||
members?.map((member) => ({
|
||||
value: member.member.id,
|
||||
query:
|
||||
(member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email) +
|
||||
" " +
|
||||
member.member.last_name ?? "",
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar user={member.member} />
|
||||
{member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email}
|
||||
</div>
|
||||
),
|
||||
})) ?? [];
|
||||
|
||||
const selectedOption = members?.find((m) => m.member.id === value)?.member;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="lead"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SearchListbox
|
||||
title="Lead"
|
||||
optionsFontsize="sm"
|
||||
options={people?.map((person) => ({
|
||||
value: person.member.id,
|
||||
display:
|
||||
person.member.first_name && person.member.first_name !== ""
|
||||
? person.member.first_name
|
||||
: person.member.email,
|
||||
}))}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
icon={<UserIcon className="h-3 w-3 text-gray-500" />}
|
||||
/>
|
||||
)}
|
||||
<CustomSearchSelect
|
||||
options={options}
|
||||
value={value}
|
||||
label={
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
{selectedOption ? (
|
||||
<Avatar user={selectedOption} />
|
||||
) : (
|
||||
<div className="h-4 w-4 rounded-full bg-white">
|
||||
<Image src={User} height="100%" width="100%" className="rounded-full" alt="No user" />
|
||||
</div>
|
||||
)}
|
||||
{selectedOption
|
||||
? selectedOption?.first_name && selectedOption.first_name !== ""
|
||||
? selectedOption?.first_name
|
||||
: selectedOption?.email
|
||||
: "N/A"}
|
||||
</div>
|
||||
}
|
||||
onChange={onChange}
|
||||
noChevron
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -4,55 +4,72 @@ import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, Control } from "react-hook-form";
|
||||
// services
|
||||
import projectServices from "services/project.service";
|
||||
// ui
|
||||
import SearchListbox from "components/search-listbox";
|
||||
import { AssigneesList, Avatar, CustomSearchSelect } from "components/ui";
|
||||
// icons
|
||||
import { UserIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IModule } from "types";
|
||||
import { UserGroupIcon } from "@heroicons/react/24/outline";
|
||||
// fetch-keys
|
||||
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
control: Control<IModule, any>;
|
||||
value: string[];
|
||||
onChange: () => void;
|
||||
};
|
||||
|
||||
export const ModuleMembersSelect: React.FC<Props> = ({ control }) => {
|
||||
export const ModuleMembersSelect: React.FC<Props> = ({ value, onChange }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: people } = useSWR(
|
||||
const { data: members } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectServices.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
const options =
|
||||
members?.map((member) => ({
|
||||
value: member.member.id,
|
||||
query:
|
||||
(member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email) +
|
||||
" " +
|
||||
member.member.last_name ?? "",
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar user={member.member} />
|
||||
{member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email}
|
||||
</div>
|
||||
),
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="members_list"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SearchListbox
|
||||
title="Members"
|
||||
optionsFontsize="sm"
|
||||
options={people?.map((person) => ({
|
||||
value: person.member.id,
|
||||
display:
|
||||
person.member.first_name && person.member.first_name !== ""
|
||||
? person.member.first_name
|
||||
: person.member.email,
|
||||
}))}
|
||||
multiple={true}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
icon={<UserIcon className="h-3 w-3 text-gray-500" />}
|
||||
/>
|
||||
)}
|
||||
<CustomSearchSelect
|
||||
value={value}
|
||||
label={
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
{value && value.length > 0 && Array.isArray(value) ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<AssigneesList userIds={value} length={3} showLength={false} />
|
||||
<span className="text-gray-500">{value.length} Assignees</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-gray-500">Assignee</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
height="md"
|
||||
multiple
|
||||
noChevron
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -3,7 +3,7 @@ import React from "react";
|
||||
// react hook form
|
||||
import { Controller, FieldError, Control } from "react-hook-form";
|
||||
// ui
|
||||
import { CustomListbox } from "components/ui";
|
||||
import { CustomSelect } from "components/ui";
|
||||
// icons
|
||||
import { Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
@ -22,26 +22,43 @@ export const ModuleStatusSelect: React.FC<Props> = ({ control, error }) => (
|
||||
rules={{ required: true }}
|
||||
name="status"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div>
|
||||
<CustomListbox
|
||||
className={`${
|
||||
error
|
||||
? "border-red-500 bg-red-100 hover:bg-red-100 focus:outline-none focus:ring-red-500"
|
||||
: ""
|
||||
}`}
|
||||
title="Status"
|
||||
options={MODULE_STATUS.map((status) => ({
|
||||
value: status.value,
|
||||
display: status.label,
|
||||
color: status.color,
|
||||
}))}
|
||||
value={value}
|
||||
optionsFontsize="sm"
|
||||
onChange={onChange}
|
||||
icon={<Squares2X2Icon className={`h-3 w-3 ${error ? "text-black" : "text-gray-400"}`} />}
|
||||
/>
|
||||
{error && <p className="mt-1 text-sm text-red-600">{error.message}</p>}
|
||||
</div>
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 text-xs ${
|
||||
error ? "text-red-500" : ""
|
||||
}`}
|
||||
>
|
||||
<Squares2X2Icon className={`h-3 w-3 ${error ? "text-red-500" : "text-gray-400"}`} />
|
||||
{value && (
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: MODULE_STATUS.find((s) => s.value === value)?.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{MODULE_STATUS.find((s) => s.value === value)?.label ?? "Status"}
|
||||
</div>
|
||||
}
|
||||
onChange={onChange}
|
||||
noChevron
|
||||
>
|
||||
{MODULE_STATUS.map((status) => (
|
||||
<CustomSelect.Option key={status.value} value={status.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: status.color,
|
||||
}}
|
||||
/>
|
||||
{status.label}
|
||||
</div>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
@ -5,160 +5,88 @@ import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import workspaceService from "services/workspace.service";
|
||||
import projectService from "services/project.service";
|
||||
// ui
|
||||
import { Avatar, CustomSearchSelect } from "components/ui";
|
||||
// icons
|
||||
import { UserIcon } from "@heroicons/react/24/outline";
|
||||
import { UserCircleIcon } from "@heroicons/react/24/outline";
|
||||
import User from "public/user.png";
|
||||
// types
|
||||
import { IModule, IUserLite } from "types";
|
||||
// fetch-keys
|
||||
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
control: Control<Partial<IModule>, any>;
|
||||
submitChanges: (formData: Partial<IModule>) => void;
|
||||
lead: IUserLite | null;
|
||||
value: string | null | undefined;
|
||||
onChange: (val: string) => void;
|
||||
};
|
||||
|
||||
export const SidebarLeadSelect: React.FC<Props> = ({ control, submitChanges, lead }) => {
|
||||
export const SidebarLeadSelect: React.FC<Props> = ({ value, onChange }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: people } = useSWR(
|
||||
workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug as string) : null,
|
||||
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
|
||||
const { data: members } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const options =
|
||||
members?.map((member) => ({
|
||||
value: member.member.id,
|
||||
query:
|
||||
(member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email) +
|
||||
" " +
|
||||
member.member.last_name ?? "",
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar user={member.member} />
|
||||
{member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email}
|
||||
</div>
|
||||
),
|
||||
})) ?? [];
|
||||
|
||||
const selectedOption = members?.find((m) => m.member.id === value)?.member;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
|
||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Lead</p>
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<div className="flex w-40 items-center justify-start gap-2">
|
||||
<UserCircleIcon className="h-5 w-5 text-gray-400" />
|
||||
<span>Lead</span>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="lead"
|
||||
render={({ field: { value } }) => (
|
||||
<Listbox
|
||||
as="div"
|
||||
value={value}
|
||||
onChange={(value: any) => {
|
||||
submitChanges({ lead: value });
|
||||
}}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{({ open }) => (
|
||||
<div className="relative">
|
||||
<Listbox.Button className="flex w-full cursor-pointer items-center gap-1 text-xs">
|
||||
<span
|
||||
className={`hidden truncate text-left sm:block ${
|
||||
value ? "" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{lead ? (
|
||||
lead.avatar && lead.avatar !== "" ? (
|
||||
<div className="h-5 w-5 rounded-full border-2 border-transparent">
|
||||
<Image
|
||||
src={lead.avatar}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded-full"
|
||||
alt={lead?.first_name}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
|
||||
{lead?.first_name && lead.first_name !== ""
|
||||
? lead.first_name.charAt(0)
|
||||
: lead?.email.charAt(0)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
|
||||
<Image
|
||||
src={User}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded-full"
|
||||
alt="No user"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{lead
|
||||
? lead?.first_name && lead.first_name !== ""
|
||||
? lead?.first_name
|
||||
: lead?.email
|
||||
: "N/A"}
|
||||
</div>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-48 w-full overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="py-1">
|
||||
{people ? (
|
||||
people.length > 0 ? (
|
||||
people.map((option) => (
|
||||
<Listbox.Option
|
||||
key={option.member.id}
|
||||
className={({ active, selected }) =>
|
||||
`${
|
||||
active || selected ? "bg-indigo-50" : ""
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
}
|
||||
value={option.member.id}
|
||||
>
|
||||
{option.member.avatar && option.member.avatar !== "" ? (
|
||||
<div className="relative h-4 w-4">
|
||||
<Image
|
||||
src={option.member.avatar}
|
||||
alt="avatar"
|
||||
className="rounded-full"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 capitalize text-white">
|
||||
{option.member.first_name && option.member.first_name !== ""
|
||||
? option.member.first_name.charAt(0)
|
||||
: option.member.email.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
{option.member.first_name && option.member.first_name !== ""
|
||||
? option.member.first_name
|
||||
: option.member.email}
|
||||
</Listbox.Option>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center">No members found</div>
|
||||
)
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 px-2">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
<CustomSearchSelect
|
||||
value={value}
|
||||
label={
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
{selectedOption ? (
|
||||
<Avatar user={selectedOption} />
|
||||
) : (
|
||||
<div className="h-5 w-5 rounded-full border-2 border-transparent bg-white">
|
||||
<Image
|
||||
src={User}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded-full"
|
||||
alt="No user"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
{selectedOption
|
||||
? selectedOption?.first_name && selectedOption.first_name !== ""
|
||||
? selectedOption?.first_name
|
||||
: selectedOption?.email
|
||||
: "N/A"}
|
||||
</div>
|
||||
}
|
||||
options={options}
|
||||
height="md"
|
||||
position="right"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,132 +1,79 @@
|
||||
import React from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
// services
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { UserGroupIcon } from "@heroicons/react/24/outline";
|
||||
import workspaceService from "services/workspace.service";
|
||||
// headless ui
|
||||
import projectService from "services/project.service";
|
||||
// ui
|
||||
import { AssigneesList } from "components/ui";
|
||||
// types
|
||||
import { IModule } from "types";
|
||||
// constants
|
||||
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||
import { AssigneesList, Avatar, CustomSearchSelect } from "components/ui";
|
||||
// icons
|
||||
import { UserGroupIcon } from "@heroicons/react/24/outline";
|
||||
// fetch-keys
|
||||
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
control: Control<Partial<IModule>, any>;
|
||||
submitChanges: (formData: Partial<IModule>) => void;
|
||||
value: string[] | undefined;
|
||||
onChange: (val: string[]) => void;
|
||||
};
|
||||
|
||||
export const SidebarMembersSelect: React.FC<Props> = ({ control, submitChanges }) => {
|
||||
export const SidebarMembersSelect: React.FC<Props> = ({ value, onChange }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: people } = useSWR(
|
||||
workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug as string) : null,
|
||||
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
|
||||
const { data: members } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const options =
|
||||
members?.map((member) => ({
|
||||
value: member.member.id,
|
||||
query:
|
||||
(member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email) +
|
||||
" " +
|
||||
member.member.last_name ?? "",
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar user={member.member} />
|
||||
{member.member.first_name && member.member.first_name !== ""
|
||||
? member.member.first_name
|
||||
: member.member.email}
|
||||
</div>
|
||||
),
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
|
||||
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Members</p>
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<div className="flex w-40 items-center justify-start gap-2">
|
||||
<UserGroupIcon className="h-5 w-5 text-gray-400" />
|
||||
<span>Members</span>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="members_list"
|
||||
render={({ field: { value } }) => (
|
||||
<Listbox
|
||||
as="div"
|
||||
value={value}
|
||||
multiple={true}
|
||||
onChange={(value: any) => {
|
||||
submitChanges({ members_list: value });
|
||||
}}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{({ open }) => (
|
||||
<div className="relative">
|
||||
<Listbox.Button className="flex w-full cursor-pointer items-center gap-1 text-xs">
|
||||
<span
|
||||
className={`hidden truncate text-left sm:block ${
|
||||
value ? "" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-1 text-xs">
|
||||
{value && Array.isArray(value) ? (
|
||||
<AssigneesList userIds={value} length={10} />
|
||||
) : null}
|
||||
</div>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Listbox.Options className="absolute left-0 z-10 mt-1 max-h-48 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none w-full">
|
||||
<div className="py-1">
|
||||
{people ? (
|
||||
people.length > 0 ? (
|
||||
people.map((option) => (
|
||||
<Listbox.Option
|
||||
key={option.member.id}
|
||||
className={({ active, selected }) =>
|
||||
`${
|
||||
active || selected ? "bg-indigo-50" : ""
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
}
|
||||
value={option.member.id}
|
||||
>
|
||||
{option.member.avatar && option.member.avatar !== "" ? (
|
||||
<div className="relative h-4 w-4">
|
||||
<Image
|
||||
src={option.member.avatar}
|
||||
alt="avatar"
|
||||
className="rounded-full"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 capitalize text-white">
|
||||
{option.member.first_name && option.member.first_name !== ""
|
||||
? option.member.first_name.charAt(0)
|
||||
: option.member.email.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
{option.member.first_name && option.member.first_name !== ""
|
||||
? option.member.first_name
|
||||
: option.member.email}
|
||||
</Listbox.Option>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center">No members found</div>
|
||||
)
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 px-2">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
<CustomSearchSelect
|
||||
value={value}
|
||||
label={
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
{value && value.length > 0 && Array.isArray(value) ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<AssigneesList userIds={value} length={3} showLength={false} />
|
||||
<span className="text-gray-500">{value.length} Assignees</span>
|
||||
</div>
|
||||
) : (
|
||||
"No members"
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
height="md"
|
||||
position="right"
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
@ -9,32 +8,32 @@ import { mutate } from "swr";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// icons
|
||||
import {
|
||||
ArrowLongRightIcon,
|
||||
CalendarDaysIcon,
|
||||
ChartPieIcon,
|
||||
LinkIcon,
|
||||
PlusIcon,
|
||||
Squares2X2Icon,
|
||||
ChevronDownIcon,
|
||||
DocumentDuplicateIcon,
|
||||
DocumentIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Disclosure, Popover, Transition } from "@headlessui/react";
|
||||
import DatePicker from "react-datepicker";
|
||||
|
||||
// services
|
||||
import modulesService from "services/modules.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { LinkModal, LinksList, SidebarProgressStats } from "components/core";
|
||||
import { LinkModal, SidebarProgressStats } from "components/core";
|
||||
import { DeleteModuleModal, SidebarLeadSelect, SidebarMembersSelect } from "components/modules";
|
||||
import ProgressChart from "components/core/sidebar/progress-chart";
|
||||
|
||||
// components
|
||||
// ui
|
||||
import { CustomSelect, Loader, ProgressBar } from "components/ui";
|
||||
import { CustomMenu, CustomSelect, Loader, ProgressBar } from "components/ui";
|
||||
// helpers
|
||||
import { renderDateFormat, renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper";
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { renderDateFormat, renderShortDate, timeAgo } from "helpers/date-time.helper";
|
||||
import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helper";
|
||||
import { groupBy } from "helpers/array.helper";
|
||||
// types
|
||||
import { IIssue, IModule, ModuleIssueResponse, ModuleLink, UserAuth } from "types";
|
||||
@ -115,14 +114,10 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
|
||||
const handleCreateLink = async (formData: ModuleLink) => {
|
||||
if (!workspaceSlug || !projectId || !moduleId) return;
|
||||
|
||||
const previousLinks = module?.link_module.map((l) => ({ title: l.title, url: l.url }));
|
||||
|
||||
const payload: Partial<IModule> = {
|
||||
links_list: [...(previousLinks ?? []), formData],
|
||||
};
|
||||
const payload = { metadata: {}, ...formData };
|
||||
|
||||
await modulesService
|
||||
.patchModule(workspaceSlug as string, projectId as string, moduleId as string, payload)
|
||||
.createModuleLink(workspaceSlug as string, projectId as string, moduleId as string, payload)
|
||||
.then((res) => {
|
||||
mutate(MODULE_DETAILS(moduleId as string));
|
||||
})
|
||||
@ -135,11 +130,44 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteLink = (linkId: string) => {
|
||||
if (!module) return;
|
||||
const handleDeleteLink = async (linkId: string) => {
|
||||
if (!workspaceSlug || !projectId || !module) return;
|
||||
|
||||
const updatedLinks = module.link_module.filter((l) => l.id !== linkId);
|
||||
submitChanges({ links_list: updatedLinks });
|
||||
|
||||
mutate<IModule>(
|
||||
MODULE_DETAILS(module.id),
|
||||
(prevData) => ({ ...(prevData as IModule), link_module: updatedLinks }),
|
||||
false
|
||||
);
|
||||
|
||||
await modulesService
|
||||
.deleteModuleLink(workspaceSlug as string, projectId as string, module.id, linkId)
|
||||
.then((res) => {
|
||||
mutate(MODULE_DETAILS(module.id));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyText = () => {
|
||||
const originURL =
|
||||
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
copyTextToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${module?.id}`)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Module link copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -153,6 +181,10 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
|
||||
const isStartValid = new Date(`${module?.start_date}`) <= new Date();
|
||||
const isEndValid = new Date(`${module?.target_date}`) >= new Date(`${module?.start_date}`);
|
||||
|
||||
const progressPercentage = moduleIssues
|
||||
? Math.round((groupedIssues.completed.length / moduleIssues?.length) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkModal
|
||||
@ -168,225 +200,312 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
|
||||
<div
|
||||
className={`fixed top-0 ${
|
||||
isOpen ? "right-0" : "-right-[24rem]"
|
||||
} z-20 h-full w-[24rem] overflow-y-auto border-l bg-gray-50 p-5 duration-300`}
|
||||
} z-20 h-full w-[24rem] overflow-y-auto border-l bg-gray-50 py-5 duration-300`}
|
||||
>
|
||||
{module ? (
|
||||
<>
|
||||
<div className="flex gap-1 text-sm my-2">
|
||||
<div className="flex items-center ">
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
render={({ field: { value } }) => (
|
||||
<CustomSelect
|
||||
label={
|
||||
<span
|
||||
className={`flex items-center gap-1 text-left capitalize p-1 text-xs h-full w-full text-gray-900`}
|
||||
<div className="flex flex-col items-start justify-center">
|
||||
<div className="flex gap-2.5 px-7 text-sm">
|
||||
<div className="flex items-center ">
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
render={({ field: { value } }) => (
|
||||
<CustomSelect
|
||||
customButton={
|
||||
<span
|
||||
className={`flex cursor-pointer items-center rounded border-[0.5px] border-gray-200 bg-gray-100 px-2.5 py-1.5 text-center text-sm capitalize text-gray-800 `}
|
||||
>
|
||||
{capitalizeFirstLetter(`${watch("status")}`)}
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
onChange={(value: any) => {
|
||||
submitChanges({ status: value });
|
||||
}}
|
||||
>
|
||||
{MODULE_STATUS.map((option) => (
|
||||
<CustomSelect.Option key={option.value} value={option.value}>
|
||||
<span className="text-xs">{option.label}</span>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex h-full w-52 items-center justify-center gap-2 text-sm text-gray-800">
|
||||
<Popover className="flex h-full items-center justify-center rounded-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex h-full items-center gap-1 rounded border-[0.5px] border-gray-200 bg-gray-100 px-2.5 py-1.5 text-gray-800 ${
|
||||
open ? "bg-gray-100" : ""
|
||||
}`}
|
||||
>
|
||||
<Squares2X2Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{watch("status")}
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
onChange={(value: any) => {
|
||||
submitChanges({ status: value });
|
||||
}}
|
||||
>
|
||||
{MODULE_STATUS.map((option) => (
|
||||
<CustomSelect.Option key={option.value} value={option.value}>
|
||||
<span className="text-xs">{option.label}</span>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-center items-center gap-2 rounded-md border bg-transparent h-full p-2 px-4 text-xs font-medium text-gray-900 hover:bg-gray-100 hover:text-gray-900 focus:outline-none">
|
||||
<Popover className="flex justify-center items-center relative rounded-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center ${open ? "bg-gray-100" : ""}`}
|
||||
>
|
||||
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0 mr-2" />
|
||||
<span>
|
||||
{renderShortNumericDateFormat(`${module?.start_date}`)
|
||||
? renderShortNumericDateFormat(`${module?.start_date}`)
|
||||
: "N/A"}
|
||||
</span>
|
||||
</Popover.Button>
|
||||
<CalendarDaysIcon className="h-3 w-3" />
|
||||
<span>{renderShortDate(new Date(`${module.start_date}`))}</span>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 -left-10 z-20 transform overflow-hidden">
|
||||
<DatePicker
|
||||
selected={startDateRange}
|
||||
onChange={(date) => {
|
||||
submitChanges({
|
||||
start_date: renderDateFormat(date),
|
||||
});
|
||||
setStartDateRange(date);
|
||||
}}
|
||||
selectsStart
|
||||
startDate={startDateRange}
|
||||
endDate={endDateRange}
|
||||
inline
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
<Popover className="flex justify-center items-center relative rounded-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center ${open ? "bg-gray-100" : ""}`}
|
||||
>
|
||||
<span>
|
||||
-{" "}
|
||||
{renderShortNumericDateFormat(`${module?.target_date}`)
|
||||
? renderShortNumericDateFormat(`${module?.target_date}`)
|
||||
: "N/A"}
|
||||
</span>
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
|
||||
<DatePicker
|
||||
selected={startDateRange}
|
||||
onChange={(date) => {
|
||||
submitChanges({
|
||||
start_date: renderDateFormat(date),
|
||||
});
|
||||
setStartDateRange(date);
|
||||
}}
|
||||
selectsStart
|
||||
startDate={startDateRange}
|
||||
endDate={endDateRange}
|
||||
maxDate={endDateRange}
|
||||
shouldCloseOnSelect
|
||||
inline
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
<span>
|
||||
<ArrowLongRightIcon className="h-3 w-3" />
|
||||
</span>
|
||||
<Popover className="flex h-full items-center justify-center rounded-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center gap-1 rounded border-[0.5px] border-gray-200 bg-gray-100 px-2.5 py-1.5 text-gray-800 ${
|
||||
open ? "bg-gray-100" : ""
|
||||
}`}
|
||||
>
|
||||
<CalendarDaysIcon className="h-3 w-3 " />
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 -right-20 z-20 transform overflow-hidden">
|
||||
<DatePicker
|
||||
selected={endDateRange}
|
||||
onChange={(date) => {
|
||||
submitChanges({
|
||||
target_date: renderDateFormat(date),
|
||||
});
|
||||
setEndDateRange(date);
|
||||
}}
|
||||
selectsEnd
|
||||
startDate={startDateRange}
|
||||
endDate={endDateRange}
|
||||
minDate={startDateRange}
|
||||
inline
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
<span>{renderShortDate(new Date(`${module?.target_date}`))}</span>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
|
||||
<DatePicker
|
||||
selected={endDateRange}
|
||||
onChange={(date) => {
|
||||
submitChanges({
|
||||
target_date: renderDateFormat(date),
|
||||
});
|
||||
setEndDateRange(date);
|
||||
}}
|
||||
selectsEnd
|
||||
startDate={startDateRange}
|
||||
endDate={endDateRange}
|
||||
// minDate={startDateRange}
|
||||
|
||||
inline
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pb-3">
|
||||
<h4 className="text-sm font-medium">{module.name}</h4>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border p-2 shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
onClick={() =>
|
||||
copyTextToClipboard(
|
||||
`https://app.plane.so/${workspaceSlug}/projects/${projectId}/modules/${module.id}`
|
||||
)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Module link copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Some error occurred",
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-50 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
onClick={() => setModuleDeleteModal(true)}
|
||||
>
|
||||
<TrashIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y-2 divide-gray-100 text-xs">
|
||||
<div className="py-1">
|
||||
<SidebarLeadSelect
|
||||
control={control}
|
||||
submitChanges={submitChanges}
|
||||
lead={module.lead_detail}
|
||||
/>
|
||||
<SidebarMembersSelect control={control} submitChanges={submitChanges} />
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
|
||||
<ChartPieIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Progress</p>
|
||||
|
||||
<div className="flex flex-col gap-6 px-7 py-6">
|
||||
<div className="flex flex-col items-start justify-start gap-2 ">
|
||||
<div className="flex items-center justify-start gap-2 ">
|
||||
<h4 className="text-xl font-semibold text-gray-900">{module.name}</h4>
|
||||
<CustomMenu width="lg" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
<span className="flex items-center justify-start gap-2 text-gray-800">
|
||||
<DocumentDuplicateIcon className="h-4 w-4" />
|
||||
<span>Copy Link</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => setModuleDeleteModal(true)}>
|
||||
<span className="flex items-center justify-start gap-2 text-gray-800">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:basis-1/2">
|
||||
<div className="grid flex-shrink-0 place-items-center">
|
||||
|
||||
<span className="whitespace-normal text-sm leading-5 text-black">
|
||||
{module.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
<Controller
|
||||
control={control}
|
||||
name="lead"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarLeadSelect
|
||||
value={value}
|
||||
onChange={(val: string) => {
|
||||
submitChanges({ lead: value });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="members_list"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarMembersSelect
|
||||
value={value}
|
||||
onChange={(val: string[]) => {
|
||||
submitChanges({ members_list: val });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<div className="flex w-40 items-center justify-start gap-2">
|
||||
<ChartPieIcon className="h-5 w-5 text-gray-400" />
|
||||
<span>Progress</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 text-gray-800">
|
||||
<span className="h-4 w-4">
|
||||
<ProgressBar
|
||||
value={groupedIssues.completed.length}
|
||||
maxValue={moduleIssues?.length}
|
||||
/>
|
||||
</span>
|
||||
{groupedIssues.completed.length}/{moduleIssues?.length}
|
||||
</div>
|
||||
{groupedIssues.completed.length}/{moduleIssues?.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4>Links</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-100"
|
||||
onClick={() => setModuleLinkModal(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{module.link_module && module.link_module.length > 0 ? (
|
||||
<LinksList
|
||||
links={module.link_module}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center w-full gap-2">
|
||||
{isStartValid && isEndValid ? (
|
||||
<ProgressChart
|
||||
issues={issues}
|
||||
start={module?.start_date ?? ""}
|
||||
end={module?.target_date ?? ""}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{issues.length > 0 ? (
|
||||
<SidebarProgressStats issues={issues} groupedIssues={groupedIssues} />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-gray-300 px-7 py-6 ">
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<div
|
||||
className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2 ">
|
||||
<div className="flex items-center justify-start gap-2 text-sm">
|
||||
<span className="font-medium text-gray-500">Progress</span>
|
||||
{!open && moduleIssues && progressPercentage ? (
|
||||
<span className="rounded bg-[#09A953]/10 px-1.5 py-0.5 text-xs text-[#09A953]">
|
||||
{progressPercentage ? `${progressPercentage}%` : ""}
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Disclosure.Button>
|
||||
<ChevronDownIcon
|
||||
className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
<Transition show={open}>
|
||||
<Disclosure.Panel>
|
||||
{isStartValid && isEndValid && moduleIssues ? (
|
||||
<div className=" h-full w-full py-4">
|
||||
<div className="flex items-start justify-between gap-4 py-2 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>
|
||||
<DocumentIcon className="h-3 w-3 text-gray-500" />
|
||||
</span>
|
||||
<span>
|
||||
Pending Issues -{" "}
|
||||
{moduleIssues?.length - groupedIssues.completed.length}{" "}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-gray-900">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
|
||||
<span>Ideal</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
|
||||
<span>Current</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-40 w-80">
|
||||
<ProgressChart
|
||||
issues={issues}
|
||||
start={module?.start_date ?? ""}
|
||||
end={module?.target_date ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-gray-300 px-7 py-6 ">
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<div
|
||||
className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2 ">
|
||||
<div className="flex items-center justify-start gap-2 text-sm">
|
||||
<span className="font-medium text-gray-500">Other Information</span>
|
||||
</div>
|
||||
|
||||
<Disclosure.Button>
|
||||
<ChevronDownIcon
|
||||
className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
<Transition show={open}>
|
||||
<Disclosure.Panel>
|
||||
{issues.length > 0 ? (
|
||||
<>
|
||||
<div className=" h-full w-full py-4">
|
||||
<SidebarProgressStats
|
||||
issues={issues}
|
||||
groupedIssues={groupedIssues}
|
||||
setModuleLinkModal={setModuleLinkModal}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
userAuth={userAuth}
|
||||
module={module}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user