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

This commit is contained in:
NarayanBavisetti 2023-09-14 17:59:48 +05:30
commit 2e05a2199e
1190 changed files with 26875 additions and 10720 deletions

View File

@ -1,36 +1,3 @@
# Frontend
# Extra image domains that need to be added for Next Image
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID=""
# Github ID for Github OAuth
NEXT_PUBLIC_GITHUB_ID=""
# Github App Name for GitHub Integration
NEXT_PUBLIC_GITHUB_APP_NAME=""
# Sentry DSN for error monitoring
NEXT_PUBLIC_SENTRY_DSN=""
# Enable/Disable OAUTH - default 0 for selfhosted instance
NEXT_PUBLIC_ENABLE_OAUTH=0
# Enable/Disable sentry
NEXT_PUBLIC_ENABLE_SENTRY=0
# Enable/Disable session recording
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
# Enable/Disable event tracking
NEXT_PUBLIC_TRACK_EVENTS=0
# Slack for Slack Integration
NEXT_PUBLIC_SLACK_CLIENT_ID=""
# For Telemetry, set it to "app.plane.so"
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
# public boards deploy url
NEXT_PUBLIC_DEPLOY_URL=""
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
# Error logs
SENTRY_DSN=""
# Database Settings
PGUSER="plane"
PGPASSWORD="plane"
@ -43,15 +10,6 @@ REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# Email Settings
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS="1"
EMAIL_USE_SSL="0"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
@ -67,9 +25,6 @@ OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint
OPENAI_API_KEY="sk-" # add your openai key here
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access
# Github
GITHUB_CLIENT_SECRET="" # For fetching release notes
# Settings related to Docker
DOCKERIZED=1
# set to 1 If using the pre-configured minio setup
@ -78,10 +33,3 @@ USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# Default Creds
DEFAULT_EMAIL="captain@plane.so"
DEFAULT_PASSWORD="password123"
# SignUps
ENABLE_SIGNUP="1"
# Auto generated and Required that will be generated from setup.sh

View File

@ -4,7 +4,7 @@ module.exports = {
extends: ["custom"],
settings: {
next: {
rootDir: ["apps/*"],
rootDir: ["web/", "space/"],
},
},
};

View File

@ -29,9 +29,9 @@ jobs:
apiserver:
- apiserver/**
web:
- apps/app/**
- web/**
deploy:
- apps/space/**
- space/**
- name: Setup .npmrc for repository
run: |
@ -40,15 +40,15 @@ jobs:
- name: Build Plane's Main App
if: steps.changed-files.outputs.web_any_changed == 'true'
run: |
mv ./.npmrc ./apps/app
cd apps/app
mv ./.npmrc ./web
cd web
yarn
yarn build
- name: Build Plane's Deploy App
if: steps.changed-files.outputs.deploy_any_changed == 'true'
run: |
cd apps/space
cd space
yarn
yarn build

View File

@ -2,7 +2,7 @@ name: Update Docker Images for Plane on Release
on:
release:
types: [released]
types: [released, prereleased]
jobs:
build_push_backend:
@ -62,7 +62,7 @@ jobs:
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./apps/app/Dockerfile.web
file: ./web/Dockerfile.web
platforms: linux/amd64
tags: ${{ steps.metaFrontend.outputs.tags }}
push: true
@ -88,7 +88,7 @@ jobs:
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./apps/space/Dockerfile.space
file: ./space/Dockerfile.space
platforms: linux/amd64
push: true
tags: ${{ steps.metaDeploy.outputs.tags }}

View File

@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities

View File

@ -35,12 +35,10 @@
Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘‍♀️.
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting).
## ⚡️ Quick start with Docker Compose
### Docker Compose Setup
@ -71,6 +69,7 @@ chmod +x setup.sh
@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=YOUR_REGISTRY_TOKEN
```
- Run Docker compose up
```bash
@ -81,15 +80,15 @@ docker compose up -d
## 🚀 Features
* **Issue Planning and Tracking**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to issues for better organization and tracking.
* **Issue Attachments**: Collaborate effectively by attaching files to issues, making it easy for your team to find and share important project-related documents.
* **Layouts**: Customize your project view with your preferred layout - choose from List, Kanban, or Calendar to visualize your project in a way that makes sense to you.
* **Cycles**: Plan sprints with Cycles to keep your team on track and productive. Gain insights into your project's progress with burn-down charts and other useful features.
* **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to easily track and plan your project's progress.
* **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks.
* **Pages**: Plane pages function as an AI-powered notepad, allowing you to easily document issues, cycle plans, and module details, and then synchronize them with your issues.
* **Command K**: Enjoy a better user experience with the new Command + K menu. Easily manage and navigate through your projects from one convenient location.
* **GitHub Sync**: Streamline your planning process by syncing your GitHub issues with Plane. Keep all your issues in one place for better tracking and collaboration.
- **Issue Planning and Tracking**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to issues for better organization and tracking.
- **Issue Attachments**: Collaborate effectively by attaching files to issues, making it easy for your team to find and share important project-related documents.
- **Layouts**: Customize your project view with your preferred layout - choose from List, Kanban, or Calendar to visualize your project in a way that makes sense to you.
- **Cycles**: Plan sprints with Cycles to keep your team on track and productive. Gain insights into your project's progress with burn-down charts and other useful features.
- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to easily track and plan your project's progress.
- **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks.
- **Pages**: Plane pages function as an AI-powered notepad, allowing you to easily document issues, cycle plans, and module details, and then synchronize them with your issues.
- **Command K**: Enjoy a better user experience with the new Command + K menu. Easily manage and navigate through your projects from one convenient location.
- **GitHub Sync**: Streamline your planning process by syncing your GitHub issues with Plane. Keep all your issues in one place for better tracking and collaboration.
## 📸 Screenshots
@ -150,7 +149,6 @@ docker compose up -d
</p>
</p>
## 📚Documentation
For full documentation, visit [docs.plane.so](https://docs.plane.so/)

60
apiserver/.env.example Normal file
View File

@ -0,0 +1,60 @@
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
# Error logs
SENTRY_DSN=""
# Database Settings
PGUSER="plane"
PGPASSWORD="plane"
PGHOST="plane-db"
PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# Email Settings
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS="1"
EMAIL_USE_SSL="0"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key"
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
# Changing this requires change in the nginx.conf for uploads if using minio setup
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint
OPENAI_API_KEY="sk-" # add your openai key here
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access
# Github
GITHUB_CLIENT_SECRET="" # For fetching release notes
# Settings related to Docker
DOCKERIZED=1
# set to 1 If using the pre-configured minio setup
USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# Default Creds
DEFAULT_EMAIL="captain@plane.so"
DEFAULT_PASSWORD="password123"
# SignUps
ENABLE_SIGNUP="1"

View File

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

View File

@ -20,6 +20,7 @@ from .project import (
ProjectMemberLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
ProjectPublicMemberSerializer
)
from .state import StateSerializer, StateLiteSerializer
from .view import WorkspaceViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer
@ -30,8 +31,6 @@ from .issue import (
IssueActivitySerializer,
IssueCommentSerializer,
IssuePropertySerializer,
BlockerIssueSerializer,
BlockedIssueSerializer,
IssueAssigneeSerializer,
LabelSerializer,
IssueSerializer,
@ -44,6 +43,9 @@ from .issue import (
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer,
)
from .module import (

View File

@ -17,12 +17,10 @@ from plane.db.models import (
IssueActivity,
IssueComment,
IssueProperty,
IssueBlocker,
IssueAssignee,
IssueSubscriber,
IssueLabel,
Label,
IssueBlocker,
CycleIssue,
Cycle,
Module,
@ -32,6 +30,7 @@ from plane.db.models import (
IssueReaction,
CommentReaction,
IssueVote,
IssueRelation,
)
@ -50,6 +49,7 @@ class IssueFlatSerializer(BaseSerializer):
"target_date",
"sequence_id",
"sort_order",
"is_draft",
]
@ -81,25 +81,12 @@ class IssueCreateSerializer(BaseSerializer):
required=False,
)
# List of issues that are blocking this issue
blockers_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()),
write_only=True,
required=False,
)
labels_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
)
# List of issues that are blocked by this issue
blocks_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()),
write_only=True,
required=False,
)
class Meta:
model = Issue
fields = "__all__"
@ -113,15 +100,17 @@ class IssueCreateSerializer(BaseSerializer):
]
def validate(self, data):
if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None):
if (
data.get("start_date", None) is not None
and data.get("target_date", None) is not None
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
return data
def create(self, validated_data):
blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
project_id = self.context["project_id"]
workspace_id = self.context["workspace_id"]
@ -133,22 +122,6 @@ class IssueCreateSerializer(BaseSerializer):
created_by_id = issue.created_by_id
updated_by_id = issue.updated_by_id
if blockers is not None and len(blockers):
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
block=issue,
blocked_by=blocker,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for blocker in blockers
],
batch_size=10,
)
if assignees is not None and len(assignees):
IssueAssignee.objects.bulk_create(
[
@ -192,29 +165,11 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10,
)
if blocks is not None and len(blocks):
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
block=block,
blocked_by=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for block in blocks
],
batch_size=10,
)
return issue
def update(self, instance, validated_data):
blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
# Related models
project_id = instance.project_id
@ -222,23 +177,6 @@ class IssueCreateSerializer(BaseSerializer):
created_by_id = instance.created_by_id
updated_by_id = instance.updated_by_id
if blockers is not None:
IssueBlocker.objects.filter(block=instance).delete()
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
block=instance,
blocked_by=blocker,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for blocker in blockers
],
batch_size=10,
)
if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete()
IssueAssignee.objects.bulk_create(
@ -273,23 +211,6 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10,
)
if blocks is not None:
IssueBlocker.objects.filter(blocked_by=instance).delete()
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
block=block,
blocked_by=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for block in blocks
],
batch_size=10,
)
# Time updation occues even when other related models are updated
instance.updated_at = timezone.now()
return super().update(instance, validated_data)
@ -371,32 +292,39 @@ class IssueLabelSerializer(BaseSerializer):
]
class BlockedIssueSerializer(BaseSerializer):
blocked_issue_detail = IssueProjectLiteSerializer(source="block", read_only=True)
class IssueRelationSerializer(BaseSerializer):
related_issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
class Meta:
model = IssueBlocker
model = IssueRelation
fields = [
"blocked_issue_detail",
"blocked_by",
"block",
"related_issue_detail",
"relation_type",
"related_issue",
"issue",
"id"
]
read_only_fields = [
"workspace",
"project",
]
read_only_fields = fields
class BlockerIssueSerializer(BaseSerializer):
blocker_issue_detail = IssueProjectLiteSerializer(
source="blocked_by", read_only=True
)
class RelatedIssueSerializer(BaseSerializer):
issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue")
class Meta:
model = IssueBlocker
model = IssueRelation
fields = [
"blocker_issue_detail",
"blocked_by",
"block",
"issue_detail",
"relation_type",
"related_issue",
"issue",
"id"
]
read_only_fields = [
"workspace",
"project",
]
read_only_fields = fields
class IssueAssigneeSerializer(BaseSerializer):
@ -510,6 +438,9 @@ class IssueAttachmentSerializer(BaseSerializer):
class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueReaction
fields = "__all__"
@ -521,19 +452,6 @@ class IssueReactionSerializer(BaseSerializer):
]
class IssueReactionLiteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueReaction
fields = [
"id",
"reaction",
"issue",
"actor_detail",
]
class CommentReactionLiteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
@ -554,12 +472,13 @@ class CommentReactionSerializer(BaseSerializer):
read_only_fields = ["workspace", "project", "comment", "actor"]
class IssueVoteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueVote
fields = ["issue", "vote", "workspace_id", "project_id", "actor"]
fields = ["issue", "vote", "workspace", "project", "actor", "actor_detail"]
read_only_fields = fields
@ -569,7 +488,7 @@ class IssueCommentSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True)
is_member = serializers.BooleanField(read_only=True)
class Meta:
model = IssueComment
@ -582,7 +501,6 @@ class IssueCommentSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"access",
]
@ -623,16 +541,14 @@ class IssueSerializer(BaseSerializer):
parent_detail = IssueStateFlatSerializer(read_only=True, source="parent")
label_details = LabelSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
# List of issues blocked by this issue
blocked_issues = BlockedIssueSerializer(read_only=True, many=True)
# List of issues that block this issue
blocker_issues = BlockerIssueSerializer(read_only=True, many=True)
related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True)
issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True)
issue_cycle = IssueCycleDetailSerializer(read_only=True)
issue_module = IssueModuleDetailSerializer(read_only=True)
issue_link = IssueLinkSerializer(read_only=True, many=True)
issue_attachment = IssueAttachmentSerializer(read_only=True, many=True)
sub_issues_count = serializers.IntegerField(read_only=True)
issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True)
issue_reactions = IssueReactionSerializer(read_only=True, many=True)
class Meta:
model = Issue
@ -658,7 +574,7 @@ class IssueLiteSerializer(BaseSerializer):
module_id = serializers.UUIDField(read_only=True)
attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True)
issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True)
issue_reactions = IssueReactionSerializer(read_only=True, many=True)
class Meta:
model = Issue
@ -676,6 +592,33 @@ class IssueLiteSerializer(BaseSerializer):
]
class IssuePublicSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateLiteSerializer(read_only=True, source="state")
reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions")
votes = IssueVoteSerializer(read_only=True, many=True)
class Meta:
model = Issue
fields = [
"id",
"name",
"description_html",
"sequence_id",
"state",
"state_detail",
"project",
"project_detail",
"workspace",
"priority",
"target_date",
"reactions",
"votes",
]
read_only_fields = fields
class IssueSubscriberSerializer(BaseSerializer):
class Meta:
model = IssueSubscriber

View File

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

View File

@ -51,6 +51,7 @@ from plane.api.views import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
LeaveWorkspaceEndpoint,
## End Workspaces
# File Assets
FileAssetEndpoint,
@ -68,6 +69,7 @@ from plane.api.views import (
UserProjectInvitationsViewset,
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
LeaveProjectEndpoint,
## End Projects
# Issues
IssueViewSet,
@ -88,7 +90,9 @@ from plane.api.views import (
IssueSubscriberViewSet,
IssueCommentPublicViewSet,
IssueReactionViewSet,
IssueRelationViewSet,
CommentReactionViewSet,
IssueDraftViewSet,
## End Issues
# States
StateViewSet,
@ -166,16 +170,18 @@ from plane.api.views import (
# Notification
NotificationViewSet,
UnreadNotificationEndpoint,
MarkAllReadNotificationViewSet,
## End Notification
# Public Boards
ProjectDeployBoardViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectIssuesPublicEndpoint,
ProjectDeployBoardPublicSettingsEndpoint,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
InboxIssuePublicViewSet,
IssueVotePublicViewSet,
WorkspaceProjectDeployBoardEndpoint,
IssueRetrievePublicEndpoint,
## End Public Boards
## Exporter
ExportIssuesEndpoint,
@ -237,7 +243,7 @@ urlpatterns = [
UpdateUserTourCompletedEndpoint.as_view(),
name="user-tour",
),
path("users/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
path("users/workspaces/<str:slug>/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
# user workspaces
path(
"users/me/workspaces/",
@ -441,6 +447,11 @@ urlpatterns = [
WorkspaceLabelsEndpoint.as_view(),
name="workspace-labels",
),
path(
"workspaces/<str:slug>/members/leave/",
LeaveWorkspaceEndpoint.as_view(),
name="workspace-labels",
),
## End Workspaces ##
# Projects
path(
@ -554,6 +565,11 @@ urlpatterns = [
),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/leave/",
LeaveProjectEndpoint.as_view(),
name="project",
),
# End Projects
# States
path(
@ -1025,6 +1041,49 @@ urlpatterns = [
name="project-issue-archive",
),
## End Issue Archives
## Issue Relation
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-relation/",
IssueRelationViewSet.as_view(
{
"post": "create",
}
),
name="issue-relation",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-relation/<uuid:pk>/",
IssueRelationViewSet.as_view(
{
"delete": "destroy",
}
),
name="issue-relation",
),
## End Issue Relation
## Issue Drafts
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-drafts/",
IssueDraftViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-draft",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-drafts/<uuid:pk>/",
IssueDraftViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-issue-draft",
),
## End Issue Drafts
## File Assets
path(
"workspaces/<str:slug>/file-assets/",
@ -1523,6 +1582,15 @@ urlpatterns = [
UnreadNotificationEndpoint.as_view(),
name="unread-notifications",
),
path(
"workspaces/<str:slug>/users/notifications/mark-all-read/",
MarkAllReadNotificationViewSet.as_view(
{
"post": "create",
}
),
name="mark-all-read-notifications",
),
## End Notification
# Public Boards
path(
@ -1553,9 +1621,14 @@ urlpatterns = [
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/",
ProjectDeployBoardIssuesPublicEndpoint.as_view(),
ProjectIssuesPublicEndpoint.as_view(),
name="project-deploy-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/",
IssueRetrievePublicEndpoint.as_view(),
name="workspace-project-boards",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
IssueCommentPublicViewSet.as_view(

View File

@ -12,11 +12,11 @@ from .project import (
ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint,
ProjectFavoritesViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectDeployBoardViewSet,
ProjectDeployBoardPublicSettingsEndpoint,
ProjectMemberEndpoint,
WorkspaceProjectDeployBoardEndpoint,
LeaveProjectEndpoint,
)
from .user import (
UserEndpoint,
@ -53,6 +53,7 @@ from .workspace import (
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceMembersEndpoint,
LeaveWorkspaceEndpoint,
)
from .state import StateViewSet
from .view import WorkspaceViewViewSet, WorkspaceViewIssuesEndpoint, IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
@ -85,6 +86,10 @@ from .issue import (
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
IssueRelationViewSet,
IssueRetrievePublicEndpoint,
ProjectIssuesPublicEndpoint,
IssueDraftViewSet,
)
from .auth_extended import (
@ -162,8 +167,6 @@ from .analytic import (
DefaultAnalyticsEndpoint,
)
from .notification import NotificationViewSet, UnreadNotificationEndpoint
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
from .exporter import (
ExportIssuesEndpoint,
)
from .exporter import ExportIssuesEndpoint

View File

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

View File

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

View File

@ -191,11 +191,10 @@ class CycleViewSet(BaseViewSet):
workspace__slug=slug,
project_id=project_id,
)
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar")
.values("display_name", "assignee_id", "avatar")
.annotate(total_issues=Count("assignee_id"))
.annotate(
completed_issues=Count(
@ -209,7 +208,7 @@ class CycleViewSet(BaseViewSet):
filter=Q(completed_at__isnull=True),
)
)
.order_by("first_name", "last_name")
.order_by("display_name")
)
label_distribution = (
@ -334,7 +333,15 @@ class CycleViewSet(BaseViewSet):
workspace__slug=slug, project_id=project_id, pk=pk
)
request_data = request.data
if cycle.end_date is not None and cycle.end_date < timezone.now().date():
if "sort_order" in request_data:
# Can only change sort order
request_data = {
"sort_order": request_data.get("sort_order", cycle.sort_order)
}
else:
return Response(
{
"error": "The Cycle has already been completed so it cannot be edited"
@ -374,7 +381,9 @@ class CycleViewSet(BaseViewSet):
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(display_name=F("assignees__display_name"))
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.values(
"first_name", "last_name", "assignee_id", "avatar", "display_name"
)
.annotate(total_issues=Count("assignee_id"))
.annotate(
completed_issues=Count(
@ -508,6 +517,7 @@ class CycleIssueViewSet(BaseViewSet):
try:
order_by = request.GET.get("order_by", "created_at")
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
@ -546,9 +556,15 @@ class CycleIssueViewSet(BaseViewSet):
issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues_data, group_by),
group_results(issues_data, group_by, sub_group_by),
status=status.HTTP_200_OK,
)
@ -710,7 +726,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
class CycleFavoriteViewSet(BaseViewSet):
serializer_class = CycleFavoriteSerializer
model = CycleFavorite

View File

@ -41,9 +41,9 @@ class GPTIntegrationEndpoint(BaseAPIView):
final_text = task + "\n" + prompt
openai.api_key = settings.OPENAI_API_KEY
response = openai.Completion.create(
response = openai.ChatCompletion.create(
model=settings.GPT_ENGINE,
prompt=final_text,
messages=[{"role": "user", "content": final_text}],
temperature=0.7,
max_tokens=1024,
)
@ -51,7 +51,7 @@ class GPTIntegrationEndpoint(BaseAPIView):
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=project_id)
text = response.choices[0].text.strip()
text = response.choices[0].message.content.strip()
text_html = text.replace("\n", "<br/>")
return Response(
{

File diff suppressed because it is too large Load Diff

View File

@ -308,6 +308,7 @@ class ModuleIssueViewSet(BaseViewSet):
try:
order_by = request.GET.get("order_by", "created_at")
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_module__module_id=module_id)
@ -346,9 +347,15 @@ class ModuleIssueViewSet(BaseViewSet):
issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues_data, group_by),
group_results(issues_data, group_by, sub_group_by),
status=status.HTTP_200_OK,
)

View File

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

View File

@ -11,14 +11,8 @@ from django.db.models import (
OuterRef,
Func,
F,
Max,
CharField,
Func,
Subquery,
Prefetch,
When,
Case,
Value,
)
from django.core.validators import validate_email
from django.conf import settings
@ -47,6 +41,7 @@ from plane.api.permissions import (
ProjectBasePermission,
ProjectEntityPermission,
ProjectMemberPermission,
ProjectLitePermission,
)
from plane.db.models import (
@ -71,16 +66,9 @@ from plane.db.models import (
ModuleMember,
Inbox,
ProjectDeployBoard,
Issue,
IssueReaction,
IssueLink,
IssueAttachment,
Label,
)
from plane.bgtasks.project_invitation_task import project_invitation
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
class ProjectViewSet(BaseViewSet):
@ -494,7 +482,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
# Delete joined project invites
project_invitations.delete()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
@ -629,7 +617,7 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
except ProjectMember.DoesNotExist:
return Response(
{"error": "Project Member does not exist"}, status=status.HTTP_400
{"error": "Project Member does not exist"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
capture_exception(e)
@ -936,8 +924,7 @@ class ProjectUserViewsEndpoint(BaseAPIView):
project_member.save()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except Project.DoesNotExist:
return Response(
{"error": "The requested resource does not exists"},
@ -1143,158 +1130,11 @@ class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
)
class ProjectDeployBoardIssuesPublicEndpoint(BaseAPIView):
class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
states = State.objects.filter(
workspace__slug=slug, project_id=project_id
).values("name", "group", "color", "id")
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
issues = group_results(issues, group_by)
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
permission_classes = [AllowAny,]
def get(self, request, slug):
try:
projects = (
@ -1324,3 +1164,48 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class LeaveProjectEndpoint(BaseAPIView):
permission_classes = [
ProjectLitePermission,
]
def delete(self, request, slug, project_id):
try:
project_member = ProjectMember.objects.get(
workspace__slug=slug,
member=request.user,
project_id=project_id,
)
# Only Admin case
if (
project_member.role == 20
and ProjectMember.objects.filter(
workspace__slug=slug,
role=20,
project_id=project_id,
).count()
== 1
):
return Response(
{
"error": "You cannot leave the project since you are the only admin of the project you should delete the project"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Delete the member from workspace
project_member.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except ProjectMember.DoesNotExist:
return Response(
{"error": "Workspace member does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -220,7 +220,7 @@ class IssueSearchEndpoint(BaseAPIView):
query = request.query_params.get("search", False)
workspace_search = request.query_params.get("workspace_search", "false")
parent = request.query_params.get("parent", "false")
blocker_blocked_by = request.query_params.get("blocker_blocked_by", "false")
issue_relation = request.query_params.get("issue_relation", "false")
cycle = request.query_params.get("cycle", "false")
module = request.query_params.get("module", "false")
sub_issue = request.query_params.get("sub_issue", "false")
@ -247,12 +247,12 @@ class IssueSearchEndpoint(BaseAPIView):
"parent_id", flat=True
)
)
if blocker_blocked_by == "true" and issue_id:
if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id),
~Q(blocked_issues__block=issue),
~Q(blocker_issues__blocked_by=issue),
~Q(issue_related__issue=issue),
~Q(issue_relation__related_issue=issue),
)
if sub_issue == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)

View File

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

View File

@ -116,7 +116,7 @@ class WorkSpaceViewSet(BaseViewSet):
)
issue_count = (
Issue.objects.filter(workspace=OuterRef("id"))
Issue.issue_objects.filter(workspace=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@ -203,7 +203,7 @@ class UserWorkSpacesEndpoint(BaseAPIView):
)
issue_count = (
Issue.objects.filter(workspace=OuterRef("id"))
Issue.issue_objects.filter(workspace=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@ -532,7 +532,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
# Delete joined workspace invites
workspace_invitations.delete()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
@ -846,7 +846,7 @@ class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
workspace_member.view_props = request.data.get("view_props", {})
workspace_member.save()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except WorkspaceMember.DoesNotExist:
return Response(
{"error": "User not a member of workspace"},
@ -1072,10 +1072,10 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
.order_by("state_group")
)
priority_order = ["urgent", "high", "medium", "low", None]
priority_order = ["urgent", "high", "medium", "low", "none"]
priority_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
@ -1100,7 +1100,6 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
created_issues = (
Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
created_by_id=user_id,
)
@ -1198,6 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
projects = request.query_params.getlist("project", [])
queryset = IssueActivity.objects.filter(
~Q(field__in=["comment", "vote", "reaction"]),
workspace__slug=slug,
project__project_projectmember__member=request.user,
actor=user_id,
@ -1473,3 +1473,44 @@ class WorkspaceMembersEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class LeaveWorkspaceEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def delete(self, request, slug):
try:
workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user
)
# Only Admin case
if (
workspace_member.role == 20
and WorkspaceMember.objects.filter(
workspace__slug=slug, role=20
).count()
== 1
):
return Response(
{
"error": "You cannot leave the workspace since you are the only admin of the workspace you should delete the workspace"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Delete the member from workspace
workspace_member.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except WorkspaceMember.DoesNotExist:
return Response(
{"error": "Workspace member does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

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

View File

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

View File

@ -24,6 +24,9 @@ from plane.db.models import (
IssueSubscriber,
Notification,
IssueAssignee,
IssueReaction,
CommentReaction,
IssueComment,
)
from plane.api.serializers import IssueActivitySerializer
@ -390,130 +393,6 @@ def track_assignees(
)
# Track changes in blocking issues
def track_blocks(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blocks_list")) > len(
current_instance.get("blocked_issues")
):
for block in requested_data.get("blocks_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocked_issues")
if blocked.get("block") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"added blocking issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
if len(requested_data.get("blocks_list")) < len(
current_instance.get("blocked_issues")
):
for blocked in current_instance.get("blocked_issues"):
if blocked.get("block") not in requested_data.get("blocks_list"):
issue = Issue.objects.get(pk=blocked.get("block"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{issue.project.identifier}-{issue.sequence_id}",
new_value="",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"removed blocking issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
# Track changes in blocked_by issues
def track_blockings(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blockers_list")) > len(
current_instance.get("blocker_issues")
):
for block in requested_data.get("blockers_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocker_issues")
if blocked.get("blocked_by") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"added blocked by issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
if len(requested_data.get("blockers_list")) < len(
current_instance.get("blocker_issues")
):
for blocked in current_instance.get("blocker_issues"):
if blocked.get("blocked_by") not in requested_data.get("blockers_list"):
issue = Issue.objects.get(pk=blocked.get("blocked_by"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{issue.project.identifier}-{issue.sequence_id}",
new_value="",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"removed blocked by issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
def create_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
@ -629,23 +508,16 @@ def update_issue_activity(
"parent": track_parent,
"priority": track_priority,
"state": track_state,
"description": track_description,
"description_html": track_description,
"target_date": track_target_date,
"start_date": track_start_date,
"labels_list": track_labels,
"assignees_list": track_assignees,
"blocks_list": track_blocks,
"blockers_list": track_blockings,
"estimate_point": track_estimate_points,
"archived_at": track_archive_at,
"closed_to": track_closed_to,
}
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
if func is not None:
@ -1022,6 +894,264 @@ def delete_attachment_activity(
)
)
def create_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None:
issue_reaction = IssueReaction.objects.filter(reaction=requested_data.get("reaction"), project=project, actor=actor).values_list('id', flat=True).first()
if issue_reaction is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="created",
old_value=None,
new_value=requested_data.get("reaction"),
field="reaction",
project=project,
workspace=project.workspace,
comment="added the reaction",
old_identifier=None,
new_identifier=issue_reaction,
)
)
def delete_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance and current_instance.get("reaction") is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="deleted",
old_value=current_instance.get("reaction"),
new_value=None,
field="reaction",
project=project,
workspace=project.workspace,
comment="removed the reaction",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
)
)
def create_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None:
comment_reaction_id, comment_id = CommentReaction.objects.filter(reaction=requested_data.get("reaction"), project=project, actor=actor).values_list('id', 'comment__id').first()
comment = IssueComment.objects.get(pk=comment_id,project=project)
if comment is not None and comment_reaction_id is not None and comment_id is not None:
issue_activities.append(
IssueActivity(
issue_id=comment.issue_id,
actor=actor,
verb="created",
old_value=None,
new_value=requested_data.get("reaction"),
field="reaction",
project=project,
workspace=project.workspace,
comment="added the reaction",
old_identifier=None,
new_identifier=comment_reaction_id,
)
)
def delete_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance and current_instance.get("reaction") is not None:
issue_id = IssueComment.objects.filter(pk=current_instance.get("comment_id"), project=project).values_list('issue_id', flat=True).first()
if issue_id is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="deleted",
old_value=current_instance.get("reaction"),
new_value=None,
field="reaction",
project=project,
workspace=project.workspace,
comment="removed the reaction",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
)
)
def create_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("vote") is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="created",
old_value=None,
new_value=requested_data.get("vote"),
field="vote",
project=project,
workspace=project.workspace,
comment="added the vote",
old_identifier=None,
new_identifier=None,
)
)
def delete_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance and current_instance.get("vote") is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="deleted",
old_value=current_instance.get("vote"),
new_value=None,
field="vote",
project=project,
workspace=project.workspace,
comment="removed the vote",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
)
)
def create_issue_relation_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance is None and requested_data.get("related_list") is not None:
for issue_relation in requested_data.get("related_list"):
issue = Issue.objects.get(pk=issue_relation.get("related_issue"))
issue_activities.append(
IssueActivity(
issue_id=issue_relation.get("issue"),
actor=actor,
verb="created",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field=f'{issue_relation.get("relation_type")}',
project=project,
workspace=project.workspace,
comment=f'added {issue_relation.get("relation_type")} relation',
old_identifier=issue_relation.get("issue"),
)
)
def delete_issue_relation_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance is not None and requested_data.get("related_list") is None:
issue = Issue.objects.get(pk=current_instance.get("issue"))
issue_activities.append(
IssueActivity(
issue_id=current_instance.get("issue"),
actor=actor,
verb="deleted",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field=f'{current_instance.get("relation_type")}',
project=project,
workspace=project.workspace,
comment=f'deleted the {current_instance.get("relation_type")} relation',
old_identifier=current_instance.get("issue"),
)
)
def create_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"drafted the issue",
field="draft",
verb="created",
actor=actor,
)
)
def update_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if requested_data.get("is_draft") is not None and requested_data.get("is_draft") == False:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"created the issue",
verb="updated",
actor=actor,
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"updated the draft issue",
field="draft",
verb="updated",
actor=actor,
)
)
def delete_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
issue_activities.append(
IssueActivity(
project=project,
workspace=project.workspace,
comment=f"deleted the draft issue",
field="draft",
verb="deleted",
actor=actor,
)
)
# Receive message from room group
@shared_task
@ -1045,6 +1175,12 @@ def issue_activity(
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
]:
issue = Issue.objects.filter(pk=issue_id).first()
@ -1080,6 +1216,17 @@ def issue_activity(
"link.activity.deleted": delete_link_activity,
"attachment.activity.created": create_attachment_activity,
"attachment.activity.deleted": delete_attachment_activity,
"issue_relation.activity.created": create_issue_relation_activity,
"issue_relation.activity.deleted": delete_issue_relation_activity,
"issue_reaction.activity.created": create_issue_reaction_activity,
"issue_reaction.activity.deleted": delete_issue_reaction_activity,
"comment_reaction.activity.created": create_comment_reaction_activity,
"comment_reaction.activity.deleted": delete_comment_reaction_activity,
"issue_vote.activity.created": create_issue_vote_activity,
"issue_vote.activity.deleted": delete_issue_vote_activity,
"issue_draft.activity.created": create_draft_issue_activity,
"issue_draft.activity.updated": update_draft_issue_activity,
"issue_draft.activity.deleted": delete_draft_issue_activity,
}
func = ACTIVITY_MAPPER.get(type)
@ -1119,6 +1266,12 @@ def issue_activity(
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
]:
# Create Notifications
bulk_notifications = []

View File

@ -32,7 +32,7 @@ def archive_old_issues():
archive_in = project.archive_in
# Get all the issues whose updated_at in less that the archive_in month
issues = Issue.objects.filter(
issues = Issue.issue_objects.filter(
Q(
project=project_id,
archived_at__isnull=True,
@ -64,7 +64,8 @@ def archive_old_issues():
issues_to_update.append(issue)
# Bulk Update the issues and log the activity
Issue.objects.bulk_update(
if issues_to_update:
updated_issues = Issue.objects.bulk_update(
issues_to_update, ["archived_at"], batch_size=100
)
[
@ -77,7 +78,7 @@ def archive_old_issues():
current_instance=None,
subscriber=False,
)
for issue in issues_to_update
for issue in updated_issues
]
return
except Exception as e:
@ -99,7 +100,7 @@ def close_old_issues():
close_in = project.close_in
# Get all the issues whose updated_at in less that the close_in month
issues = Issue.objects.filter(
issues = Issue.issue_objects.filter(
Q(
project=project_id,
archived_at__isnull=True,
@ -136,7 +137,8 @@ def close_old_issues():
issues_to_update.append(issue)
# Bulk Update the issues and log the activity
Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
if issues_to_update:
updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
[
issue_activity.delay(
type="issue.activity.updated",
@ -147,7 +149,7 @@ def close_old_issues():
current_instance=None,
subscriber=False,
)
for issue in issues_to_update
for issue in updated_issues
]
return
except Exception as e:

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,84 @@
# Generated by Django 4.2.3 on 2023-09-12 07:29
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
from plane.db.models import IssueRelation
from sentry_sdk import capture_exception
import uuid
def create_issue_relation(apps, schema_editor):
try:
IssueBlockerModel = apps.get_model("db", "IssueBlocker")
updated_issue_relation = []
for blocked_issue in IssueBlockerModel.objects.all():
updated_issue_relation.append(
IssueRelation(
issue_id=blocked_issue.block_id,
related_issue_id=blocked_issue.blocked_by_id,
relation_type="blocked_by",
project_id=blocked_issue.project_id,
workspace_id=blocked_issue.workspace_id,
created_by_id=blocked_issue.created_by_id,
updated_by_id=blocked_issue.updated_by_id,
)
)
IssueRelation.objects.bulk_create(updated_issue_relation, batch_size=100)
except Exception as e:
print(e)
capture_exception(e)
def update_issue_priority_choice(apps, schema_editor):
IssueModel = apps.get_model("db", "Issue")
updated_issues = []
for obj in IssueModel.objects.all():
if obj.priority is None:
obj.priority = "none"
updated_issues.append(obj)
IssueModel.objects.bulk_update(updated_issues, ["priority"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('db', '0042_alter_analyticview_created_by_and_more'),
]
operations = [
migrations.CreateModel(
name='IssueRelation',
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)),
('relation_type', models.CharField(choices=[('duplicate', 'Duplicate'), ('relates_to', 'Relates To'), ('blocked_by', 'Blocked By')], default='blocked_by', max_length=20, verbose_name='Issue Relation Type')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_relation', to='db.issue')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('related_issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_related', to='db.issue')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Issue Relation',
'verbose_name_plural': 'Issue Relations',
'db_table': 'issue_relations',
'ordering': ('-created_at',),
'unique_together': {('issue', 'related_issue')},
},
),
migrations.AddField(
model_name='issue',
name='is_draft',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='issue',
name='priority',
field=models.CharField(choices=[('urgent', 'Urgent'), ('high', 'High'), ('medium', 'Medium'), ('low', 'Low'), ('none', 'None')], default='none', max_length=30, verbose_name='Issue Priority'),
),
migrations.RunPython(create_issue_relation),
migrations.RunPython(update_issue_priority_choice),
]

View File

@ -0,0 +1,138 @@
# Generated by Django 4.2.3 on 2023-09-13 07:09
from django.db import migrations
def workspace_member_props(old_props):
new_props = {
"filters": {
"priority": old_props.get("filters", {}).get("priority", None),
"state": old_props.get("filters", {}).get("state", None),
"state_group": old_props.get("filters", {}).get("state_group", None),
"assignees": old_props.get("filters", {}).get("assignees", None),
"created_by": old_props.get("filters", {}).get("created_by", None),
"labels": old_props.get("filters", {}).get("labels", None),
"start_date": old_props.get("filters", {}).get("start_date", None),
"target_date": old_props.get("filters", {}).get("target_date", None),
"subscriber": old_props.get("filters", {}).get("subscriber", None),
},
"display_filters": {
"group_by": old_props.get("groupByProperty", None),
"order_by": old_props.get("orderBy", "-created_at"),
"type": old_props.get("filters", {}).get("type", None),
"sub_issue": old_props.get("showSubIssues", True),
"show_empty_groups": old_props.get("showEmptyGroups", True),
"layout": old_props.get("issueView", "list"),
"calendar_date_range": old_props.get("calendarDateRange", ""),
},
"display_properties": {
"assignee": old_props.get("properties", {}).get("assignee",None),
"attachment_count": old_props.get("properties", {}).get("attachment_count", None),
"created_on": old_props.get("properties", {}).get("created_on", None),
"due_date": old_props.get("properties", {}).get("due_date", None),
"estimate": old_props.get("properties", {}).get("estimate", None),
"key": old_props.get("properties", {}).get("key", None),
"labels": old_props.get("properties", {}).get("labels", None),
"link": old_props.get("properties", {}).get("link", None),
"priority": old_props.get("properties", {}).get("priority", None),
"start_date": old_props.get("properties", {}).get("start_date", None),
"state": old_props.get("properties", {}).get("state", None),
"sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", None),
"updated_on": old_props.get("properties", {}).get("updated_on", None),
},
}
return new_props
def project_member_props(old_props):
new_props = {
"filters": {
"priority": old_props.get("filters", {}).get("priority", None),
"state": old_props.get("filters", {}).get("state", None),
"state_group": old_props.get("filters", {}).get("state_group", None),
"assignees": old_props.get("filters", {}).get("assignees", None),
"created_by": old_props.get("filters", {}).get("created_by", None),
"labels": old_props.get("filters", {}).get("labels", None),
"start_date": old_props.get("filters", {}).get("start_date", None),
"target_date": old_props.get("filters", {}).get("target_date", None),
"subscriber": old_props.get("filters", {}).get("subscriber", None),
},
"display_filters": {
"group_by": old_props.get("groupByProperty", None),
"order_by": old_props.get("orderBy", "-created_at"),
"type": old_props.get("filters", {}).get("type", None),
"sub_issue": old_props.get("showSubIssues", True),
"show_empty_groups": old_props.get("showEmptyGroups", True),
"layout": old_props.get("issueView", "list"),
"calendar_date_range": old_props.get("calendarDateRange", ""),
},
}
return new_props
def cycle_module_props(old_props):
new_props = {
"filters": {
"priority": old_props.get("filters", {}).get("priority", None),
"state": old_props.get("filters", {}).get("state", None),
"state_group": old_props.get("filters", {}).get("state_group", None),
"assignees": old_props.get("filters", {}).get("assignees", None),
"created_by": old_props.get("filters", {}).get("created_by", None),
"labels": old_props.get("filters", {}).get("labels", None),
"start_date": old_props.get("filters", {}).get("start_date", None),
"target_date": old_props.get("filters", {}).get("target_date", None),
"subscriber": old_props.get("filters", {}).get("subscriber", None),
},
}
return new_props
def update_workspace_member_view_props(apps, schema_editor):
WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember")
updated_workspace_member = []
for obj in WorkspaceMemberModel.objects.all():
obj.view_props = workspace_member_props(obj.view_props)
obj.default_props = workspace_member_props(obj.default_props)
updated_workspace_member.append(obj)
WorkspaceMemberModel.objects.bulk_update(updated_workspace_member, ["view_props", "default_props"], batch_size=100)
def update_project_member_view_props(apps, schema_editor):
ProjectMemberModel = apps.get_model("db", "ProjectMember")
updated_project_member = []
for obj in ProjectMemberModel.objects.all():
obj.view_props = project_member_props(obj.view_props)
obj.default_props = project_member_props(obj.default_props)
updated_project_member.append(obj)
ProjectMemberModel.objects.bulk_update(updated_project_member, ["view_props", "default_props"], batch_size=100)
def update_cycle_props(apps, schema_editor):
CycleModel = apps.get_model("db", "Cycle")
updated_cycle = []
for obj in CycleModel.objects.all():
if "filter" in obj.view_props:
obj.view_props = cycle_module_props(obj.view_props)
updated_cycle.append(obj)
CycleModel.objects.bulk_update(updated_cycle, ["view_props"], batch_size=100)
def update_module_props(apps, schema_editor):
ModuleModel = apps.get_model("db", "Module")
updated_module = []
for obj in ModuleModel.objects.all():
if "filter" in obj.view_props:
obj.view_props = cycle_module_props(obj.view_props)
updated_module.append(obj)
ModuleModel.objects.bulk_update(updated_module, ["view_props"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('db', '0043_alter_analyticview_created_by_and_more'),
]
operations = [
migrations.RunPython(update_workspace_member_view_props),
migrations.RunPython(update_project_member_view_props),
migrations.RunPython(update_cycle_props),
migrations.RunPython(update_module_props),
]

View File

@ -19,6 +19,7 @@ from .project import (
ProjectIdentifier,
ProjectFavorite,
ProjectDeployBoard,
ProjectPublicMember,
)
from .issue import (
@ -31,6 +32,7 @@ from .issue import (
IssueAssignee,
Label,
IssueBlocker,
IssueRelation,
IssueLink,
IssueSequence,
IssueAttachment,

View File

@ -29,6 +29,7 @@ class IssueManager(models.Manager):
| models.Q(issue_inbox__isnull=True)
)
.exclude(archived_at__isnull=False)
.exclude(is_draft=True)
)
@ -38,6 +39,7 @@ class Issue(ProjectBaseModel):
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("none", "None")
)
parent = models.ForeignKey(
"self",
@ -64,8 +66,7 @@ class Issue(ProjectBaseModel):
max_length=30,
choices=PRIORITY_CHOICES,
verbose_name="Issue Priority",
null=True,
blank=True,
default="none",
)
start_date = models.DateField(null=True, blank=True)
target_date = models.DateField(null=True, blank=True)
@ -83,6 +84,7 @@ class Issue(ProjectBaseModel):
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
is_draft = models.BooleanField(default=False)
objects = models.Manager()
issue_objects = IssueManager()
@ -178,6 +180,37 @@ class IssueBlocker(ProjectBaseModel):
return f"{self.block.name} {self.blocked_by.name}"
class IssueRelation(ProjectBaseModel):
RELATION_CHOICES = (
("duplicate", "Duplicate"),
("relates_to", "Relates To"),
("blocked_by", "Blocked By"),
)
issue = models.ForeignKey(
Issue, related_name="issue_relation", on_delete=models.CASCADE
)
related_issue = models.ForeignKey(
Issue, related_name="issue_related", on_delete=models.CASCADE
)
relation_type = models.CharField(
max_length=20,
choices=RELATION_CHOICES,
verbose_name="Issue Relation Type",
default="blocked_by",
)
class Meta:
unique_together = ["issue", "related_issue"]
verbose_name = "Issue Relation"
verbose_name_plural = "Issue Relations"
db_table = "issue_relations"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.related_issue.name}"
class IssueAssignee(ProjectBaseModel):
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_assignee"
@ -293,7 +326,7 @@ class IssueComment(ProjectBaseModel):
comment_json = models.JSONField(blank=True, default=dict)
comment_html = models.TextField(blank=True, default="<p></p>")
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
issue = models.ForeignKey(Issue, on_delete=models.CASCADE)
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_comments")
# System can also create comment
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
@ -476,10 +509,12 @@ class IssueVote(ProjectBaseModel):
choices=(
(-1, "DOWNVOTE"),
(1, "UPVOTE"),
),
default=1,
)
)
class Meta:
unique_together = ["issue", "actor"]
unique_together = ["issue", "actor",]
verbose_name = "Issue Vote"
verbose_name_plural = "Issue Votes"
db_table = "issue_votes"

View File

@ -25,13 +25,26 @@ ROLE_CHOICES = (
def get_default_props():
return {
"filters": {"type": None},
"orderBy": "-created_at",
"collapsed": True,
"issueView": "list",
"filterIssue": None,
"groupByProperty": None,
"showEmptyGroups": True,
"filters": {
"priority": None,
"state": None,
"state_group": None,
"assignees": None,
"created_by": None,
"labels": None,
"start_date": None,
"target_date": None,
"subscriber": None,
},
"display_filters": {
"group_by": None,
"order_by": '-created_at',
"type": None,
"sub_issue": True,
"show_empty_groups": True,
"layout": "list",
"calendar_date_range": "",
},
}
@ -254,3 +267,18 @@ class ProjectDeployBoard(ProjectBaseModel):
def __str__(self):
"""Return project and anchor"""
return f"{self.anchor} <{self.project.name}>"
class ProjectPublicMember(ProjectBaseModel):
member = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="public_project_members",
)
class Meta:
unique_together = ["project", "member"]
verbose_name = "Project Public Member"
verbose_name_plural = "Project Public Members"
db_table = "project_public_members"
ordering = ("-created_at",)

View File

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

View File

@ -16,26 +16,41 @@ ROLE_CHOICES = (
def get_default_props():
return {
"filters": {"type": None},
"groupByProperty": None,
"issueView": "list",
"orderBy": "-created_at",
"properties": {
"filters": {
"priority": None,
"state": None,
"state_group": None,
"assignees": None,
"created_by": None,
"labels": None,
"start_date": None,
"target_date": None,
"subscriber": None,
},
"display_filters": {
"group_by": None,
"order_by": '-created_at',
"type": None,
"sub_issue": True,
"show_empty_groups": True,
"layout": "list",
"calendar_date_range": "",
},
"display_properties": {
"assignee": True,
"attachment_count": True,
"created_on": True,
"due_date": True,
"estimate": True,
"key": True,
"labels": True,
"link": True,
"priority": True,
"start_date": True,
"state": True,
"sub_issue_count": True,
"attachment_count": True,
"link": True,
"estimate": True,
"created_on": True,
"updated_on": True,
"start_date": True,
},
"showEmptyGroups": True,
}
}

View File

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

View File

@ -96,7 +96,7 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
chart_data = {str(date): 0 for date in date_range}
completed_issues_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
@ -118,7 +118,7 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
chart_data = {str(date): 0 for date in date_range}
completed_issues_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_module__module_id=module_id,

View File

@ -15,7 +15,7 @@ def resolve_keys(group_keys, value):
return value
def group_results(results_data, group_by):
def group_results(results_data, group_by, sub_group_by=False):
"""group results data into certain group_by
Args:
@ -25,6 +25,108 @@ def group_results(results_data, group_by):
Returns:
obj: grouped results
"""
if sub_group_by:
main_responsive_dict = dict()
if sub_group_by == "priority":
main_responsive_dict = {
"urgent": {},
"high": {},
"medium": {},
"low": {},
"none": {},
}
for value in results_data:
main_group_attribute = resolve_keys(sub_group_by, value)
group_attribute = resolve_keys(group_by, value)
if isinstance(main_group_attribute, list) and not isinstance(group_attribute, list):
if len(main_group_attribute):
for attrib in main_group_attribute:
if str(attrib) not in main_responsive_dict:
main_responsive_dict[str(attrib)] = {}
if str(group_attribute) in main_responsive_dict[str(attrib)]:
main_responsive_dict[str(attrib)][str(group_attribute)].append(value)
else:
main_responsive_dict[str(attrib)][str(group_attribute)] = []
main_responsive_dict[str(attrib)][str(group_attribute)].append(value)
else:
if str(None) not in main_responsive_dict:
main_responsive_dict[str(None)] = {}
if str(group_attribute) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][str(group_attribute)].append(value)
else:
main_responsive_dict[str(None)][str(group_attribute)] = []
main_responsive_dict[str(None)][str(group_attribute)].append(value)
elif isinstance(group_attribute, list) and not isinstance(main_group_attribute, list):
if str(main_group_attribute) not in main_responsive_dict:
main_responsive_dict[str(main_group_attribute)] = {}
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in main_responsive_dict[str(main_group_attribute)]:
main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value)
else:
main_responsive_dict[str(main_group_attribute)][str(attrib)] = []
main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value)
else:
if str(None) in main_responsive_dict[str(main_group_attribute)]:
main_responsive_dict[str(main_group_attribute)][str(None)].append(value)
else:
main_responsive_dict[str(main_group_attribute)][str(None)] = []
main_responsive_dict[str(main_group_attribute)][str(None)].append(value)
elif isinstance(group_attribute, list) and isinstance(main_group_attribute, list):
if len(main_group_attribute):
for main_attrib in main_group_attribute:
if str(main_attrib) not in main_responsive_dict:
main_responsive_dict[str(main_attrib)] = {}
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in main_responsive_dict[str(main_attrib)]:
main_responsive_dict[str(main_attrib)][str(attrib)].append(value)
else:
main_responsive_dict[str(main_attrib)][str(attrib)] = []
main_responsive_dict[str(main_attrib)][str(attrib)].append(value)
else:
if str(None) in main_responsive_dict[str(main_attrib)]:
main_responsive_dict[str(main_attrib)][str(None)].append(value)
else:
main_responsive_dict[str(main_attrib)][str(None)] = []
main_responsive_dict[str(main_attrib)][str(None)].append(value)
else:
if str(None) not in main_responsive_dict:
main_responsive_dict[str(None)] = {}
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][str(attrib)].append(value)
else:
main_responsive_dict[str(None)][str(attrib)] = []
main_responsive_dict[str(None)][str(attrib)].append(value)
else:
if str(None) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][str(None)].append(value)
else:
main_responsive_dict[str(None)][str(None)] = []
main_responsive_dict[str(None)][str(None)].append(value)
else:
main_group_attribute = resolve_keys(sub_group_by, value)
group_attribute = resolve_keys(group_by, value)
if str(main_group_attribute) not in main_responsive_dict:
main_responsive_dict[str(main_group_attribute)] = {}
if str(group_attribute) in main_responsive_dict[str(main_group_attribute)]:
main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value)
else:
main_responsive_dict[str(main_group_attribute)][str(group_attribute)] = []
main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value)
return main_responsive_dict
else:
response_dict = dict()
if group_by == "priority":
@ -33,7 +135,7 @@ def group_results(results_data, group_by):
"high": [],
"medium": [],
"low": [],
"None": [],
"none": [],
}
for value in results_data:

View File

@ -1,6 +1,7 @@
from django.utils.timezone import make_aware
from django.utils.dateparse import parse_datetime
def filter_state(params, filter, method):
if method == "GET":
states = params.get("state").split(",")
@ -23,7 +24,6 @@ def filter_state_group(params, filter, method):
return filter
def filter_estimate_point(params, filter, method):
if method == "GET":
estimate_points = params.get("estimate_point").split(",")
@ -39,25 +39,10 @@ def filter_priority(params, filter, method):
if method == "GET":
priorities = params.get("priority").split(",")
if len(priorities) and "" not in priorities:
if len(priorities) == 1 and "null" in priorities:
filter["priority__isnull"] = True
elif len(priorities) > 1 and "null" in priorities:
filter["priority__isnull"] = True
filter["priority__in"] = [p for p in priorities if p != "null"]
else:
filter["priority__in"] = [p for p in priorities if p != "null"]
filter["priority__in"] = priorities
else:
if params.get("priority", None) and len(params.get("priority")):
priorities = params.get("priority")
if len(priorities) == 1 and "null" in priorities:
filter["priority__isnull"] = True
elif len(priorities) > 1 and "null" in priorities:
filter["priority__isnull"] = True
filter["priority__in"] = [p for p in priorities if p != "null"]
else:
filter["priority__in"] = [p for p in priorities if p != "null"]
filter["priority__in"] = params.get("priority")
return filter
@ -229,7 +214,6 @@ def filter_issue_state_type(params, filter, method):
return filter
def filter_project(params, filter, method):
if method == "GET":
projects = params.get("project").split(",")

View File

@ -1,36 +1,36 @@
# base requirements
Django==4.2.3
Django==4.2.5
django-braces==1.15.0
django-taggit==4.0.0
psycopg==3.1.9
psycopg==3.1.10
django-oauth-toolkit==2.3.0
mistune==3.0.1
djangorestframework==3.14.0
redis==4.6.0
django-nested-admin==4.0.2
django-cors-headers==4.1.0
django-cors-headers==4.2.0
whitenoise==6.5.0
django-allauth==0.54.0
django-allauth==0.55.2
faker==18.11.2
django-filter==23.2
jsonmodels==2.6.0
djangorestframework-simplejwt==5.2.2
sentry-sdk==1.27.0
djangorestframework-simplejwt==5.3.0
sentry-sdk==1.30.0
django-s3-storage==0.14.0
django-crum==0.7.9
django-guardian==2.4.0
dj_rest_auth==2.2.5
google-auth==2.21.0
google-api-python-client==2.92.0
google-auth==2.22.0
google-api-python-client==2.97.0
django-redis==5.3.0
uvicorn==0.22.0
uvicorn==0.23.2
channels==4.0.0
openai==0.27.8
openai==0.28.0
slack-sdk==3.21.3
celery==5.3.1
celery==5.3.4
django_celery_beat==2.5.0
psycopg-binary==3.1.9
psycopg-c==3.1.9
psycopg-binary==3.1.10
psycopg-c==3.1.10
scout-apm==2.26.1
openpyxl==3.1.2

View File

@ -1,11 +1,11 @@
-r base.txt
dj-database-url==2.0.0
gunicorn==20.1.0
dj-database-url==2.1.0
gunicorn==21.2.0
whitenoise==6.5.0
django-storages==1.13.2
boto3==1.27.0
django-anymail==10.0
django-storages==1.14
boto3==1.28.40
django-anymail==10.1
django-debug-toolbar==4.1.0
gevent==23.7.0
psycogreen==1.0.2

View File

@ -1,70 +0,0 @@
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo
COPY . .
RUN turbo prune --scope=app --docker
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install --network-timeout 500000
# Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build --filter=app
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL}
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} app
FROM node:18-alpine AS runner
WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 plane
RUN adduser --system --uid 1001 captain
USER captain
COPY --from=installer /app/apps/app/next.config.js .
COPY --from=installer /app/apps/app/package.json .
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/apps/app/.next ./apps/app/.next
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
USER root
COPY replace-env-vars.sh /usr/local/bin/
COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh
USER captain
ENV NEXT_TELEMETRY_DISABLED 1
EXPOSE 3000

View File

@ -1,90 +0,0 @@
import React, { useState } from "react";
// component
import { CustomSelect, ToggleSwitch } from "components/ui";
import { SelectMonthModal } from "components/automation";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
// constants
import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
// types
import { IProject } from "types";
type Props = {
projectDetails: IProject | undefined;
handleChange: (formData: Partial<IProject>) => Promise<void>;
};
export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleChange }) => {
const [monthModal, setmonthModal] = useState(false);
const initialValues: Partial<IProject> = { archive_in: 1 };
return (
<>
<SelectMonthModal
type="auto-archive"
initialValues={initialValues}
isOpen={monthModal}
handleClose={() => setmonthModal(false)}
handleChange={handleChange}
/>
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-custom-border-300 bg-custom-background-90">
<div className="flex items-center justify-between gap-x-8 gap-y-2">
<div className="flex flex-col gap-2.5">
<h4 className="text-lg font-semibold">Auto-archive closed issues</h4>
<p className="text-sm text-custom-text-200">
Plane will automatically archive issues that have been completed or cancelled for the
configured time period.
</p>
</div>
<ToggleSwitch
value={projectDetails?.archive_in !== 0}
onChange={() =>
projectDetails?.archive_in === 0
? handleChange({ archive_in: 1 })
: handleChange({ archive_in: 0 })
}
size="sm"
/>
</div>
{projectDetails?.archive_in !== 0 && (
<div className="flex items-center justify-between gap-2 w-full">
<div className="w-1/2 text-base font-medium">
Auto-archive issues that are closed for
</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.archive_in}
label={`${projectDetails?.archive_in} ${
projectDetails?.archive_in === 1 ? "Month" : "Months"
}`}
onChange={(val: number) => {
handleChange({ archive_in: val });
}}
input
verticalPosition="top"
width="w-full"
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
{month.label}
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
Customize Time Range
</button>
</>
</CustomSelect>
</div>
</div>
)}
</div>
</>
);
};

View File

@ -1,176 +0,0 @@
import React, { useState } from "react";
import useSWR from "swr";
import { useRouter } from "next/router";
// component
import { CustomSearchSelect, CustomSelect, ToggleSwitch } from "components/ui";
import { SelectMonthModal } from "components/automation";
// icons
import { ChevronDownIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
import { getStateGroupIcon } from "components/icons";
// services
import stateService from "services/state.service";
// constants
import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
import { STATES_LIST } from "constants/fetch-keys";
// types
import { IProject } from "types";
// helper
import { getStatesList } from "helpers/state.helper";
type Props = {
projectDetails: IProject | undefined;
handleChange: (formData: Partial<IProject>) => Promise<void>;
};
export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleChange }) => {
const [monthModal, setmonthModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups);
const options = states
?.filter((state) => state.group === "cancelled")
.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 multipleOptions = (options ?? []).length > 1;
const defaultState = stateGroups && stateGroups.cancelled ? stateGroups.cancelled[0].id : null;
const selectedOption = states?.find(
(s) => s.id === projectDetails?.default_state ?? defaultState
);
const currentDefaultState = states?.find((s) => s.id === defaultState);
const initialValues: Partial<IProject> = {
close_in: 1,
default_state: defaultState,
};
return (
<>
<SelectMonthModal
type="auto-close"
initialValues={initialValues}
isOpen={monthModal}
handleClose={() => setmonthModal(false)}
handleChange={handleChange}
/>
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-custom-border-300 bg-custom-background-90">
<div className="flex items-center justify-between gap-x-8 gap-y-2 ">
<div className="flex flex-col gap-2.5">
<h4 className="text-lg font-semibold">Auto-close inactive issues</h4>
<p className="text-sm text-custom-text-200">
Plane will automatically close the issues that have not been updated for the
configured time period.
</p>
</div>
<ToggleSwitch
value={projectDetails?.close_in !== 0}
onChange={() =>
projectDetails?.close_in === 0
? handleChange({ close_in: 1, default_state: defaultState })
: handleChange({ close_in: 0, default_state: null })
}
size="sm"
/>
</div>
{projectDetails?.close_in !== 0 && (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center justify-between gap-2 w-full">
<div className="w-1/2 text-base font-medium">
Auto-close issues that are inactive for
</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.close_in}
label={`${projectDetails?.close_in} ${
projectDetails?.close_in === 1 ? "Month" : "Months"
}`}
onChange={(val: number) => {
handleChange({ close_in: val });
}}
input
width="w-full"
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
{month.label}
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
Customize Time Range
</button>
</>
</CustomSelect>
</div>
</div>
<div className="flex items-center justify-between gap-2 w-full">
<div className="w-1/2 text-base font-medium">Auto-close Status</div>
<div className="w-1/2 ">
<CustomSearchSelect
value={
projectDetails?.default_state ? projectDetails?.default_state : defaultState
}
label={
<div className="flex items-center gap-2">
{selectedOption ? (
getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)
) : currentDefaultState ? (
getStateGroupIcon(
currentDefaultState.group,
"16",
"16",
currentDefaultState.color
)
) : (
<Squares2X2Icon className="h-3.5 w-3.5 text-custom-text-200" />
)}
{selectedOption?.name
? selectedOption.name
: currentDefaultState?.name ?? (
<span className="text-custom-text-200">State</span>
)}
</div>
}
onChange={(val: string) => {
handleChange({ default_state: val });
}}
options={options}
disabled={!multipleOptions}
width="w-full"
input
/>
</div>
</div>
</div>
)}
</div>
</>
);
};

View File

@ -1,62 +0,0 @@
// components
import { SingleList } from "components/core/views/list-view/single-list";
// types
import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types";
// types
type Props = {
states: IState[] | undefined;
addIssueToGroup: (groupTitle: string) => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
viewProps: IIssueViewProps;
};
export const AllLists: React.FC<Props> = ({
addIssueToGroup,
handleIssueAction,
disableUserActions,
openIssuesListModal,
removeIssue,
states,
user,
userAuth,
viewProps,
}) => {
const { groupByProperty: selectedGroup, groupedIssues, showEmptyGroups } = viewProps;
return (
<>
{groupedIssues && (
<div className="h-full overflow-y-auto">
{Object.keys(groupedIssues).map((singleGroup) => {
const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
if (!showEmptyGroups && groupedIssues[singleGroup].length === 0) return null;
return (
<SingleList
key={singleGroup}
groupTitle={singleGroup}
currentState={currentState}
addIssueToGroup={() => addIssueToGroup(singleGroup)}
handleIssueAction={handleIssueAction}
openIssuesListModal={openIssuesListModal}
removeIssue={removeIssue}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
viewProps={viewProps}
/>
);
})}
</div>
)}
</>
);
};

View File

@ -1,138 +0,0 @@
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// components
import { SpreadsheetColumns, SpreadsheetIssues } from "components/core";
import { CustomMenu, Spinner } from "components/ui";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
// types
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types";
// constants
import { SPREADSHEET_COLUMN } from "constants/spreadsheet";
// icon
import { PlusIcon } from "@heroicons/react/24/outline";
type Props = {
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
openIssuesListModal?: (() => void) | null;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
};
export const SpreadsheetView: React.FC<Props> = ({
handleIssueAction,
openIssuesListModal,
disableUserActions,
user,
userAuth,
}) => {
const [expandedIssues, setExpandedIssues] = useState<string[]>([]);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
const { spreadsheetIssues } = useSpreadsheetIssuesView();
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const columnData = SPREADSHEET_COLUMN.map((column) => ({
...column,
isActive: properties
? column.propertyName === "labels"
? properties[column.propertyName as keyof Properties]
: column.propertyName === "title"
? true
: properties[column.propertyName as keyof Properties]
: false,
}));
const gridTemplateColumns = columnData
.filter((column) => column.isActive)
.map((column) => column.colSize)
.join(" ");
return (
<div className="h-full rounded-lg text-custom-text-200 overflow-x-auto whitespace-nowrap bg-custom-background-100">
<div className="sticky z-[2] top-0 border-b border-custom-border-200 bg-custom-background-90 w-full min-w-max">
<SpreadsheetColumns columnData={columnData} gridTemplateColumns={gridTemplateColumns} />
</div>
{spreadsheetIssues ? (
<div className="flex flex-col h-full w-full bg-custom-background-100 rounded-sm ">
{spreadsheetIssues.map((issue: IIssue, index) => (
<SpreadsheetIssues
key={`${issue.id}_${index}`}
index={index}
issue={issue}
expandedIssues={expandedIssues}
setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns}
properties={properties}
handleIssueAction={handleIssueAction}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
/>
))}
<div
className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max"
style={{ gridTemplateColumns }}
>
{type === "issue" ? (
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
) : (
!disableUserActions && (
<CustomMenu
className="sticky left-0 z-[1]"
customButton={
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full"
type="button"
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
}
position="left"
optionsClassName="left-5 !w-36"
noBorder
>
<CustomMenu.MenuItem
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)
)}
</div>
</div>
) : (
<Spinner />
)}
</div>
);
};

View File

@ -1,45 +0,0 @@
import { useRouter } from "next/router";
// hooks
import useIssuesView from "hooks/use-issues-view";
import useUser from "hooks/use-user";
import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view";
import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
// components
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
// types
import { IIssue } from "types";
export const CycleIssuesGanttChartView = () => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const { orderBy } = useIssuesView();
const { user } = useUser();
const { ganttIssues, mutateGanttIssues } = useGanttChartCycleIssues(
workspaceSlug as string,
projectId as string,
cycleId as string
);
return (
<div className="w-full h-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}
SidebarBlockRender={IssueGanttSidebarBlock}
BlockRender={IssueGanttBlock}
enableReorder={orderBy === "sort_order"}
bottomSpacing
/>
</div>
);
};

View File

@ -1,14 +0,0 @@
// types
import { IIssue } from "types";
import { IGanttBlock } from "components/gantt-chart";
export const renderIssueBlocksStructure = (blocks: IIssue[]): IGanttBlock[] =>
blocks && blocks.length > 0
? blocks.map((block) => ({
data: block,
id: block.id,
sort_order: block.sort_order,
start_date: new Date(block.start_date ?? ""),
target_date: new Date(block.target_date ?? ""),
}))
: [];

View File

@ -1,21 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const BacklogStateIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
color = "rgb(var(--color-text-200))",
}) => (
<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>
);

View File

@ -1,25 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const BlockedIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 23 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.5 4.8C0.5 3.52696 1.00797 2.30606 1.91216 1.40589C2.81636 0.505713 4.04271 0 5.32143 0H21.3929C21.6913 0 21.9839 0.0827435 22.2378 0.238959C22.4917 0.395174 22.6968 0.618689 22.8303 0.884458C22.9638 1.15023 23.0203 1.44775 22.9935 1.74369C22.9667 2.03963 22.8576 2.32229 22.6786 2.56L18.5804 8L22.6786 13.44C22.8576 13.6777 22.9667 13.9604 22.9935 14.2563C23.0203 14.5522 22.9638 14.8498 22.8303 15.1155C22.6968 15.3813 22.4917 15.6048 22.2378 15.761C21.9839 15.9173 21.6913 16 21.3929 16H5.32143C4.89519 16 4.4864 16.1686 4.18501 16.4686C3.88361 16.7687 3.71429 17.1757 3.71429 17.6V22.4C3.71429 22.8243 3.54496 23.2313 3.24356 23.5314C2.94217 23.8314 2.53338 24 2.10714 24C1.6809 24 1.27212 23.8314 0.970721 23.5314C0.669323 23.2313 0.5 22.8243 0.5 22.4V4.8Z"
fill="#F76659"
/>
<path
d="M8.5918 20.4812H21.084C21.26 20.4812 21.4056 20.4237 21.5207 20.3086C21.6358 20.1935 21.6934 20.0479 21.6934 19.8719C21.6934 19.6958 21.6358 19.5503 21.5207 19.4352C21.4056 19.3201 21.26 19.2625 21.084 19.2625H8.57148L10.3184 17.5156C10.4267 17.4073 10.4809 17.2719 10.4809 17.1094C10.4809 16.9469 10.4199 16.8047 10.298 16.6828C10.1762 16.5609 10.034 16.5 9.87148 16.5C9.70899 16.5 9.5668 16.5609 9.44492 16.6828L6.68242 19.4453C6.61471 19.513 6.56732 19.5807 6.54023 19.6484C6.51315 19.7161 6.49961 19.7906 6.49961 19.8719C6.49961 19.9531 6.51315 20.0276 6.54023 20.0953C6.56732 20.163 6.61471 20.2307 6.68242 20.2984L9.44492 23.0609C9.58034 23.1964 9.72591 23.2607 9.88164 23.2539C10.0374 23.2471 10.1762 23.1828 10.298 23.0609C10.4199 22.9391 10.4809 22.7935 10.4809 22.6242C10.4809 22.4549 10.4267 22.3161 10.3184 22.2078L8.5918 20.4812Z"
fill="#F76659"
/>
</svg>
);

View File

@ -1,25 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const BlockerIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 23 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 4.8C0 3.52696 0.507971 2.30606 1.41216 1.40589C2.31636 0.505713 3.54271 0 4.82143 0H20.8929C21.1913 0 21.4839 0.0827435 21.7378 0.238959C21.9917 0.395174 22.1968 0.618689 22.3303 0.884458C22.4638 1.15023 22.5203 1.44775 22.4935 1.74369C22.4667 2.03963 22.3576 2.32229 22.1786 2.56L18.0804 8L22.1786 13.44C22.3576 13.6777 22.4667 13.9604 22.4935 14.2563C22.5203 14.5522 22.4638 14.8498 22.3303 15.1155C22.1968 15.3813 21.9917 15.6048 21.7378 15.761C21.4839 15.9173 21.1913 16 20.8929 16H4.82143C4.39519 16 3.9864 16.1686 3.68501 16.4686C3.38361 16.7687 3.21429 17.1757 3.21429 17.6V22.4C3.21429 22.8243 3.04496 23.2313 2.74356 23.5314C2.44217 23.8314 2.03338 24 1.60714 24C1.1809 24 0.772119 23.8314 0.470721 23.5314C0.169323 23.2313 0 22.8243 0 22.4V4.8Z"
fill="#F7AE59"
/>
<path
d="M18.5391 20.8797H6.04688C5.87083 20.8797 5.72526 20.8221 5.61016 20.707C5.49505 20.5919 5.4375 20.4464 5.4375 20.2703C5.4375 20.0943 5.49505 19.9487 5.61016 19.8336C5.72526 19.7185 5.87083 19.6609 6.04688 19.6609H18.5594L16.8125 17.9141C16.7042 17.8057 16.65 17.6703 16.65 17.5078C16.65 17.3453 16.7109 17.2031 16.8328 17.0813C16.9547 16.9594 17.0969 16.8984 17.2594 16.8984C17.4219 16.8984 17.5641 16.9594 17.6859 17.0813L20.4484 19.8438C20.5161 19.9115 20.5635 19.9792 20.5906 20.0469C20.6177 20.1146 20.6313 20.1891 20.6313 20.2703C20.6313 20.3516 20.6177 20.426 20.5906 20.4938C20.5635 20.5615 20.5161 20.6292 20.4484 20.6969L17.6859 23.4594C17.5505 23.5948 17.4049 23.6591 17.2492 23.6523C17.0935 23.6456 16.9547 23.5812 16.8328 23.4594C16.7109 23.3375 16.65 23.1919 16.65 23.0227C16.65 22.8534 16.7042 22.7146 16.8125 22.6062L18.5391 20.8797Z"
fill="#F7AE59"
/>
</svg>
);

View File

@ -1,16 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const BoltIcon: 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="M10.6002 21C10.4169 21 10.2752 20.9417 10.1752 20.825C10.0752 20.7083 10.0419 20.5583 10.0752 20.375L11.0002 13.95H7.3502C7.16686 13.95 7.03353 13.8667 6.9502 13.7C6.86686 13.5333 6.86686 13.375 6.9502 13.225L12.8752 3.325C12.9252 3.24167 13.0085 3.16667 13.1252 3.1C13.2419 3.03333 13.3585 3 13.4752 3C13.6585 3 13.8002 3.05833 13.9002 3.175C14.0002 3.29167 14.0335 3.44167 14.0002 3.625L13.0752 10.025H16.6752C16.8585 10.025 16.996 10.1083 17.0877 10.275C17.1794 10.4417 17.1835 10.6 17.1002 10.75L11.2002 20.675C11.1502 20.7583 11.0669 20.8333 10.9502 20.9C10.8335 20.9667 10.7169 21 10.6002 21V21Z" />
</svg>
);

View File

@ -1,16 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const CancelIcon: React.FC<Props> = ({ width, height, className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.725 16.275C7.875 16.425 8.05 16.5 8.25 16.5C8.45 16.5 8.625 16.425 8.775 16.275L12 13.05L15.25 16.3C15.3833 16.4333 15.5542 16.4958 15.7625 16.4875C15.9708 16.4792 16.1417 16.4083 16.275 16.275C16.425 16.125 16.5 15.95 16.5 15.75C16.5 15.55 16.425 15.375 16.275 15.225L13.05 12L16.3 8.75C16.4333 8.61667 16.4958 8.44583 16.4875 8.2375C16.4792 8.02917 16.4083 7.85833 16.275 7.725C16.125 7.575 15.95 7.5 15.75 7.5C15.55 7.5 15.375 7.575 15.225 7.725L12 10.95L8.75 7.7C8.61667 7.56667 8.44583 7.50417 8.2375 7.5125C8.02917 7.52083 7.85833 7.59167 7.725 7.725C7.575 7.875 7.5 8.05 7.5 8.25C7.5 8.45 7.575 8.625 7.725 8.775L10.95 12L7.7 15.25C7.56667 15.3833 7.50417 15.5542 7.5125 15.7625C7.52083 15.9708 7.59167 16.1417 7.725 16.275ZM12 22C10.5833 22 9.26667 21.7458 8.05 21.2375C6.83333 20.7292 5.775 20.025 4.875 19.125C3.975 18.225 3.27083 17.1667 2.7625 15.95C2.25417 14.7333 2 13.4167 2 12C2 10.6 2.25417 9.29167 2.7625 8.075C3.27083 6.85833 3.975 5.8 4.875 4.9C5.775 4 6.83333 3.29167 8.05 2.775C9.26667 2.25833 10.5833 2 12 2C13.4 2 14.7083 2.25833 15.925 2.775C17.1417 3.29167 18.2 4 19.1 4.9C20 5.8 20.7083 6.85833 21.225 8.075C21.7417 9.29167 22 10.6 22 12C22 13.4167 21.7417 14.7333 21.225 15.95C20.7083 17.1667 20 18.225 19.1 19.125C18.2 20.025 17.1417 20.7292 15.925 21.2375C14.7083 21.7458 13.4 22 12 22ZM12 20.5C14.3333 20.5 16.3333 19.6667 18 18C19.6667 16.3333 20.5 14.3333 20.5 12C20.5 9.66667 19.6667 7.66667 18 6C16.3333 4.33333 14.3333 3.5 12 3.5C9.66667 3.5 7.66667 4.33333 6 6C4.33333 7.66667 3.5 9.66667 3.5 12C3.5 14.3333 4.33333 16.3333 6 18C7.66667 19.6667 9.66667 20.5 12 20.5Z" />
</svg>
);

View File

@ -1,78 +0,0 @@
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>
);

View File

@ -1,19 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const ClipboardIcon: 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="M4.5 21C4.08333 21 3.72917 20.8542 3.4375 20.5625C3.14583 20.2708 3 19.9167 3 19.5V4.5C3 4.08333 3.14583 3.72917 3.4375 3.4375C3.72917 3.14583 4.08333 3 4.5 3H9.625C9.70833 2.41667 9.975 1.9375 10.425 1.5625C10.875 1.1875 11.4 1 12 1C12.6 1 13.125 1.1875 13.575 1.5625C14.025 1.9375 14.2917 2.41667 14.375 3H19.5C19.9167 3 20.2708 3.14583 20.5625 3.4375C20.8542 3.72917 21 4.08333 21 4.5V19.5C21 19.9167 20.8542 20.2708 20.5625 20.5625C20.2708 20.8542 19.9167 21 19.5 21H4.5ZM4.5 19.5H19.5V4.5H4.5V19.5ZM7 17H13.825V15.5H7V17ZM7 12.75H17V11.25H7V12.75ZM7 8.5H17V7H7V8.5ZM12 4.075C12.2333 4.075 12.4375 3.9875 12.6125 3.8125C12.7875 3.6375 12.875 3.43333 12.875 3.2C12.875 2.96667 12.7875 2.7625 12.6125 2.5875C12.4375 2.4125 12.2333 2.325 12 2.325C11.7667 2.325 11.5625 2.4125 11.3875 2.5875C11.2125 2.7625 11.125 2.96667 11.125 3.2C11.125 3.43333 11.2125 3.6375 11.3875 3.8125C11.5625 3.9875 11.7667 4.075 12 4.075ZM4.5 19.5V4.5V19.5Z"
fill="black"
/>
</svg>
);

View File

@ -1,16 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const CommentIcon: 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 16.1V3.05C2 2.81667 2.10833 2.58333 2.325 2.35C2.54167 2.11667 2.76667 2 3 2H15.975C16.225 2 16.4583 2.1125 16.675 2.3375C16.8917 2.5625 17 2.8 17 3.05V11.95C17 12.1833 16.8917 12.4167 16.675 12.65C16.4583 12.8833 16.225 13 15.975 13H6L2.65 16.35C2.53333 16.4667 2.39583 16.4958 2.2375 16.4375C2.07917 16.3792 2 16.2667 2 16.1ZM3.5 3.5V11.5V3.5ZM7.025 18C6.79167 18 6.5625 17.8833 6.3375 17.65C6.1125 17.4167 6 17.1833 6 16.95V14.5H18.5V6H21C21.2333 6 21.4583 6.11667 21.675 6.35C21.8917 6.58333 22 6.825 22 7.075V21.075C22 21.2417 21.9208 21.3542 21.7625 21.4125C21.6042 21.4708 21.4667 21.4417 21.35 21.325L18.025 18H7.025ZM15.5 3.5H3.5V11.5H15.5V3.5Z" />
</svg>
);

View File

@ -1,17 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const CompletedCycleIcon: React.FC<Props> = ({
width = "24",
height = "24",
className,
color = "black",
}) => (
<svg xmlns="http://www.w3.org/2000/svg" height={height} width={width} className={className}>
<path
d="m21.65 36.6-6.9-6.85 2.1-2.1 4.8 4.7 9.2-9.2 2.1 2.15ZM6 44V7h6.25V4h3.25v3h17V4h3.25v3H42v37Zm3-3h30V19.5H9Zm0-24.5h30V10H9Zm0 0V10v6.5Z"
fill={color}
/>
</svg>
);

View File

@ -1,69 +0,0 @@
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>
);

View File

@ -1,17 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const CurrentCycleIcon: React.FC<Props> = ({
width = "24",
height = "24",
className,
color = "black",
}) => (
<svg xmlns="http://www.w3.org/2000/svg" height={height} width={width} className={className}>
<path
d="M15.3 28.3q-.85 0-1.425-.575-.575-.575-.575-1.425 0-.85.575-1.425.575-.575 1.425-.575.85 0 1.425.575.575.575.575 1.425 0 .85-.575 1.425-.575.575-1.425.575Zm8.85 0q-.85 0-1.425-.575-.575-.575-.575-1.425 0-.85.575-1.425.575-.575 1.425-.575.85 0 1.425.575.575.575.575 1.425 0 .85-.575 1.425-.575.575-1.425.575Zm8.5 0q-.85 0-1.425-.575-.575-.575-.575-1.425 0-.85.575-1.425.575-.575 1.425-.575.85 0 1.425.575.575.575.575 1.425 0 .85-.575 1.425-.575.575-1.425.575ZM6 44V7h6.25V4h3.25v3h17V4h3.25v3H42v37Zm3-3h30V19.5H9Zm0-24.5h30V10H9Zm0 0V10v6.5Z"
fill={color}
/>
</svg>
);

View File

@ -1,19 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const EditIcon: React.FC<Props> = ({ width, height, className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 11.9751C10.9 11.9751 10 11.6251 9.3 10.9251C8.6 10.2251 8.25 9.3251 8.25 8.2251C8.25 7.1251 8.6 6.2251 9.3 5.5251C10 4.8251 10.9 4.4751 12 4.4751C13.1 4.4751 14 4.8251 14.7 5.5251C15.4 6.2251 15.75 7.1251 15.75 8.2251C15.75 9.3251 15.4 10.2251 14.7 10.9251C14 11.6251 13.1 11.9751 12 11.9751ZM18.5 20.0001H5.5C5.08333 20.0001 4.72917 19.8543 4.4375 19.5626C4.14583 19.2709 4 18.9168 4 18.5001V17.6501C4 17.0168 4.15833 16.4751 4.475 16.0251C4.79167 15.5751 5.2 15.2334 5.7 15.0001C6.81667 14.5001 7.8875 14.1251 8.9125 13.8751C9.9375 13.6251 10.9667 13.5001 12 13.5001C13.0333 13.5001 14.0583 13.6293 15.075 13.8876C16.0917 14.1459 17.1583 14.5168 18.275 15.0001C18.7917 15.2334 19.2083 15.5751 19.525 16.0251C19.8417 16.4751 20 17.0168 20 17.6501V18.5001C20 18.9168 19.8542 19.2709 19.5625 19.5626C19.2708 19.8543 18.9167 20.0001 18.5 20.0001ZM5.5 18.5001H18.5V17.6501C18.5 17.3834 18.4208 17.1293 18.2625 16.8876C18.1042 16.6459 17.9083 16.4668 17.675 16.3501C16.6083 15.8334 15.6333 15.4793 14.75 15.2876C13.8667 15.0959 12.95 15.0001 12 15.0001C11.05 15.0001 10.125 15.0959 9.225 15.2876C8.325 15.4793 7.35 15.8334 6.3 16.3501C6.06667 16.4668 5.875 16.6459 5.725 16.8876C5.575 17.1293 5.5 17.3834 5.5 17.6501V18.5001ZM12 10.4751C12.65 10.4751 13.1875 10.2626 13.6125 9.8376C14.0375 9.4126 14.25 8.8751 14.25 8.2251C14.25 7.5751 14.0375 7.0376 13.6125 6.6126C13.1875 6.1876 12.65 5.9751 12 5.9751C11.35 5.9751 10.8125 6.1876 10.3875 6.6126C9.9625 7.0376 9.75 7.5751 9.75 8.2251C9.75 8.8751 9.9625 9.4126 10.3875 9.8376C10.8125 10.2626 11.35 10.4751 12 10.4751Z"
fill="#212529"
/>
</svg>
);

View File

@ -1,19 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const EllipsisHorizontalIcon: React.FC<Props> = ({ width, height, className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.2 13.1998C4.86667 13.1998 4.58333 13.0831 4.35 12.8498C4.11667 12.6165 4 12.3331 4 11.9998C4 11.6665 4.11667 11.3831 4.35 11.1498C4.58333 10.9165 4.86667 10.7998 5.2 10.7998C5.53333 10.7998 5.81667 10.9165 6.05 11.1498C6.28333 11.3831 6.4 11.6665 6.4 11.9998C6.4 12.3331 6.28333 12.6165 6.05 12.8498C5.81667 13.0831 5.53333 13.1998 5.2 13.1998ZM12 13.1998C11.6667 13.1998 11.3833 13.0831 11.15 12.8498C10.9167 12.6165 10.8 12.3331 10.8 11.9998C10.8 11.6665 10.9167 11.3831 11.15 11.1498C11.3833 10.9165 11.6667 10.7998 12 10.7998C12.3333 10.7998 12.6167 10.9165 12.85 11.1498C13.0833 11.3831 13.2 11.6665 13.2 11.9998C13.2 12.3331 13.0833 12.6165 12.85 12.8498C12.6167 13.0831 12.3333 13.1998 12 13.1998ZM18.8 13.1998C18.4667 13.1998 18.1833 13.0831 17.95 12.8498C17.7167 12.6165 17.6 12.3331 17.6 11.9998C17.6 11.6665 17.7167 11.3831 17.95 11.1498C18.1833 10.9165 18.4667 10.7998 18.8 10.7998C19.1333 10.7998 19.4167 10.9165 19.65 11.1498C19.8833 11.3831 20 11.6665 20 11.9998C20 12.3331 19.8833 12.6165 19.65 12.8498C19.4167 13.0831 19.1333 13.1998 18.8 13.1998Z"
fill="black"
/>
</svg>
);

View File

@ -1,16 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const LockIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 25 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M6 22C5.58333 22 5.22917 21.8542 4.9375 21.5625C4.64583 21.2708 4.5 20.9167 4.5 20.5V9.65C4.5 9.23333 4.64583 8.87917 4.9375 8.5875C5.22917 8.29583 5.58333 8.15 6 8.15H7.75V5.75C7.75 4.43333 8.2125 3.3125 9.1375 2.3875C10.0625 1.4625 11.1833 1 12.5 1C13.8167 1 14.9375 1.4625 15.8625 2.3875C16.7875 3.3125 17.25 4.43333 17.25 5.75V8.15H19C19.4167 8.15 19.7708 8.29583 20.0625 8.5875C20.3542 8.87917 20.5 9.23333 20.5 9.65V20.5C20.5 20.9167 20.3542 21.2708 20.0625 21.5625C19.7708 21.8542 19.4167 22 19 22H6ZM6 20.5H19V9.65H6V20.5ZM12.5 17C13.0333 17 13.4875 16.8167 13.8625 16.45C14.2375 16.0833 14.425 15.6417 14.425 15.125C14.425 14.625 14.2375 14.1708 13.8625 13.7625C13.4875 13.3542 13.0333 13.15 12.5 13.15C11.9667 13.15 11.5125 13.3542 11.1375 13.7625C10.7625 14.1708 10.575 14.625 10.575 15.125C10.575 15.6417 10.7625 16.0833 11.1375 16.45C11.5125 16.8167 11.9667 17 12.5 17ZM9.25 8.15H15.75V5.75C15.75 4.85 15.4333 4.08333 14.8 3.45C14.1667 2.81667 13.4 2.5 12.5 2.5C11.6 2.5 10.8333 2.81667 10.2 3.45C9.56667 4.08333 9.25 4.85 9.25 5.75V8.15ZM6 20.5V9.65V20.5Z" />
</svg>
);

View File

@ -1,19 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const MenuIcon: 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.75 18C3.53333 18 3.35417 17.9292 3.2125 17.7875C3.07083 17.6458 3 17.4667 3 17.25C3 17.0333 3.07083 16.8542 3.2125 16.7125C3.35417 16.5708 3.53333 16.5 3.75 16.5H20.25C20.4667 16.5 20.6458 16.5708 20.7875 16.7125C20.9292 16.8542 21 17.0333 21 17.25C21 17.4667 20.9292 17.6458 20.7875 17.7875C20.6458 17.9292 20.4667 18 20.25 18H3.75ZM3.75 12.75C3.53333 12.75 3.35417 12.6792 3.2125 12.5375C3.07083 12.3958 3 12.2167 3 12C3 11.7833 3.07083 11.6042 3.2125 11.4625C3.35417 11.3208 3.53333 11.25 3.75 11.25H20.25C20.4667 11.25 20.6458 11.3208 20.7875 11.4625C20.9292 11.6042 21 11.7833 21 12C21 12.2167 20.9292 12.3958 20.7875 12.5375C20.6458 12.6792 20.4667 12.75 20.25 12.75H3.75ZM3.75 7.5C3.53333 7.5 3.35417 7.42917 3.2125 7.2875C3.07083 7.14583 3 6.96667 3 6.75C3 6.53333 3.07083 6.35417 3.2125 6.2125C3.35417 6.07083 3.53333 6 3.75 6H20.25C20.4667 6 20.6458 6.07083 20.7875 6.2125C20.9292 6.35417 21 6.53333 21 6.75C21 6.96667 20.9292 7.14583 20.7875 7.2875C20.6458 7.42917 20.4667 7.5 20.25 7.5H3.75Z"
fill="#212529"
/>
</svg>
);

View File

@ -1,19 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const PlusIcon: 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="M12 19C11.7833 19 11.6042 18.9292 11.4625 18.7875C11.3208 18.6458 11.25 18.4667 11.25 18.25V12.75H5.75C5.53333 12.75 5.35417 12.6792 5.2125 12.5375C5.07083 12.3958 5 12.2167 5 12C5 11.7833 5.07083 11.6042 5.2125 11.4625C5.35417 11.3208 5.53333 11.25 5.75 11.25H11.25V5.75C11.25 5.53333 11.3208 5.35417 11.4625 5.2125C11.6042 5.07083 11.7833 5 12 5C12.2167 5 12.3958 5.07083 12.5375 5.2125C12.6792 5.35417 12.75 5.53333 12.75 5.75V11.25H18.25C18.4667 11.25 18.6458 11.3208 18.7875 11.4625C18.9292 11.6042 19 11.7833 19 12C19 12.2167 18.9292 12.3958 18.7875 12.5375C18.6458 12.6792 18.4667 12.75 18.25 12.75H12.75V18.25C12.75 18.4667 12.6792 18.6458 12.5375 18.7875C12.3958 18.9292 12.2167 19 12 19Z"
fill="#FFFFFF"
/>
</svg>
);

View File

@ -1,22 +0,0 @@
export const getPriorityIcon = (priority: string | null, className?: string) => {
if (!className || className === "") className = "text-xs flex items-center";
priority = priority?.toLowerCase() ?? null;
switch (priority) {
case "urgent":
return <span className={`material-symbols-rounded ${className}`}>error</span>;
case "high":
return <span className={`material-symbols-rounded ${className}`}>signal_cellular_alt</span>;
case "medium":
return (
<span className={`material-symbols-rounded ${className}`}>signal_cellular_alt_2_bar</span>
);
case "low":
return (
<span className={`material-symbols-rounded ${className}`}>signal_cellular_alt_1_bar</span>
);
default:
return <span className={`material-symbols-rounded ${className}`}>block</span>;
}
};

View File

@ -1,20 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const QuestionMarkCircleIcon: 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="M12.1 17.825C12.3667 17.825 12.5917 17.7333 12.775 17.55C12.9583 17.3667 13.05 17.1417 13.05 16.875C13.05 16.6083 12.9583 16.3833 12.775 16.2C12.5917 16.0167 12.3667 15.925 12.1 15.925C11.8333 15.925 11.6083 16.0167 11.425 16.2C11.2417 16.3833 11.15 16.6083 11.15 16.875C11.15 17.1417 11.2417 17.3667 11.425 17.55C11.6083 17.7333 11.8333 17.825 12.1 17.825ZM12.075 7.5C12.6417 7.5 13.1 7.65417 13.45 7.9625C13.8 8.27083 13.975 8.66667 13.975 9.15C13.975 9.48333 13.875 9.8125 13.675 10.1375C13.475 10.4625 13.15 10.8167 12.7 11.2C12.2667 11.5833 11.9208 11.9875 11.6625 12.4125C11.4042 12.8375 11.275 13.225 11.275 13.575C11.275 13.7583 11.3458 13.9042 11.4875 14.0125C11.6292 14.1208 11.7917 14.175 11.975 14.175C12.175 14.175 12.3417 14.1083 12.475 13.975C12.6083 13.8417 12.6917 13.675 12.725 13.475C12.775 13.1417 12.8875 12.8458 13.0625 12.5875C13.2375 12.3292 13.5083 12.05 13.875 11.75C14.375 11.3333 14.7375 10.9167 14.9625 10.5C15.1875 10.0833 15.3 9.61667 15.3 9.1C15.3 8.21667 15.0125 7.50833 14.4375 6.975C13.8625 6.44167 13.1 6.175 12.15 6.175C11.5167 6.175 10.9333 6.3 10.4 6.55C9.86667 6.8 9.425 7.16667 9.075 7.65C8.94167 7.83333 8.8875 8.02083 8.9125 8.2125C8.9375 8.40417 9.01667 8.55 9.15 8.65C9.33333 8.78333 9.52917 8.825 9.7375 8.775C9.94583 8.725 10.1167 8.60833 10.25 8.425C10.4667 8.125 10.7292 7.89583 11.0375 7.7375C11.3458 7.57917 11.6917 7.5 12.075 7.5ZM12 22C10.6 22 9.29167 21.7458 8.075 21.2375C6.85833 20.7292 5.8 20.025 4.9 19.125C4 18.225 3.29167 17.1667 2.775 15.95C2.25833 14.7333 2 13.4167 2 12C2 10.6 2.25833 9.29167 2.775 8.075C3.29167 6.85833 4 5.8 4.9 4.9C5.8 4 6.85833 3.29167 8.075 2.775C9.29167 2.25833 10.6 2 12 2C13.3833 2 14.6833 2.25833 15.9 2.775C17.1167 3.29167 18.175 4 19.075 4.9C19.975 5.8 20.6875 6.85833 21.2125 8.075C21.7375 9.29167 22 10.6 22 12C22 13.4167 21.7375 14.7333 21.2125 15.95C20.6875 17.1667 19.975 18.225 19.075 19.125C18.175 20.025 17.1167 20.7292 15.9 21.2375C14.6833 21.7458 13.3833 22 12 22ZM12 20.5C14.35 20.5 16.3542 19.6667 18.0125 18C19.6708 16.3333 20.5 14.3333 20.5 12C20.5 9.66667 19.6708 7.66667 18.0125 6C16.3542 4.33333 14.35 3.5 12 3.5C9.61667 3.5 7.60417 4.33333 5.9625 6C4.32083 7.66667 3.5 9.66667 3.5 12C3.5 14.3333 4.32083 16.3333 5.9625 18C7.60417 19.6667 9.61667 20.5 12 20.5Z" />
</svg>
);

View File

@ -1,19 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const SignalCellularIcon: 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="M18.75 20C18.4 20 18.1042 19.8792 17.8625 19.6375C17.6208 19.3958 17.5 19.1 17.5 18.75V5.25C17.5 4.9 17.6208 4.60417 17.8625 4.3625C18.1042 4.12083 18.4 4 18.75 4C19.1 4 19.3958 4.12083 19.6375 4.3625C19.8792 4.60417 20 4.9 20 5.25V18.75C20 19.1 19.8792 19.3958 19.6375 19.6375C19.3958 19.8792 19.1 20 18.75 20ZM6.275 20C6.09167 20 5.92083 19.9667 5.7625 19.9C5.60417 19.8333 5.47083 19.7458 5.3625 19.6375C5.25417 19.5292 5.16667 19.3958 5.1 19.2375C5.03333 19.0792 5 18.9167 5 18.75V15.25C5 14.9 5.12083 14.6042 5.3625 14.3625C5.60417 14.1208 5.9 14 6.25 14C6.6 14 6.89583 14.1208 7.1375 14.3625C7.37917 14.6042 7.5 14.9 7.5 15.25V18.75C7.5 18.9167 7.46667 19.0792 7.4 19.2375C7.33333 19.3958 7.24583 19.5292 7.1375 19.6375C7.02917 19.7458 6.9 19.8333 6.75 19.9C6.6 19.9667 6.44167 20 6.275 20ZM12.5 20C12.15 20 11.8542 19.8792 11.6125 19.6375C11.3708 19.3958 11.25 19.1 11.25 18.75V10.25C11.25 9.9 11.3708 9.60417 11.6125 9.3625C11.8542 9.12083 12.15 9 12.5 9C12.85 9 13.1458 9.12083 13.3875 9.3625C13.6292 9.60417 13.75 9.9 13.75 10.25V18.75C13.75 19.1 13.6292 19.3958 13.3875 19.6375C13.1458 19.8792 12.85 20 12.5 20Z"
fill="#212529"
/>
</svg>
);

View File

@ -1,77 +0,0 @@
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>
);

View File

@ -1,66 +0,0 @@
import {
BacklogStateIcon,
CancelledStateIcon,
CompletedStateIcon,
StartedStateIcon,
UnstartedStateIcon,
} from "components/icons";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
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 ?? STATE_GROUP_COLORS["backlog"]}
className="flex-shrink-0"
/>
);
case "unstarted":
return (
<UnstartedStateIcon
width={width}
height={height}
color={color ?? STATE_GROUP_COLORS["unstarted"]}
className="flex-shrink-0"
/>
);
case "started":
return (
<StartedStateIcon
width={width}
height={height}
color={color ?? STATE_GROUP_COLORS["started"]}
className="flex-shrink-0"
/>
);
case "completed":
return (
<CompletedStateIcon
width={width}
height={height}
color={color ?? STATE_GROUP_COLORS["completed"]}
className="flex-shrink-0"
/>
);
case "cancelled":
return (
<CancelledStateIcon
width={width}
height={height}
color={color ?? STATE_GROUP_COLORS["cancelled"]}
className="flex-shrink-0"
/>
);
default:
return <></>;
}
};

View File

@ -1,24 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const TagIcon: React.FC<Props> = ({
width = "24",
height = "24",
className,
color = "black",
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.975 21.575C13.675 21.875 13.3125 22.025 12.8875 22.025C12.4625 22.025 12.1 21.875 11.8 21.575L2.425 12.2C2.25833 12.0333 2.14583 11.8583 2.0875 11.675C2.02917 11.4917 2 11.3 2 11.1V3.5C2 3.06667 2.14167 2.70833 2.425 2.425C2.70833 2.14167 3.06667 2 3.5 2H11.1C11.3 2 11.5 2.02917 11.7 2.0875C11.9 2.14583 12.0833 2.25833 12.25 2.425L21.575 11.75C21.8917 12.0667 22.05 12.4375 22.05 12.8625C22.05 13.2875 21.8917 13.6583 21.575 13.975L13.975 21.575ZM12.95 20.55L20.55 12.95L11.1 3.5H3.5V11.1L12.95 20.55ZM6.125 7.4C6.475 7.4 6.77917 7.27083 7.0375 7.0125C7.29583 6.75417 7.425 6.45 7.425 6.1C7.425 5.75 7.29583 5.44583 7.0375 5.1875C6.77917 4.92917 6.475 4.8 6.125 4.8C5.775 4.8 5.47083 4.92917 5.2125 5.1875C4.95417 5.44583 4.825 5.75 4.825 6.1C4.825 6.45 4.95417 6.75417 5.2125 7.0125C5.47083 7.27083 5.775 7.4 6.125 7.4Z"
fill={color}
/>
</svg>
);

View File

@ -1,16 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const TransferIcon: React.FC<Props> = ({ width, height, className, color }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 18 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M6.16683 14.6667C4.54183 14.6667 3.16336 14.1007 2.03141 12.9688C0.899468 11.8368 0.333496 10.4583 0.333496 8.83333C0.333496 7.125 0.941135 5.73264 2.15641 4.65625C3.37169 3.57986 4.72933 3.09028 6.22933 3.1875L4.87516 1.83333C4.75016 1.70833 4.68766 1.55903 4.68766 1.38542C4.68766 1.21181 4.75016 1.0625 4.87516 0.9375C5.00016 0.8125 5.14947 0.75 5.32308 0.75C5.49669 0.75 5.646 0.8125 5.771 0.9375L8.22933 3.39583C8.29877 3.46528 8.34739 3.53472 8.37516 3.60417C8.40294 3.67361 8.41683 3.75 8.41683 3.83333C8.41683 3.91667 8.40294 3.99306 8.37516 4.0625C8.34739 4.13194 8.29877 4.20139 8.22933 4.27083L5.771 6.72917C5.646 6.85417 5.50016 6.91319 5.3335 6.90625C5.16683 6.89931 5.021 6.83333 4.896 6.70833C4.771 6.58333 4.7085 6.43403 4.7085 6.26042C4.7085 6.08681 4.771 5.9375 4.896 5.8125L6.29183 4.41667C4.97239 4.38889 3.8578 4.79167 2.94808 5.625C2.03836 6.45833 1.5835 7.52778 1.5835 8.83333C1.5835 10.0972 2.03141 11.1771 2.92725 12.0729C3.82308 12.9688 4.90294 13.4167 6.16683 13.4167H8.04183C8.22239 13.4167 8.37169 13.4757 8.48975 13.5938C8.6078 13.7118 8.66683 13.8611 8.66683 14.0417C8.66683 14.2222 8.6078 14.3715 8.48975 14.4896C8.37169 14.6076 8.22239 14.6667 8.04183 14.6667H6.16683ZM11.5835 14.6667C11.2363 14.6667 10.9411 14.5451 10.6981 14.3021C10.455 14.059 10.3335 13.7639 10.3335 13.4167V10.0833C10.3335 9.73611 10.455 9.44097 10.6981 9.19792C10.9411 8.95486 11.2363 8.83333 11.5835 8.83333H16.5835C16.9307 8.83333 17.2259 8.95486 17.4689 9.19792C17.712 9.44097 17.8335 9.73611 17.8335 10.0833V13.4167C17.8335 13.7639 17.712 14.059 17.4689 14.3021C17.2259 14.5451 16.9307 14.6667 16.5835 14.6667H11.5835ZM11.5835 13.4167H16.5835V10.0833H11.5835V13.4167ZM11.5835 7.16667C11.2363 7.16667 10.9411 7.04514 10.6981 6.80208C10.455 6.55903 10.3335 6.26389 10.3335 5.91667V2.58333C10.3335 2.23611 10.455 1.94097 10.6981 1.69792C10.9411 1.45486 11.2363 1.33333 11.5835 1.33333H16.5835C16.9307 1.33333 17.2259 1.45486 17.4689 1.69792C17.712 1.94097 17.8335 2.23611 17.8335 2.58333V5.91667C17.8335 6.26389 17.712 6.55903 17.4689 6.80208C17.2259 7.04514 16.9307 7.16667 16.5835 7.16667H11.5835Z" fill={color}/>
</svg>
);

View File

@ -1,19 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const TuneIcon: 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.75 18.95C3.53333 18.95 3.35417 18.8792 3.2125 18.7375C3.07083 18.5958 3 18.4167 3 18.2C3 17.9833 3.07083 17.8042 3.2125 17.6625C3.35417 17.5208 3.53333 17.45 3.75 17.45H8.425C8.64167 17.45 8.82083 17.5208 8.9625 17.6625C9.10417 17.8042 9.175 17.9833 9.175 18.2C9.175 18.4167 9.10417 18.5958 8.9625 18.7375C8.82083 18.8792 8.64167 18.95 8.425 18.95H3.75ZM3.75 6.55C3.53333 6.55 3.35417 6.47917 3.2125 6.3375C3.07083 6.19583 3 6.01667 3 5.8C3 5.58333 3.07083 5.40417 3.2125 5.2625C3.35417 5.12083 3.53333 5.05 3.75 5.05H12.575C12.7917 5.05 12.9708 5.12083 13.1125 5.2625C13.2542 5.40417 13.325 5.58333 13.325 5.8C13.325 6.01667 13.2542 6.19583 13.1125 6.3375C12.9708 6.47917 12.7917 6.55 12.575 6.55H3.75ZM11.425 21C11.2083 21 11.0292 20.9292 10.8875 20.7875C10.7458 20.6458 10.675 20.4667 10.675 20.25V16.125C10.675 15.9083 10.7458 15.7292 10.8875 15.5875C11.0292 15.4458 11.2083 15.375 11.425 15.375C11.6417 15.375 11.8208 15.4458 11.9625 15.5875C12.1042 15.7292 12.175 15.9083 12.175 16.125V17.45H20.25C20.4667 17.45 20.6458 17.5208 20.7875 17.6625C20.9292 17.8042 21 17.9833 21 18.2C21 18.4167 20.9292 18.5958 20.7875 18.7375C20.6458 18.8792 20.4667 18.95 20.25 18.95H12.175V20.25C12.175 20.4667 12.1042 20.6458 11.9625 20.7875C11.8208 20.9292 11.6417 21 11.425 21ZM8.425 14.8C8.20833 14.8 8.02917 14.7292 7.8875 14.5875C7.74583 14.4458 7.675 14.2667 7.675 14.05V12.75H3.75C3.53333 12.75 3.35417 12.6792 3.2125 12.5375C3.07083 12.3958 3 12.2167 3 12C3 11.7833 3.07083 11.6042 3.2125 11.4625C3.35417 11.3208 3.53333 11.25 3.75 11.25H7.675V9.9C7.675 9.68333 7.74583 9.50417 7.8875 9.3625C8.02917 9.22083 8.20833 9.15 8.425 9.15C8.64167 9.15 8.82083 9.22083 8.9625 9.3625C9.10417 9.50417 9.175 9.68333 9.175 9.9V14.05C9.175 14.2667 9.10417 14.4458 8.9625 14.5875C8.82083 14.7292 8.64167 14.8 8.425 14.8ZM11.425 12.75C11.2083 12.75 11.0292 12.6792 10.8875 12.5375C10.7458 12.3958 10.675 12.2167 10.675 12C10.675 11.7833 10.7458 11.6042 10.8875 11.4625C11.0292 11.3208 11.2083 11.25 11.425 11.25H20.25C20.4667 11.25 20.6458 11.3208 20.7875 11.4625C20.9292 11.6042 21 11.7833 21 12C21 12.2167 20.9292 12.3958 20.7875 12.5375C20.6458 12.6792 20.4667 12.75 20.25 12.75H11.425ZM15.575 8.625C15.3583 8.625 15.1792 8.55417 15.0375 8.4125C14.8958 8.27083 14.825 8.09167 14.825 7.875V3.75C14.825 3.53333 14.8958 3.35417 15.0375 3.2125C15.1792 3.07083 15.3583 3 15.575 3C15.7917 3 15.9708 3.07083 16.1125 3.2125C16.2542 3.35417 16.325 3.53333 16.325 3.75V5.05H20.25C20.4667 5.05 20.6458 5.12083 20.7875 5.2625C20.9292 5.40417 21 5.58333 21 5.8C21 6.01667 20.9292 6.19583 20.7875 6.3375C20.6458 6.47917 20.4667 6.55 20.25 6.55H16.325V7.875C16.325 8.09167 16.2542 8.27083 16.1125 8.4125C15.9708 8.55417 15.7917 8.625 15.575 8.625Z"
fill="#212529"
/>
</svg>
);

View File

@ -1,59 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const UnstartedStateIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
color = "rgb(var(--color-text-200))",
}) => (
<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>
);

View File

@ -1,17 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const UpcomingCycleIcon: React.FC<Props> = ({
width = "24",
height = "24",
className,
color = "black",
}) => (
<svg xmlns="http://www.w3.org/2000/svg" height={height} width={width} className={className}>
<path
d="M28.3 44v-3H39V19.5H9v11H6V10q0-1.2.9-2.1Q7.8 7 9 7h3.25V4h3.25v3h17V4h3.25v3H39q1.2 0 2.1.9.9.9.9 2.1v31q0 1.2-.9 2.1-.9.9-2.1.9ZM16 47.3l-2.1-2.1 5.65-5.7H2.5v-3h17.05l-5.65-5.7 2.1-2.1 9.3 9.3ZM9 16.5h30V10H9Zm0 0V10v6.5Z"
fill={color}
/>
</svg>
);

View File

@ -1,16 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const UserCircleIcon: 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="M5.55 17.625C6.6 16.8917 7.64167 16.3292 8.675 15.9375C9.70833 15.5458 10.8167 15.35 12 15.35C13.1833 15.35 14.2958 15.5458 15.3375 15.9375C16.3792 16.3292 17.425 16.8917 18.475 17.625C19.2083 16.725 19.7292 15.8167 20.0375 14.9C20.3458 13.9833 20.5 13.0167 20.5 12C20.5 9.58333 19.6875 7.5625 18.0625 5.9375C16.4375 4.3125 14.4167 3.5 12 3.5C9.58333 3.5 7.5625 4.3125 5.9375 5.9375C4.3125 7.5625 3.5 9.58333 3.5 12C3.5 13.0167 3.65833 13.9833 3.975 14.9C4.29167 15.8167 4.81667 16.725 5.55 17.625ZM12 12.75C11.0333 12.75 10.2208 12.4208 9.5625 11.7625C8.90417 11.1042 8.575 10.2917 8.575 9.325C8.575 8.35833 8.90417 7.54583 9.5625 6.8875C10.2208 6.22917 11.0333 5.9 12 5.9C12.9667 5.9 13.7792 6.22917 14.4375 6.8875C15.0958 7.54583 15.425 8.35833 15.425 9.325C15.425 10.2917 15.0958 11.1042 14.4375 11.7625C13.7792 12.4208 12.9667 12.75 12 12.75ZM12 22C10.6333 22 9.34167 21.7375 8.125 21.2125C6.90833 20.6875 5.84583 19.9708 4.9375 19.0625C4.02917 18.1542 3.3125 17.0917 2.7875 15.875C2.2625 14.6583 2 13.3667 2 12C2 10.6167 2.2625 9.32083 2.7875 8.1125C3.3125 6.90417 4.02917 5.84583 4.9375 4.9375C5.84583 4.02917 6.90833 3.3125 8.125 2.7875C9.34167 2.2625 10.6333 2 12 2C13.3833 2 14.6792 2.2625 15.8875 2.7875C17.0958 3.3125 18.1542 4.02917 19.0625 4.9375C19.9708 5.84583 20.6875 6.90417 21.2125 8.1125C21.7375 9.32083 22 10.6167 22 12C22 13.3667 21.7375 14.6583 21.2125 15.875C20.6875 17.0917 19.9708 18.1542 19.0625 19.0625C18.1542 19.9708 17.0958 20.6875 15.8875 21.2125C14.6792 21.7375 13.3833 22 12 22ZM12 20.5C12.9167 20.5 13.8125 20.3667 14.6875 20.1C15.5625 19.8333 16.425 19.3667 17.275 18.7C16.425 18.1 15.5583 17.6417 14.675 17.325C13.7917 17.0083 12.9 16.85 12 16.85C11.1 16.85 10.2083 17.0083 9.325 17.325C8.44167 17.6417 7.575 18.1 6.725 18.7C7.575 19.3667 8.4375 19.8333 9.3125 20.1C10.1875 20.3667 11.0833 20.5 12 20.5ZM12 11.25C12.5667 11.25 13.0292 11.0708 13.3875 10.7125C13.7458 10.3542 13.925 9.89167 13.925 9.325C13.925 8.75833 13.7458 8.29583 13.3875 7.9375C13.0292 7.57917 12.5667 7.4 12 7.4C11.4333 7.4 10.9708 7.57917 10.6125 7.9375C10.2542 8.29583 10.075 8.75833 10.075 9.325C10.075 9.89167 10.2542 10.3542 10.6125 10.7125C10.9708 11.0708 11.4333 11.25 12 11.25Z" />
</svg>
);

View File

@ -1,19 +0,0 @@
import React from "react";
import type { Props } from "./types";
export const UserIcon: 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="M12 11.9751C10.9 11.9751 10 11.6251 9.3 10.9251C8.6 10.2251 8.25 9.3251 8.25 8.2251C8.25 7.1251 8.6 6.2251 9.3 5.5251C10 4.8251 10.9 4.4751 12 4.4751C13.1 4.4751 14 4.8251 14.7 5.5251C15.4 6.2251 15.75 7.1251 15.75 8.2251C15.75 9.3251 15.4 10.2251 14.7 10.9251C14 11.6251 13.1 11.9751 12 11.9751ZM18.5 20.0001H5.5C5.08333 20.0001 4.72917 19.8543 4.4375 19.5626C4.14583 19.2709 4 18.9168 4 18.5001V17.6501C4 17.0168 4.15833 16.4751 4.475 16.0251C4.79167 15.5751 5.2 15.2334 5.7 15.0001C6.81667 14.5001 7.8875 14.1251 8.9125 13.8751C9.9375 13.6251 10.9667 13.5001 12 13.5001C13.0333 13.5001 14.0583 13.6293 15.075 13.8876C16.0917 14.1459 17.1583 14.5168 18.275 15.0001C18.7917 15.2334 19.2083 15.5751 19.525 16.0251C19.8417 16.4751 20 17.0168 20 17.6501V18.5001C20 18.9168 19.8542 19.2709 19.5625 19.5626C19.2708 19.8543 18.9167 20.0001 18.5 20.0001ZM5.5 18.5001H18.5V17.6501C18.5 17.3834 18.4208 17.1293 18.2625 16.8876C18.1042 16.6459 17.9083 16.4668 17.675 16.3501C16.6083 15.8334 15.6333 15.4793 14.75 15.2876C13.8667 15.0959 12.95 15.0001 12 15.0001C11.05 15.0001 10.125 15.0959 9.225 15.2876C8.325 15.4793 7.35 15.8334 6.3 16.3501C6.06667 16.4668 5.875 16.6459 5.725 16.8876C5.575 17.1293 5.5 17.3834 5.5 17.6501V18.5001ZM12 10.4751C12.65 10.4751 13.1875 10.2626 13.6125 9.8376C14.0375 9.4126 14.25 8.8751 14.25 8.2251C14.25 7.5751 14.0375 7.0376 13.6125 6.6126C13.1875 6.1876 12.65 5.9751 12 5.9751C11.35 5.9751 10.8125 6.1876 10.3875 6.6126C9.9625 7.0376 9.75 7.5751 9.75 8.2251C9.75 8.8751 9.9625 9.4126 10.3875 9.8376C10.8125 10.2626 11.35 10.4751 12 10.4751Z"
fill="#212529"
/>
</svg>
);

View File

@ -1 +0,0 @@
export * from "./select-channel";

View File

@ -1,80 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
// react-hook-form
import { useForm, Controller } from "react-hook-form";
// components
import { SecondaryButton } from "components/ui";
import { TipTapEditor } from "components/tiptap";
// types
import type { IIssueComment } from "types";
const defaultValues: Partial<IIssueComment> = {
comment_json: "",
comment_html: "",
};
type Props = {
disabled?: boolean;
onSubmit: (data: IIssueComment) => Promise<void>;
};
export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit }) => {
const {
control,
formState: { isSubmitting },
handleSubmit,
reset,
setValue,
watch,
} = useForm<IIssueComment>({ defaultValues });
const editorRef = React.useRef<any>(null);
const router = useRouter();
const { workspaceSlug } = router.query;
const handleAddComment = async (formData: IIssueComment) => {
if (!formData.comment_html || !formData.comment_json || isSubmitting) return;
await onSubmit(formData).then(() => {
reset(defaultValues);
editorRef.current?.clearEditor();
});
};
return (
<div>
<form onSubmit={handleSubmit(handleAddComment)}>
<div className="issue-comments-section">
<Controller
name="comment_html"
control={control}
render={({ field: { value, onChange } }) => (
<TipTapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
value={
!value ||
value === "" ||
(typeof value === "object" && Object.keys(value).length === 0)
? watch("comment_html")
: value
}
customClassName="p-3 min-h-[50px] shadow-sm"
debouncedUpdatesEnabled={false}
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
setValue("comment_json", comment_json);
}}
/>
)}
/>
<SecondaryButton type="submit" disabled={isSubmitting || disabled} className="mt-2">
{isSubmitting ? "Adding..." : "Comment"}
</SecondaryButton>
</div>
</form>
</div>
);
};

View File

@ -1,44 +0,0 @@
import { useRouter } from "next/router";
// hooks
import useIssuesView from "hooks/use-issues-view";
import useUser from "hooks/use-user";
import useGanttChartIssues from "hooks/gantt-chart/issue-view";
import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
// components
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
// types
import { IIssue } from "types";
export const IssueGanttChartView = () => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { orderBy } = useIssuesView();
const { user } = useUser();
const { ganttIssues, mutateGanttIssues } = useGanttChartIssues(
workspaceSlug as string,
projectId as string
);
return (
<div className="w-full h-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}
BlockRender={IssueGanttBlock}
SidebarBlockRender={IssueGanttSidebarBlock}
enableReorder={orderBy === "sort_order"}
bottomSpacing
/>
</div>
);
};

View File

@ -1,79 +0,0 @@
import {
PeekOverviewHeader,
PeekOverviewIssueActivity,
PeekOverviewIssueDetails,
PeekOverviewIssueProperties,
TPeekOverviewModes,
} from "components/issues";
import { IIssue } from "types";
type Props = {
handleClose: () => void;
handleDeleteIssue: () => void;
handleUpdateIssue: (issue: Partial<IIssue>) => Promise<void>;
issue: IIssue;
mode: TPeekOverviewModes;
readOnly: boolean;
setMode: (mode: TPeekOverviewModes) => void;
workspaceSlug: string;
};
export const FullScreenPeekView: React.FC<Props> = ({
handleClose,
handleDeleteIssue,
handleUpdateIssue,
issue,
mode,
readOnly,
setMode,
workspaceSlug,
}) => (
<div className="h-full w-full grid grid-cols-10 divide-x divide-custom-border-200 overflow-hidden">
<div className="h-full w-full flex flex-col col-span-7 overflow-hidden">
<div className="w-full p-5">
<PeekOverviewHeader
handleClose={handleClose}
handleDeleteIssue={handleDeleteIssue}
issue={issue}
mode={mode}
setMode={setMode}
workspaceSlug={workspaceSlug}
/>
</div>
<div className="h-full w-full px-6 overflow-y-auto">
{/* issue title and description */}
<div className="w-full">
<PeekOverviewIssueDetails
handleUpdateIssue={handleUpdateIssue}
issue={issue}
readOnly={readOnly}
workspaceSlug={workspaceSlug}
/>
</div>
{/* divider */}
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
{/* issue activity/comments */}
<div className="w-full">
<PeekOverviewIssueActivity
workspaceSlug={workspaceSlug}
issue={issue}
readOnly={readOnly}
/>
</div>
</div>
</div>
<div className="col-span-3 h-full w-full overflow-y-auto">
{/* issue properties */}
<div className="w-full px-6 py-5">
<PeekOverviewIssueProperties
handleDeleteIssue={handleDeleteIssue}
issue={issue}
mode="full"
onChange={handleUpdateIssue}
readOnly={readOnly}
workspaceSlug={workspaceSlug}
/>
</div>
</div>
</div>
);

View File

@ -1,107 +0,0 @@
import React, { useState } from "react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
import { FullScreenPeekView, SidePeekView } from "components/issues";
// types
import { IIssue } from "types";
type Props = {
handleDeleteIssue: () => void;
handleUpdateIssue: (issue: Partial<IIssue>) => Promise<void>;
issue: IIssue | null;
isOpen: boolean;
onClose: () => void;
workspaceSlug: string;
readOnly: boolean;
};
export type TPeekOverviewModes = "side" | "modal" | "full";
export const IssuePeekOverview: React.FC<Props> = ({
handleDeleteIssue,
handleUpdateIssue,
issue,
isOpen,
onClose,
workspaceSlug,
readOnly,
}) => {
const [peekOverviewMode, setPeekOverviewMode] = useState<TPeekOverviewModes>("side");
const handleClose = () => {
onClose();
setPeekOverviewMode("side");
};
if (!issue || !isOpen) return null;
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
{/* add backdrop conditionally */}
{(peekOverviewMode === "modal" || peekOverviewMode === "full") && (
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
)}
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="relative h-full w-full">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
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={`absolute z-20 bg-custom-background-100 ${
peekOverviewMode === "side"
? "top-0 right-0 h-full w-1/2 shadow-custom-shadow-md"
: peekOverviewMode === "modal"
? "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[70%] w-3/5 rounded-lg shadow-custom-shadow-xl"
: "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[95%] w-[95%] rounded-lg shadow-custom-shadow-xl"
}`}
>
{(peekOverviewMode === "side" || peekOverviewMode === "modal") && (
<SidePeekView
handleClose={handleClose}
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode={peekOverviewMode}
readOnly={readOnly}
setMode={(mode) => setPeekOverviewMode(mode)}
workspaceSlug={workspaceSlug}
/>
)}
{peekOverviewMode === "full" && (
<FullScreenPeekView
handleClose={handleClose}
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode={peekOverviewMode}
readOnly={readOnly}
setMode={(mode) => setPeekOverviewMode(mode)}
workspaceSlug={workspaceSlug}
/>
)}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -1,75 +0,0 @@
import {
PeekOverviewHeader,
PeekOverviewIssueActivity,
PeekOverviewIssueDetails,
PeekOverviewIssueProperties,
TPeekOverviewModes,
} from "components/issues";
import { IIssue } from "types";
type Props = {
handleClose: () => void;
handleDeleteIssue: () => void;
handleUpdateIssue: (issue: Partial<IIssue>) => Promise<void>;
issue: IIssue;
mode: TPeekOverviewModes;
readOnly: boolean;
setMode: (mode: TPeekOverviewModes) => void;
workspaceSlug: string;
};
export const SidePeekView: React.FC<Props> = ({
handleClose,
handleDeleteIssue,
handleUpdateIssue,
issue,
mode,
readOnly,
setMode,
workspaceSlug,
}) => (
<div className="h-full w-full flex flex-col overflow-hidden">
<div className="w-full p-5">
<PeekOverviewHeader
handleClose={handleClose}
handleDeleteIssue={handleDeleteIssue}
issue={issue}
mode={mode}
setMode={setMode}
workspaceSlug={workspaceSlug}
/>
</div>
<div className="h-full w-full px-6 overflow-y-auto">
{/* issue title and description */}
<div className="w-full">
<PeekOverviewIssueDetails
handleUpdateIssue={handleUpdateIssue}
issue={issue}
readOnly={readOnly}
workspaceSlug={workspaceSlug}
/>
</div>
{/* issue properties */}
<div className="w-full mt-10">
<PeekOverviewIssueProperties
handleDeleteIssue={handleDeleteIssue}
issue={issue}
mode={mode}
onChange={handleUpdateIssue}
readOnly={readOnly}
workspaceSlug={workspaceSlug}
/>
</div>
{/* divider */}
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
{/* issue activity/comments */}
<div className="w-full pb-5">
<PeekOverviewIssueActivity
workspaceSlug={workspaceSlug}
issue={issue}
readOnly={readOnly}
/>
</div>
</div>
</div>
);

View File

@ -1,121 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// services
import issuesService from "services/issues.service";
import cyclesService from "services/cycles.service";
// ui
import { Spinner, CustomSelect, Tooltip } from "components/ui";
// helper
import { truncateText } from "helpers/string.helper";
// icons
import { ContrastIcon } from "components/icons";
// types
import { ICycle, IIssue, UserAuth } from "types";
// fetch-keys
import { CYCLE_ISSUES, INCOMPLETE_CYCLES_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
type Props = {
issueDetail: IIssue | undefined;
handleCycleChange: (cycle: ICycle) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarCycleSelect: React.FC<Props> = ({
issueDetail,
handleCycleChange,
userAuth,
disabled = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: incompleteCycles } = useSWR(
workspaceSlug && projectId ? INCOMPLETE_CYCLES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () =>
cyclesService.getCyclesWithParams(
workspaceSlug as string,
projectId as string,
"incomplete"
)
: null
);
const removeIssueFromCycle = (bridgeId: string, cycleId: string) => {
if (!workspaceSlug || !projectId) return;
issuesService
.removeIssueFromCycle(workspaceSlug as string, projectId as string, cycleId, bridgeId)
.then((res) => {
mutate(ISSUE_DETAILS(issueId as string));
mutate(CYCLE_ISSUES(cycleId));
})
.catch((e) => {
console.log(e);
});
};
const issueCycle = issueDetail?.issue_cycle;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<ContrastIcon className="h-4 w-4 flex-shrink-0" />
<p>Cycle</p>
</div>
<div className="space-y-1 sm:basis-1/2">
<CustomSelect
label={
<Tooltip
position="left"
tooltipContent={`${issueCycle ? issueCycle.cycle_detail.name : "No cycle"}`}
>
<span className="w-full max-w-[125px] truncate text-left sm:block">
<span className={`${issueCycle ? "text-custom-text-100" : "text-custom-text-200"}`}>
{issueCycle ? truncateText(issueCycle.cycle_detail.name, 15) : "No cycle"}
</span>
</span>
</Tooltip>
}
value={issueCycle ? issueCycle.cycle_detail.id : null}
onChange={(value: any) => {
!value
? removeIssueFromCycle(issueCycle?.id ?? "", issueCycle?.cycle ?? "")
: handleCycleChange(incompleteCycles?.find((c) => c.id === value) as ICycle);
}}
width="w-full"
position="right"
maxHeight="rg"
disabled={isNotAllowed}
>
{incompleteCycles ? (
incompleteCycles.length > 0 ? (
<>
{incompleteCycles.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}>
<Tooltip position="left-bottom" tooltipContent={option.name}>
<span className="w-full truncate">{truncateText(option.name, 25)}</span>
</Tooltip>
</CustomSelect.Option>
))}
<CustomSelect.Option value={null}>None</CustomSelect.Option>
</>
) : (
<div className="text-center">No cycles found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
</div>
</div>
);
};

View File

@ -1,122 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// services
import modulesService from "services/modules.service";
// ui
import { Spinner, CustomSelect, Tooltip } from "components/ui";
// helper
import { truncateText } from "helpers/string.helper";
// icons
import { RectangleGroupIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IModule, UserAuth } from "types";
// fetch-keys
import { ISSUE_DETAILS, MODULE_ISSUES, MODULE_LIST } from "constants/fetch-keys";
type Props = {
issueDetail: IIssue | undefined;
handleModuleChange: (module: IModule) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarModuleSelect: React.FC<Props> = ({
issueDetail,
handleModuleChange,
userAuth,
disabled = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: modules } = useSWR(
workspaceSlug && projectId ? MODULE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => modulesService.getModules(workspaceSlug as string, projectId as string)
: null
);
const removeIssueFromModule = (bridgeId: string, moduleId: string) => {
if (!workspaceSlug || !projectId) return;
modulesService
.removeIssueFromModule(workspaceSlug as string, projectId as string, moduleId, bridgeId)
.then((res) => {
mutate(ISSUE_DETAILS(issueId as string));
mutate(MODULE_ISSUES(moduleId));
})
.catch((e) => {
console.log(e);
});
};
const issueModule = issueDetail?.issue_module;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<RectangleGroupIcon className="h-4 w-4 flex-shrink-0" />
<p>Module</p>
</div>
<div className="space-y-1 sm:basis-1/2">
<CustomSelect
label={
<Tooltip
position="left"
tooltipContent={`${
modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module"
}`}
>
<span className="w-full max-w-[125px] truncate text-left sm:block">
<span
className={`${issueModule ? "text-custom-text-100" : "text-custom-text-200"}`}
>
{truncateText(
`${modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module"}`,
15
)}
</span>
</span>
</Tooltip>
}
value={issueModule ? issueModule.module_detail?.id : null}
onChange={(value: any) => {
!value
? removeIssueFromModule(issueModule?.id ?? "", issueModule?.module ?? "")
: handleModuleChange(modules?.find((m) => m.id === value) as IModule);
}}
width="w-full"
position="right"
maxHeight="rg"
disabled={isNotAllowed}
>
{modules ? (
modules.length > 0 ? (
<>
{modules.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}>
<Tooltip position="left-bottom" tooltipContent={option.name}>
<span className="w-full truncate">{truncateText(option.name, 25)}</span>
</Tooltip>
</CustomSelect.Option>
))}
<CustomSelect.Option value={null}>None</CustomSelect.Option>
</>
) : (
<div className="text-center">No modules found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
</div>
</div>
);
};

View File

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

View File

@ -1,56 +0,0 @@
import React from "react";
// ui
import { CustomMenu } from "components/ui";
// types
import { IIssueLabels } from "types";
//icons
import { RectangleGroupIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
type Props = {
label: IIssueLabels;
addLabelToGroup: (parentLabel: IIssueLabels) => void;
editLabel: (label: IIssueLabels) => void;
handleLabelDelete: () => void;
};
export const SingleLabel: React.FC<Props> = ({
label,
addLabelToGroup,
editLabel,
handleLabelDelete,
}) => (
<div className="gap-2 space-y-3 divide-y divide-custom-border-200 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span
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>
</div>
<CustomMenu ellipsis>
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
<span className="flex items-center justify-start gap-2">
<RectangleGroupIcon className="h-4 w-4" />
<span>Convert to group</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => editLabel(label)}>
<span className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
<span>Edit label</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleLabelDelete}>
<span className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete label</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
);

View File

@ -1,49 +0,0 @@
import { FC } from "react";
import { useRouter } from "next/router";
// hooks
import useIssuesView from "hooks/use-issues-view";
import useUser from "hooks/use-user";
import useGanttChartModuleIssues from "hooks/gantt-chart/module-issues-view";
import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
// components
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
// types
import { IIssue } from "types";
type Props = {};
export const ModuleIssuesGanttChartView: FC<Props> = ({}) => {
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
const { orderBy } = useIssuesView();
const { user } = useUser();
const { ganttIssues, mutateGanttIssues } = useGanttChartModuleIssues(
workspaceSlug as string,
projectId as string,
moduleId as string
);
return (
<div className="w-full h-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}
SidebarBlockRender={IssueGanttSidebarBlock}
BlockRender={IssueGanttBlock}
enableReorder={orderBy === "sort_order"}
bottomSpacing
/>
</div>
);
};

View File

@ -1,474 +0,0 @@
import React, { useEffect } from "react";
// next imports
import { useRouter } from "next/router";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui components
import { ToggleSwitch, PrimaryButton, SecondaryButton } from "components/ui";
import { CustomPopover } from "./popover";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
import { IProjectPublishSettingsViews } from "store/project-publish";
// hooks
import useToast from "hooks/use-toast";
import useProjectDetails from "hooks/use-project-details";
type Props = {
// user: ICurrentUserResponse | undefined;
};
const defaultValues: Partial<any> = {
id: null,
comments: false,
reactions: false,
votes: false,
inbox: null,
views: ["list", "kanban"],
};
const viewOptions = [
{ key: "list", value: "List" },
{ key: "kanban", value: "Kanban" },
// { key: "calendar", value: "Calendar" },
// { key: "gantt", value: "Gantt" },
// { key: "spreadsheet", value: "Spreadsheet" },
];
export const PublishProjectModal: React.FC<Props> = observer(() => {
const store: RootStore = useMobxStore();
const { projectPublish } = store;
const { projectDetails, mutateProjectDetails } = useProjectDetails();
const { setToastAlert } = useToast();
const handleToastAlert = (title: string, type: string, message: string) => {
setToastAlert({
title: title || "Title",
type: "error" || "warning",
message: message || "Message",
});
};
const { NEXT_PUBLIC_DEPLOY_URL } = process.env;
const plane_deploy_url = NEXT_PUBLIC_DEPLOY_URL
? NEXT_PUBLIC_DEPLOY_URL
: "http://localhost:3001";
const router = useRouter();
const { workspaceSlug } = router.query;
const {
formState: { errors, isSubmitting },
handleSubmit,
reset,
watch,
setValue,
} = useForm<any>({
defaultValues,
reValidateMode: "onChange",
});
const handleClose = () => {
projectPublish.handleProjectModal(null);
reset({ ...defaultValues });
};
useEffect(() => {
if (
projectPublish.projectPublishSettings &&
projectPublish.projectPublishSettings != "not-initialized"
) {
let userBoards: string[] = [];
if (projectPublish.projectPublishSettings?.views) {
const _views: IProjectPublishSettingsViews | null =
projectPublish.projectPublishSettings?.views || null;
if (_views != null) {
if (_views.list) userBoards.push("list");
if (_views.kanban) userBoards.push("kanban");
if (_views.calendar) userBoards.push("calendar");
if (_views.gantt) userBoards.push("gantt");
if (_views.spreadsheet) userBoards.push("spreadsheet");
userBoards = userBoards && userBoards.length > 0 ? userBoards : ["list"];
}
}
const updatedData = {
id: projectPublish.projectPublishSettings?.id || null,
comments: projectPublish.projectPublishSettings?.comments || false,
reactions: projectPublish.projectPublishSettings?.reactions || false,
votes: projectPublish.projectPublishSettings?.votes || false,
inbox: projectPublish.projectPublishSettings?.inbox || null,
views: userBoards,
};
reset({ ...updatedData });
}
}, [reset, projectPublish.projectPublishSettings]);
useEffect(() => {
if (
projectPublish.projectPublishModal &&
workspaceSlug &&
projectPublish.project_id != null &&
projectPublish?.projectPublishSettings === "not-initialized"
) {
projectPublish.getProjectSettingsAsync(
workspaceSlug as string,
projectPublish.project_id as string,
null
);
}
}, [workspaceSlug, projectPublish, projectPublish.projectPublishModal]);
const onSettingsPublish = async (formData: any) => {
if (formData.views && formData.views.length > 0) {
const payload = {
comments: formData.comments || false,
reactions: formData.reactions || false,
votes: formData.votes || false,
inbox: formData.inbox || null,
views: {
list: formData.views.includes("list") || false,
kanban: formData.views.includes("kanban") || false,
calendar: formData.views.includes("calendar") || false,
gantt: formData.views.includes("gantt") || false,
spreadsheet: formData.views.includes("spreadsheet") || false,
},
};
const _workspaceSlug = workspaceSlug;
const _projectId = projectPublish.project_id;
return projectPublish
.createProjectSettingsAsync(_workspaceSlug as string, _projectId as string, payload, null)
.then((response) => {
mutateProjectDetails();
handleClose();
console.log("_projectId", _projectId);
if (_projectId)
window.open(`${plane_deploy_url}/${_workspaceSlug}/${_projectId}`, "_blank");
return response;
})
.catch((error) => {
console.error("error", error);
return error;
});
} else {
handleToastAlert("Missing fields", "warning", "Please select at least one view to publish");
}
};
const onSettingsUpdate = async (key: string, value: any) => {
const payload = {
comments: key === "comments" ? value : watch("comments"),
reactions: key === "reactions" ? value : watch("reactions"),
votes: key === "votes" ? value : watch("votes"),
inbox: key === "inbox" ? value : watch("inbox"),
views:
key === "views"
? {
list: value.includes("list") ? true : false,
kanban: value.includes("kanban") ? true : false,
calendar: value.includes("calendar") ? true : false,
gantt: value.includes("gantt") ? true : false,
spreadsheet: value.includes("spreadsheet") ? true : false,
}
: {
list: watch("views").includes("list") ? true : false,
kanban: watch("views").includes("kanban") ? true : false,
calendar: watch("views").includes("calendar") ? true : false,
gantt: watch("views").includes("gantt") ? true : false,
spreadsheet: watch("views").includes("spreadsheet") ? true : false,
},
};
return projectPublish
.updateProjectSettingsAsync(
workspaceSlug as string,
projectPublish.project_id as string,
watch("id"),
payload,
null
)
.then((response) => {
mutateProjectDetails();
return response;
})
.catch((error) => {
console.log("error", error);
return error;
});
};
const onSettingsUnPublish = async (formData: any) =>
projectPublish
.deleteProjectSettingsAsync(
workspaceSlug as string,
projectPublish.project_id as string,
formData?.id,
null
)
.then((response) => {
mutateProjectDetails();
reset({ ...defaultValues });
handleClose();
return response;
})
.catch((error) => {
console.error("error", error);
return error;
});
const CopyLinkToClipboard = ({ copy_link }: { copy_link: string }) => {
const [status, setStatus] = React.useState(false);
const copyText = () => {
navigator.clipboard.writeText(copy_link);
setStatus(true);
setTimeout(() => {
setStatus(false);
}, 1000);
};
return (
<div
className="border border-custom-border-100 bg-custom-background-100 text-xs px-2 min-w-[30px] h-[30px] rounded flex justify-center items-center hover:bg-custom-background-90 cursor-pointer"
onClick={() => copyText()}
>
{status ? "Copied" : "Copy Link"}
</div>
);
};
return (
<Transition.Root show={projectPublish.projectPublishModal} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-100"
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="transform rounded-lg bg-custom-background-100 border border-custom-border-100 text-left shadow-xl transition-all w-full sm:w-3/5 lg:w-1/2 xl:w-2/5 space-y-4">
{/* heading */}
<div className="p-3 px-4 pb-0 flex gap-2 justify-between items-center">
<div className="font-medium text-xl">Publish</div>
{projectPublish.loader && (
<div className="text-xs text-custom-text-400">Changes saved</div>
)}
<div
className="hover:bg-custom-background-90 w-[30px] h-[30px] rounded flex justify-center items-center cursor-pointer transition-all"
onClick={handleClose}
>
<span className="material-symbols-rounded text-[16px]">close</span>
</div>
</div>
{/* content */}
<div className="space-y-3">
{watch("id") && (
<div className="flex items-center gap-1 px-4 text-custom-primary-100">
<div className="w-[20px] h-[20px] overflow-hidden flex items-center">
<span className="material-symbols-rounded text-[18px]">
radio_button_checked
</span>
</div>
<div className="text-sm">This project is live on web</div>
</div>
)}
<div className="mx-4 border border-custom-border-100 bg-custom-background-90 rounded p-3 py-2 relative flex gap-2 items-center">
<div className="relative line-clamp-1 overflow-hidden w-full text-sm">
{`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`}
</div>
<div className="flex-shrink-0 relative flex items-center gap-1">
<a
href={`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`}
target="_blank"
rel="noreferrer"
>
<div className="border border-custom-border-100 bg-custom-background-100 w-[30px] h-[30px] rounded flex justify-center items-center hover:bg-custom-background-90 cursor-pointer">
<span className="material-symbols-rounded text-[16px]">open_in_new</span>
</div>
</a>
<CopyLinkToClipboard
copy_link={`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`}
/>
</div>
</div>
<div className="space-y-3 px-4">
<div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Views</div>
<div>
<CustomPopover
label={
watch("views") && watch("views").length > 0
? viewOptions
.filter(
(_view) => watch("views").includes(_view.key) && _view.value
)
.map((_view) => _view.value)
.join(", ")
: ``
}
placeholder="Select views"
>
<>
{viewOptions &&
viewOptions.length > 0 &&
viewOptions.map((_view) => (
<div
key={_view.value}
className={`relative flex items-center gap-2 justify-between p-1 m-1 px-2 cursor-pointer rounded-sm text-custom-text-200 ${
watch("views").includes(_view.key)
? `bg-custom-background-80 text-custom-text-100`
: `hover:bg-custom-background-80 hover:text-custom-text-100`
}`}
onClick={() => {
const _views =
watch("views") && watch("views").length > 0
? watch("views").includes(_view?.key)
? watch("views").filter((_o: string) => _o !== _view?.key)
: [...watch("views"), _view?.key]
: [_view?.key];
setValue("views", _views);
if (watch("id") != null) onSettingsUpdate("views", _views);
}}
>
<div className="text-sm">{_view.value}</div>
<div
className={`w-[18px] h-[18px] relative flex justify-center items-center`}
>
{watch("views") &&
watch("views").length > 0 &&
watch("views").includes(_view.key) && (
<span className="material-symbols-rounded text-[18px]">
done
</span>
)}
</div>
</div>
))}
</>
</CustomPopover>
</div>
</div>
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow comments</div>
<div>
<ToggleSwitch
value={watch("comments") ?? false}
onChange={() => {
const _comments = !watch("comments");
setValue("comments", _comments);
if (watch("id") != null) onSettingsUpdate("comments", _comments);
}}
size="sm"
/>
</div>
</div> */}
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow reactions</div>
<div>
<ToggleSwitch
value={watch("reactions") ?? false}
onChange={() => {
const _reactions = !watch("reactions");
setValue("reactions", _reactions);
if (watch("id") != null) onSettingsUpdate("reactions", _reactions);
}}
size="sm"
/>
</div>
</div> */}
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow Voting</div>
<div>
<ToggleSwitch
value={watch("votes") ?? false}
onChange={() => {
const _votes = !watch("votes");
setValue("votes", _votes);
if (watch("id") != null) onSettingsUpdate("votes", _votes);
}}
size="sm"
/>
</div>
</div> */}
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow issue proposals</div>
<div>
<ToggleSwitch
value={watch("inbox") ?? false}
onChange={() => {
setValue("inbox", !watch("inbox"));
}}
size="sm"
/>
</div>
</div> */}
</div>
</div>
{/* modal handlers */}
<div className="border-t border-custom-border-300 p-3 px-4 relative flex justify-between items-center">
<div className="flex items-center gap-1 text-custom-text-300">
<div className="w-[20px] h-[20px] overflow-hidden flex items-center">
<span className="material-symbols-rounded text-[18px]">public</span>
</div>
<div className="text-sm">Anyone with the link can access</div>
</div>
<div className="relative flex items-center gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
{watch("id") != null ? (
<PrimaryButton
outline
onClick={handleSubmit(onSettingsUnPublish)}
disabled={isSubmitting}
>
{isSubmitting ? "Unpublishing..." : "Unpublish"}
</PrimaryButton>
) : (
<PrimaryButton
onClick={handleSubmit(onSettingsPublish)}
disabled={isSubmitting}
>
{isSubmitting ? "Publishing..." : "Publish"}
</PrimaryButton>
)}
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@ -1,13 +0,0 @@
import SettingsNavbar from "layouts/settings-navbar";
export const SettingsHeader = () => (
<div className="mb-8 space-y-6">
<div>
<h3 className="text-2xl font-semibold">Project Settings</h3>
<p className="mt-1 text-sm text-custom-text-200">
This information will be displayed to every member of the project.
</p>
</div>
<SettingsNavbar />
</div>
);

View File

@ -1,357 +0,0 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-beautiful-dnd
import { DraggableProvided, DraggableStateSnapshot } from "react-beautiful-dnd";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// services
import projectService from "services/project.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { CustomMenu, Icon, Tooltip } from "components/ui";
// icons
import { EllipsisVerticalIcon, LinkIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
import {
ArchiveOutlined,
ArticleOutlined,
ContrastOutlined,
DatasetOutlined,
ExpandMoreOutlined,
FilterNoneOutlined,
PhotoFilterOutlined,
SettingsOutlined,
} from "@mui/icons-material";
// helpers
import { truncateText } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
// types
import { IProject } from "types";
// fetch-keys
import { PROJECTS_LIST } from "constants/fetch-keys";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
type Props = {
project: IProject;
sidebarCollapse: boolean;
provided?: DraggableProvided;
snapshot?: DraggableStateSnapshot;
handleDeleteProject: () => void;
handleCopyText: () => void;
shortContextMenu?: boolean;
};
const navigation = (workspaceSlug: string, projectId: string) => [
{
name: "Issues",
href: `/${workspaceSlug}/projects/${projectId}/issues`,
Icon: FilterNoneOutlined,
},
{
name: "Cycles",
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
Icon: ContrastOutlined,
},
{
name: "Modules",
href: `/${workspaceSlug}/projects/${projectId}/modules`,
Icon: DatasetOutlined,
},
{
name: "Views",
href: `/${workspaceSlug}/projects/${projectId}/views`,
Icon: PhotoFilterOutlined,
},
{
name: "Pages",
href: `/${workspaceSlug}/projects/${projectId}/pages`,
Icon: ArticleOutlined,
},
{
name: "Settings",
href: `/${workspaceSlug}/projects/${projectId}/settings`,
Icon: SettingsOutlined,
},
];
export const SingleSidebarProject: React.FC<Props> = observer(
({
project,
sidebarCollapse,
provided,
snapshot,
handleDeleteProject,
handleCopyText,
shortContextMenu = false,
}) => {
const store: RootStore = useMobxStore();
const { projectPublish } = store;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const isAdmin = project.member_role === 20;
const handleAddToFavorites = () => {
if (!workspaceSlug) return;
mutate<IProject[]>(
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
(prevData) =>
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)),
false
);
projectService
.addProjectToFavorites(workspaceSlug as string, {
project: project.id,
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
})
);
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug) return;
mutate<IProject[]>(
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
(prevData) =>
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: false } : p)),
false
);
projectService.removeProjectFromFavorites(workspaceSlug as string, project.id).catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
})
);
};
return (
<Disclosure key={project.id} defaultOpen={projectId === project.id}>
{({ open }) => (
<>
<div
className={`group relative text-custom-sidebar-text-10 px-2 py-1 w-full flex items-center hover:bg-custom-sidebar-background-80 rounded-md ${
snapshot?.isDragging ? "opacity-60" : ""
}`}
>
{provided && (
<Tooltip
tooltipContent={
project.sort_order === null
? "Join the project to rearrange"
: "Drag to rearrange"
}
position="top-right"
>
<button
type="button"
className={`absolute top-1/2 -translate-y-1/2 -left-4 hidden rounded p-0.5 text-custom-sidebar-text-400 ${
sidebarCollapse ? "" : "group-hover:!flex"
} ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""}`}
{...provided?.dragHandleProps}
>
<EllipsisVerticalIcon className="h-4" />
<EllipsisVerticalIcon className="-ml-5 h-4" />
</button>
</Tooltip>
)}
<Tooltip
tooltipContent={`${project.name}`}
position="right"
className="ml-2"
disabled={!sidebarCollapse}
>
<Disclosure.Button
as="div"
className={`flex items-center flex-grow truncate cursor-pointer select-none text-left text-sm font-medium ${
sidebarCollapse ? "justify-center" : `justify-between`
}`}
>
<div
className={`flex items-center flex-grow w-full truncate gap-x-2 ${
sidebarCollapse ? "justify-center" : ""
}`}
>
{project.emoji ? (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
{renderEmoji(project.emoji)}
</span>
) : project.icon_prop ? (
<div className="h-7 w-7 flex-shrink-0 grid place-items-center">
{renderEmoji(project.icon_prop)}
</div>
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{project?.name.charAt(0)}
</span>
)}
{!sidebarCollapse && (
<p className={`truncate ${open ? "" : "text-custom-sidebar-text-200"}`}>
{project.name}
</p>
)}
</div>
{!sidebarCollapse && (
<ExpandMoreOutlined
fontSize="small"
className={`flex-shrink-0 ${
open ? "rotate-180" : ""
} !hidden group-hover:!block text-custom-sidebar-text-400 duration-300`}
/>
)}
</Disclosure.Button>
</Tooltip>
{!sidebarCollapse && (
<CustomMenu
className="hidden group-hover:block flex-shrink-0"
buttonClassName="!text-custom-sidebar-text-400 hover:text-custom-sidebar-text-400"
ellipsis
>
{!shortContextMenu && isAdmin && (
<CustomMenu.MenuItem onClick={handleDeleteProject}>
<span className="flex items-center justify-start gap-2 ">
<TrashIcon className="h-4 w-4" />
<span>Delete project</span>
</span>
</CustomMenu.MenuItem>
)}
{!project.is_favorite && (
<CustomMenu.MenuItem onClick={handleAddToFavorites}>
<span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4" />
<span>Add to favorites</span>
</span>
</CustomMenu.MenuItem>
)}
{project.is_favorite && (
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
<span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy project link</span>
</span>
</CustomMenu.MenuItem>
{/* publish project settings */}
{isAdmin && (
<CustomMenu.MenuItem
onClick={() => projectPublish.handleProjectModal(project?.id)}
>
<div className="flex-shrink-0 relative flex items-center justify-start gap-2">
<div className="rounded transition-all w-4 h-4 flex justify-center items-center text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 duration-300 cursor-pointer">
<span className="material-symbols-rounded text-[16px]">ios_share</span>
</div>
<div>Publish</div>
</div>
{/* <PublishProjectModal /> */}
</CustomMenu.MenuItem>
)}
{project.archive_in > 0 && (
<CustomMenu.MenuItem
onClick={() =>
router.push(`/${workspaceSlug}/projects/${project?.id}/archived-issues/`)
}
>
<div className="flex items-center justify-start gap-2">
<ArchiveOutlined fontSize="small" />
<span>Archived Issues</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={() =>
router.push(`/${workspaceSlug}/projects/${project?.id}/settings`)
}
>
<div className="flex items-center justify-start gap-2">
<Icon iconName="settings" className="!text-base !leading-4" />
<span>Settings</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel
className={`space-y-2 mt-1 ${sidebarCollapse ? "" : "ml-[2.25rem]"}`}
>
{navigation(workspaceSlug as string, project?.id).map((item) => {
if (
(item.name === "Cycles" && !project.cycle_view) ||
(item.name === "Modules" && !project.module_view) ||
(item.name === "Views" && !project.issue_views_view) ||
(item.name === "Pages" && !project.page_view)
)
return;
return (
<Link key={item.name} href={item.href}>
<a className="block w-full">
<Tooltip
tooltipContent={`${project?.name}: ${item.name}`}
position="right"
className="ml-2"
disabled={!sidebarCollapse}
>
<div
className={`group flex items-center rounded-md px-2 py-1.5 gap-2.5 text-xs font-medium outline-none ${
router.asPath.includes(item.href)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
} ${sidebarCollapse ? "justify-center" : ""}`}
>
<item.Icon
sx={{
fontSize: 18,
}}
/>
{!sidebarCollapse && item.name}
</div>
</Tooltip>
</a>
</Link>
);
})}
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
);
}
);

View File

@ -1,137 +0,0 @@
import StarterKit from "@tiptap/starter-kit";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import TiptapLink from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import TiptapUnderline from "@tiptap/extension-underline";
import TextStyle from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { Markdown } from "tiptap-markdown";
import Highlight from "@tiptap/extension-highlight";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { lowlight } from "lowlight/lib/core";
import SlashCommand from "../slash-command";
import { InputRule } from "@tiptap/core";
import ts from "highlight.js/lib/languages/typescript";
import "highlight.js/styles/github-dark.css";
import UniqueID from "@tiptap-pro/extension-unique-id";
import UpdatedImage from "./updated-image";
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
lowlight.registerLanguage("ts", ts);
export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
code: {
HTMLAttributes: {
class:
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
width: 2,
},
gapcursor: false,
}),
CodeBlockLowlight.configure({
lowlight,
}),
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
UpdatedImage.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
return "Press '/' for commands...";
},
includeChildren: true,
}),
UniqueID.configure({
types: ["image"],
}),
SlashCommand(workspaceSlug, setIsSubmitting),
TiptapUnderline,
TextStyle,
Color,
Highlight.configure({
multicolor: true,
}),
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
];

View File

@ -1,56 +0,0 @@
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
import fileService from "services/file.service";
const deleteKey = new PluginKey("delete-image");
const TrackImageDeletionPlugin = () =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions, oldState, newState) => {
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const removedImages: ProseMirrorNode[] = [];
oldState.doc.descendants((oldNode, oldPos) => {
if (oldNode.type.name !== 'image') return;
if (!newState.doc.resolve(oldPos).parent) return;
const newNode = newState.doc.nodeAt(oldPos);
// Check if the node has been deleted or replaced
if (!newNode || newNode.type.name !== 'image') {
// Check if the node still exists elsewhere in the document
let nodeExists = false;
newState.doc.descendants((node) => {
if (node.attrs.id === oldNode.attrs.id) {
nodeExists = true;
}
});
if (!nodeExists) {
removedImages.push(oldNode as ProseMirrorNode);
}
}
});
removedImages.forEach((node) => {
const src = node.attrs.src;
onNodeDeleted(src);
});
});
return null;
},
});
export default TrackImageDeletionPlugin;
async function onNodeDeleted(src: string) {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("Image deleted successfully");
}
}

View File

@ -1,44 +0,0 @@
import { FC } from "react";
import { useRouter } from "next/router";
// hooks
import useGanttChartViewIssues from "hooks/gantt-chart/view-issues-view";
import useUser from "hooks/use-user";
import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
// components
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
// types
import { IIssue } from "types";
type Props = {};
export const ViewIssuesGanttChartView: FC<Props> = ({}) => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const { user } = useUser();
const { ganttIssues, mutateGanttIssues } = useGanttChartViewIssues(
workspaceSlug as string,
projectId as string,
viewId as string
);
return (
<div className="w-full h-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}
SidebarBlockRender={IssueGanttSidebarBlock}
BlockRender={IssueGanttBlock}
/>
</div>
);
};

View File

@ -1,83 +0,0 @@
// components
import { ActivityGraph } from "components/workspace";
// ui
import { Loader, Tooltip } from "components/ui";
// icons
import { InformationCircleIcon } from "@heroicons/react/24/outline";
// types
import { IUserWorkspaceDashboard } from "types";
type Props = {
data: IUserWorkspaceDashboard | undefined;
};
export const IssuesStats: React.FC<Props> = ({ data }) => (
<div className="grid grid-cols-1 rounded-[10px] border border-custom-border-200 bg-custom-background-100 lg:grid-cols-3">
<div className="grid grid-cols-1 divide-y divide-custom-border-200 border-b border-custom-border-200 lg:border-r lg:border-b-0">
<div className="flex">
<div className="basis-1/2 p-4">
<h4 className="text-sm">Issues assigned to you</h4>
<h5 className="mt-2 text-2xl font-semibold">
{data ? (
data.assigned_issues_count
) : (
<Loader>
<Loader.Item height="25px" width="50%" />
</Loader>
)}
</h5>
</div>
<div className="basis-1/2 border-l border-custom-border-200 p-4">
<h4 className="text-sm">Pending issues</h4>
<h5 className="mt-2 text-2xl font-semibold">
{data ? (
data.pending_issues_count
) : (
<Loader>
<Loader.Item height="25px" width="50%" />
</Loader>
)}
</h5>
</div>
</div>
<div className="flex">
<div className="basis-1/2 p-4">
<h4 className="text-sm">Completed issues</h4>
<h5 className="mt-2 text-2xl font-semibold">
{data ? (
data.completed_issues_count
) : (
<Loader>
<Loader.Item height="25px" width="50%" />
</Loader>
)}
</h5>
</div>
<div className="basis-1/2 border-l border-custom-border-200 p-4">
<h4 className="text-sm">Issues due by this week</h4>
<h5 className="mt-2 text-2xl font-semibold">
{data ? (
data.issues_due_week_count
) : (
<Loader>
<Loader.Item height="25px" width="50%" />
</Loader>
)}
</h5>
</div>
</div>
</div>
<div className="p-4 lg:col-span-2">
<h3 className="mb-2 font-semibold capitalize flex items-center gap-2">
Activity Graph
<Tooltip
tooltipContent="Your profile activity graph is a record of actions you've performed on issues across the workspace."
className="w-72 border border-custom-border-200"
>
<InformationCircleIcon className="h-3 w-3" />
</Tooltip>
</h3>
<ActivityGraph activities={data?.issue_activities} />
</div>
</div>
);

View File

@ -1,8 +0,0 @@
export const MODULE_STATUS = [
{ label: "Backlog", value: "backlog", color: "#5e6ad2" },
{ label: "Planned", value: "planned", color: "#26b5ce" },
{ label: "In Progress", value: "in-progress", color: "#f2c94c" },
{ label: "Paused", value: "paused", color: "#ff6900" },
{ label: "Completed", value: "completed", color: "#4cb782" },
{ label: "Cancelled", value: "cancelled", color: "#cc1d10" },
];

View File

@ -1,312 +0,0 @@
import { createContext, useCallback, useReducer } from "react";
// components
import ToastAlert from "components/toast-alert";
// types
import {
IIssueFilterOptions,
TIssueViewOptions,
TIssueGroupByOptions,
TIssueOrderByOptions,
Properties,
} from "types";
export const profileIssuesContext = createContext<ContextType>({} as ContextType);
type IssueViewProps = {
issueView: TIssueViewOptions;
groupByProperty: TIssueGroupByOptions;
orderBy: TIssueOrderByOptions;
showEmptyGroups: boolean;
showSubIssues: boolean;
filters: IIssueFilterOptions;
properties: Properties;
};
type ReducerActionType = {
type:
| "SET_ISSUE_VIEW"
| "SET_ORDER_BY_PROPERTY"
| "SET_SHOW_EMPTY_STATES"
| "SET_FILTERS"
| "SET_PROPERTIES"
| "SET_GROUP_BY_PROPERTY"
| "RESET_TO_DEFAULT"
| "SET_SHOW_SUB_ISSUES";
payload?: Partial<IssueViewProps>;
};
type ContextType = IssueViewProps & {
setGroupByProperty: (property: TIssueGroupByOptions) => void;
setOrderBy: (property: TIssueOrderByOptions) => void;
setShowEmptyGroups: (property: boolean) => void;
setShowSubIssues: (value: boolean) => void;
setFilters: (filters: Partial<IIssueFilterOptions>) => void;
setProperties: (key: keyof Properties) => void;
setIssueView: (property: TIssueViewOptions) => void;
};
type StateType = {
issueView: TIssueViewOptions;
groupByProperty: TIssueGroupByOptions;
orderBy: TIssueOrderByOptions;
showEmptyGroups: boolean;
showSubIssues: boolean;
filters: IIssueFilterOptions;
properties: Properties;
};
type ReducerFunctionType = (state: StateType, action: ReducerActionType) => StateType;
export const initialState: StateType = {
issueView: "list",
groupByProperty: null,
orderBy: "-created_at",
showEmptyGroups: true,
showSubIssues: true,
filters: {
type: null,
priority: null,
assignees: null,
labels: null,
state: null,
state_group: null,
subscriber: null,
created_by: null,
start_date: null,
target_date: null,
},
properties: {
assignee: true,
start_date: true,
due_date: true,
key: true,
labels: true,
priority: true,
state: true,
sub_issue_count: true,
attachment_count: true,
link: true,
estimate: true,
created_on: true,
updated_on: true,
},
};
export const reducer: ReducerFunctionType = (state, action) => {
const { type, payload } = action;
switch (type) {
case "SET_ISSUE_VIEW": {
const newState = {
...state,
issueView: payload?.issueView || "list",
};
return {
...state,
...newState,
};
}
case "SET_GROUP_BY_PROPERTY": {
const newState = {
...state,
groupByProperty: payload?.groupByProperty || null,
};
return {
...state,
...newState,
};
}
case "SET_ORDER_BY_PROPERTY": {
const newState = {
...state,
orderBy: payload?.orderBy || "-created_at",
};
return {
...state,
...newState,
};
}
case "SET_SHOW_EMPTY_STATES": {
const newState = {
...state,
showEmptyGroups: payload?.showEmptyGroups || true,
};
return {
...state,
...newState,
};
}
case "SET_SHOW_SUB_ISSUES": {
const newState = {
...state,
showSubIssues: payload?.showSubIssues || true,
};
return {
...state,
...newState,
};
}
case "SET_FILTERS": {
const newState = {
...state,
filters: {
...state.filters,
...payload?.filters,
},
};
return {
...state,
...newState,
};
}
case "SET_PROPERTIES": {
const newState = {
...state,
properties: {
...state.properties,
...payload?.properties,
},
};
return {
...state,
...newState,
};
}
default: {
return state;
}
}
};
export const ProfileIssuesContextProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [state, dispatch] = useReducer(reducer, initialState);
const setIssueView = useCallback((property: TIssueViewOptions) => {
dispatch({
type: "SET_ISSUE_VIEW",
payload: {
issueView: property,
},
});
if (property === "kanban") {
dispatch({
type: "SET_GROUP_BY_PROPERTY",
payload: {
groupByProperty: "state_detail.group",
},
});
}
}, []);
const setGroupByProperty = useCallback((property: TIssueGroupByOptions) => {
dispatch({
type: "SET_GROUP_BY_PROPERTY",
payload: {
groupByProperty: property,
},
});
}, []);
const setOrderBy = useCallback((property: TIssueOrderByOptions) => {
dispatch({
type: "SET_ORDER_BY_PROPERTY",
payload: {
orderBy: property,
},
});
}, []);
const setShowEmptyGroups = useCallback((property: boolean) => {
dispatch({
type: "SET_SHOW_EMPTY_STATES",
payload: {
showEmptyGroups: property,
},
});
}, []);
const setShowSubIssues = useCallback((property: boolean) => {
dispatch({
type: "SET_SHOW_SUB_ISSUES",
payload: {
showSubIssues: property,
},
});
}, []);
const setFilters = useCallback(
(property: Partial<IIssueFilterOptions>) => {
Object.keys(property).forEach((key) => {
if (property[key as keyof typeof property]?.length === 0)
property[key as keyof typeof property] = null;
});
dispatch({
type: "SET_FILTERS",
payload: {
filters: {
...state.filters,
...property,
},
},
});
},
[state]
);
const setProperties = useCallback(
(key: keyof Properties) => {
dispatch({
type: "SET_PROPERTIES",
payload: {
properties: {
...state.properties,
[key]: !state.properties[key],
},
},
});
},
[state]
);
return (
<profileIssuesContext.Provider
value={{
issueView: state.issueView,
setIssueView,
groupByProperty: state.groupByProperty,
setGroupByProperty,
orderBy: state.orderBy,
setOrderBy,
showEmptyGroups: state.showEmptyGroups,
setShowEmptyGroups,
showSubIssues: state.showSubIssues,
setShowSubIssues,
filters: state.filters,
setFilters,
properties: state.properties,
setProperties,
}}
>
<ToastAlert />
{children}
</profileIssuesContext.Provider>
);
};

View File

@ -1,241 +0,0 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// services
import projectService from "services/project.service";
// hooks
import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth";
// components
import { SettingsHeader } from "components/project";
// ui
import { CustomSelect, Loader, SecondaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types
import { IProject, IUserLite, IWorkspace } from "types";
import type { NextPage } from "next";
// fetch-keys
import { PROJECTS_LIST, PROJECT_DETAILS, PROJECT_MEMBERS } from "constants/fetch-keys";
// helper
import { truncateText } from "helpers/string.helper";
const defaultValues: Partial<IProject> = {
project_lead: null,
default_assignee: null,
};
const ControlSettings: NextPage = () => {
const { setToastAlert } = useToast();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { user } = useUserAuth();
const { data: projectDetails } = useSWR<IProject>(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const { data: people } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const {
handleSubmit,
reset,
control,
formState: { isSubmitting },
} = useForm<IProject>({ defaultValues });
const onSubmit = async (formData: IProject) => {
if (!workspaceSlug || !projectId || !projectDetails) return;
const payload: Partial<IProject> = {
default_assignee: formData.default_assignee,
project_lead: formData.project_lead,
};
await projectService
.updateProject(workspaceSlug as string, projectId as string, payload, user)
.then((res) => {
mutate(PROJECT_DETAILS(projectId as string));
mutate(
PROJECTS_LIST(workspaceSlug as string, {
is_favorite: "all",
})
);
setToastAlert({
title: "Success",
type: "success",
message: "Project updated successfully",
});
})
.catch((err) => {
console.log(err);
});
};
useEffect(() => {
if (projectDetails)
reset({
...projectDetails,
default_assignee: projectDetails.default_assignee?.id ?? projectDetails.default_assignee,
project_lead: (projectDetails.project_lead as IUserLite)?.id ?? projectDetails.project_lead,
workspace: (projectDetails.workspace as IWorkspace).id,
});
}, [projectDetails, reset]);
return (
<ProjectAuthorizationWrapper
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${truncateText(projectDetails?.name ?? "Project", 32)}`}
link={`/${workspaceSlug}/projects/${projectId}/issues`}
linkTruncate
/>
<BreadcrumbItem title="Control Settings" unshrinkTitle />
</Breadcrumbs>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="p-8">
<SettingsHeader />
<div className="space-y-8 sm:space-y-12">
<div className="grid grid-cols-12 items-start gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Project Lead</h4>
<p className="text-sm text-custom-text-200">Select the project leader.</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<Controller
name="project_lead"
control={control}
render={({ field }) => (
<CustomSelect
{...field}
label={
people?.find((person) => person.member.id === field.value)?.member
.display_name ?? <span className="text-custom-text-200">Select lead</span>
}
width="w-full"
input
>
{people?.map((person) => (
<CustomSelect.Option
key={person.member.id}
value={person.member.id}
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
{person.member.avatar && person.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<img
src={person.member.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded-full"
alt="User Avatar"
/>
</div>
) : (
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 capitalize text-white">
{person.member.display_name?.charAt(0)}
</div>
)}
{person.member.display_name}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 items-start gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Default Assignee</h4>
<p className="text-sm text-custom-text-200">
Select the default assignee for the project.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<Controller
name="default_assignee"
control={control}
render={({ field }) => (
<CustomSelect
{...field}
label={
people?.find((p) => p.member.id === field.value)?.member.display_name ?? (
<span className="text-custom-text-200">Select default assignee</span>
)
}
width="w-full"
input
>
{people?.map((person) => (
<CustomSelect.Option
key={person.member.id}
value={person.member.id}
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
{person.member.avatar && person.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<img
src={person.member.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded-full"
alt="User Avatar"
/>
</div>
) : (
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 capitalize text-white">
{person.member.display_name?.charAt(0)}
</div>
)}
{person.member.display_name}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
</div>
<div className="sm:text-right">
<SecondaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</SecondaryButton>
</div>
</div>
</form>
</ProjectAuthorizationWrapper>
);
};
export default ControlSettings;

View File

@ -1,407 +0,0 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// services
import projectService from "services/project.service";
// components
import { DeleteProjectModal, SettingsHeader } from "components/project";
import { ImagePickerPopover } from "components/core";
import EmojiIconPicker from "components/emoji-icon-picker";
// hooks
import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth";
// ui
import {
Input,
TextArea,
Loader,
CustomSelect,
SecondaryButton,
DangerButton,
} from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
import { truncateText } from "helpers/string.helper";
// types
import { IProject, IWorkspace } from "types";
import type { NextPage } from "next";
// fetch-keys
import { PROJECTS_LIST, PROJECT_DETAILS, USER_PROJECT_VIEW } from "constants/fetch-keys";
// constants
import { NETWORK_CHOICES } from "constants/project";
const defaultValues: Partial<IProject> = {
name: "",
description: "",
identifier: "",
network: 0,
};
const GeneralSettings: NextPage = () => {
const [selectProject, setSelectedProject] = useState<string | null>(null);
const { user } = useUserAuth();
const { setToastAlert } = useToast();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: projectDetails } = useSWR<IProject>(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const { data: memberDetails, error } = useSWR(
workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null,
workspaceSlug && projectId
? () => projectService.projectMemberMe(workspaceSlug.toString(), projectId.toString())
: null
);
const {
register,
handleSubmit,
reset,
watch,
control,
setValue,
setError,
formState: { errors, isSubmitting },
} = useForm<IProject>({
defaultValues,
});
useEffect(() => {
if (projectDetails)
reset({
...projectDetails,
emoji_and_icon: projectDetails.emoji ?? projectDetails.icon_prop,
workspace: (projectDetails.workspace as IWorkspace).id,
});
}, [projectDetails, reset]);
const updateProject = async (payload: Partial<IProject>) => {
if (!workspaceSlug || !projectDetails) return;
await projectService
.updateProject(workspaceSlug as string, projectDetails.id, payload, user)
.then((res) => {
mutate<IProject>(
PROJECT_DETAILS(projectDetails.id),
(prevData) => ({ ...prevData, ...res }),
false
);
mutate(
PROJECTS_LIST(workspaceSlug as string, {
is_favorite: "all",
})
);
setToastAlert({
type: "success",
title: "Success!",
message: "Project updated successfully",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Project could not be updated. Please try again.",
});
});
};
const onSubmit = async (formData: IProject) => {
if (!workspaceSlug || !projectDetails) return;
const payload: Partial<IProject> = {
name: formData.name,
network: formData.network,
identifier: formData.identifier,
description: formData.description,
cover_image: formData.cover_image,
};
if (typeof formData.emoji_and_icon === "object") {
payload.emoji = null;
payload.icon_prop = formData.emoji_and_icon;
} else {
payload.emoji = formData.emoji_and_icon;
payload.icon_prop = null;
}
if (projectDetails.identifier !== formData.identifier)
await projectService
.checkProjectIdentifierAvailability(workspaceSlug as string, payload.identifier ?? "")
.then(async (res) => {
if (res.exists) setError("identifier", { message: "Identifier already exists" });
else await updateProject(payload);
});
else await updateProject(payload);
};
const handleIdentifierChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
const alphanumericValue = value.replace(/[^a-zA-Z0-9]/g, "");
const formattedValue = alphanumericValue.toUpperCase();
setValue("identifier", formattedValue);
};
const currentNetwork = NETWORK_CHOICES.find((n) => n.key === projectDetails?.network);
const isAdmin = memberDetails?.role === 20;
return (
<ProjectAuthorizationWrapper
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${truncateText(projectDetails?.name ?? "Project", 32)}`}
link={`/${workspaceSlug}/projects/${projectDetails?.id}/issues`}
linkTruncate
/>
<BreadcrumbItem title="General Settings" unshrinkTitle />
</Breadcrumbs>
}
>
<DeleteProjectModal
data={projectDetails ?? null}
isOpen={Boolean(selectProject)}
onClose={() => setSelectedProject(null)}
user={user}
/>
<form onSubmit={handleSubmit(onSubmit)} className="p-8">
<SettingsHeader />
<div className="space-y-8 sm:space-y-12">
<div className="grid grid-cols-12 items-start gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Icon & Name</h4>
<p className="text-sm text-custom-text-200">
Select an icon and a name for your project.
</p>
</div>
<div className="col-span-12 flex gap-2 sm:col-span-6">
{projectDetails ? (
<div className="h-7 w-7 grid place-items-center">
<Controller
control={control}
name="emoji_and_icon"
render={({ field: { value, onChange } }) => (
<EmojiIconPicker
label={value ? renderEmoji(value) : "Icon"}
value={value}
onChange={onChange}
/>
)}
/>
</div>
) : (
<Loader>
<Loader.Item height="46px" width="46px" />
</Loader>
)}
{projectDetails ? (
<Input
id="name"
name="name"
error={errors.name}
register={register}
placeholder="Project Name"
validations={{
required: "Name is required",
}}
/>
) : (
<Loader>
<Loader.Item height="46px" width="225px" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Description</h4>
<p className="text-sm text-custom-text-200">Give a description to your project.</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
placeholder="Enter project description"
validations={{}}
className="min-h-[46px] text-sm"
/>
) : (
<Loader className="w-full">
<Loader.Item height="46px" width="full" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Cover Photo</h4>
<p className="text-sm text-custom-text-200">
Select your cover photo from the given library.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
{watch("cover_image") ? (
<div className="h-32 w-full rounded border border-custom-border-200 p-1">
<div className="relative h-full w-full rounded">
<img
src={watch("cover_image")!}
className="absolute top-0 left-0 h-full w-full object-cover rounded"
alt={projectDetails?.name ?? "Cover image"}
/>
<div className="absolute bottom-0 flex w-full justify-end">
<ImagePickerPopover
label={"Change cover"}
onChange={(imageUrl) => {
setValue("cover_image", imageUrl);
}}
value={watch("cover_image")}
/>
</div>
</div>
</div>
) : (
<Loader className="w-full">
<Loader.Item height="46px" width="full" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Identifier</h4>
<p className="text-sm text-custom-text-200">
Create a 1-6 characters{"'"} identifier for the project.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<Input
id="identifier"
name="identifier"
error={errors.identifier}
register={register}
placeholder="Enter identifier"
onChange={handleIdentifierChange}
validations={{
required: "Identifier is required",
validate: (value) =>
/^[A-Z0-9]+$/.test(value.toUpperCase()) || "Identifier must be in uppercase.",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 5,
message: "Identifier must at most be of 5 characters",
},
}}
/>
) : (
<Loader>
<Loader.Item height="46px" width="160px" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Network</h4>
<p className="text-sm text-custom-text-200">Select privacy type for the project.</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<Controller
name="network"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={currentNetwork?.label ?? "Select network"}
input
>
{NETWORK_CHOICES.map((network) => (
<CustomSelect.Option key={network.key} value={network.key}>
{network.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<Loader className="w-full">
<Loader.Item height="46px" width="160px" />
</Loader>
)}
</div>
</div>
<div className="sm:text-right">
{projectDetails ? (
<SecondaryButton type="submit" loading={isSubmitting} disabled={!isAdmin}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</SecondaryButton>
) : (
<Loader className="mt-2 w-full">
<Loader.Item height="34px" width="100px" />
</Loader>
)}
</div>
{memberDetails?.role === 20 && (
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Danger Zone</h4>
<p className="text-sm text-custom-text-200">
The danger zone of the project delete page is a critical area that requires
careful consideration and attention. When deleting a project, all of the data and
resources within that project will be permanently removed and cannot be recovered.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<div>
<DangerButton
onClick={() => setSelectedProject(projectDetails.id ?? null)}
outline
>
Delete Project
</DangerButton>
</div>
) : (
<Loader className="mt-2 w-full">
<Loader.Item height="46px" width="100px" />
</Loader>
)}
</div>
</div>
)}
</div>
</form>
</ProjectAuthorizationWrapper>
);
};
export default GeneralSettings;

View File

@ -1,80 +0,0 @@
// next imports
import Head from "next/head";
import dynamic from "next/dynamic";
import Router from "next/router";
// themes
import { ThemeProvider } from "next-themes";
// styles
import "styles/globals.css";
import "styles/editor.css";
import "styles/command-pallette.css";
import "styles/nprogress.css";
import "styles/react-datepicker.css";
// nprogress
import NProgress from "nprogress";
// contexts
import { UserProvider } from "contexts/user.context";
import { ToastContextProvider } from "contexts/toast.context";
import { ThemeContextProvider } from "contexts/theme.context";
// types
import type { AppProps } from "next/app";
// constants
import { THEMES } from "constants/themes";
// constants
import {
SITE_NAME,
SITE_DESCRIPTION,
SITE_URL,
TWITTER_USER_NAME,
SITE_KEYWORDS,
SITE_TITLE,
} from "constants/seo-variables";
// mobx store provider
import { MobxStoreProvider } from "lib/mobx/store-provider";
import MobxStoreInit from "lib/mobx/store-init";
const CrispWithNoSSR = dynamic(() => import("constants/crisp"), { ssr: false });
// nprogress
NProgress.configure({ showSpinner: false });
Router.events.on("routeChangeStart", NProgress.start);
Router.events.on("routeChangeError", NProgress.done);
Router.events.on("routeChangeComplete", NProgress.done);
function MyApp({ Component, pageProps }: AppProps) {
return (
// <UserProvider>
// mobx root provider
<MobxStoreProvider {...pageProps}>
<ThemeProvider themes={THEMES} defaultTheme="system">
<ToastContextProvider>
<CrispWithNoSSR />
<Head>
<title>{SITE_TITLE}</title>
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:title" content={SITE_TITLE} />
<meta property="og:url" content={SITE_URL} />
<meta name="description" content={SITE_DESCRIPTION} />
<meta property="og:description" content={SITE_DESCRIPTION} />
<meta name="keywords" content={SITE_KEYWORDS} />
<meta name="twitter:site" content={`@${TWITTER_USER_NAME}`} />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest.json" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
</Head>
<MobxStoreInit />
<Component {...pageProps} />
</ToastContextProvider>
</ThemeProvider>
</MobxStoreProvider>
// </UserProvider>
);
}
export default MyApp;

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