Merge pull request #2091 from makeplane/stage-release

promote: stage release to master
This commit is contained in:
sriram veeraghanta 2023-09-04 19:14:29 +05:30 committed by GitHub
commit 80bcca71ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
1109 changed files with 20403 additions and 7322 deletions

View File

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

View File

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

View File

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

View File

@ -1,77 +0,0 @@
name: Build and Push Backend Docker Image
on:
push:
branches:
- 'develop'
- 'master'
tags:
- '*'
jobs:
build_push_backend:
name: Build and Push Api Server Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.1.0
with:
platforms: linux/arm64,linux/amd64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.1.0
with:
registry: "ghcr.io"
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
registry: "registry.hub.docker.com"
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker (Docker Hub)
id: ghmeta
uses: docker/metadata-action@v4.3.0
with:
images: makeplane/plane-backend
- name: Extract metadata (tags, labels) for Docker (Github)
id: dkrmeta
uses: docker/metadata-action@v4.3.0
with:
images: ghcr.io/${{ github.repository }}-backend
- name: Build and Push to GitHub Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: linux/arm64,linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.ghmeta.outputs.tags }}
labels: ${{ steps.ghmeta.outputs.labels }}
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: linux/arm64,linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.dkrmeta.outputs.tags }}
labels: ${{ steps.dkrmeta.outputs.labels }}

View File

@ -1,77 +0,0 @@
name: Build and Push Frontend Docker Image
on:
push:
branches:
- 'develop'
- 'master'
tags:
- '*'
jobs:
build_push_frontend:
name: Build Frontend Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.1.0
with:
platforms: linux/arm64,linux/amd64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Github Container Registry
uses: docker/login-action@v2.1.0
with:
registry: "ghcr.io"
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
registry: "registry.hub.docker.com"
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker (Docker Hub)
id: ghmeta
uses: docker/metadata-action@v4.3.0
with:
images: makeplane/plane-frontend
- name: Extract metadata (tags, labels) for Docker (Github)
id: meta
uses: docker/metadata-action@v4.3.0
with:
images: ghcr.io/${{ github.repository }}-frontend
- name: Build and Push to GitHub Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./apps/app/Dockerfile.web
platforms: linux/arm64,linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.ghmeta.outputs.tags }}
labels: ${{ steps.ghmeta.outputs.labels }}
- name: Build and Push to Docker Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./apps/app/Dockerfile.web
platforms: linux/arm64,linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.dkrmeta.outputs.tags }}
labels: ${{ steps.dkrmeta.outputs.labels }}

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
@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
@ -125,4 +125,4 @@ enforcement ladder](https://github.com/mozilla/diversity).
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
https://www.contributor-covenant.org/translations.

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
@ -56,7 +54,7 @@ chmod +x setup.sh
- Run setup.sh
```bash
./setup.sh http://localhost
./setup.sh http://localhost
```
> If running in a cloud env replace localhost with public facing IP address of the VM
@ -65,31 +63,32 @@ chmod +x setup.sh
Visit [Tiptap Pro](https://collab.tiptap.dev/pro-extensions) and signup (it is free).
Create a **`.npmrc`** file, copy the following and replace your registry token generated from Tiptap Pro.
Create a **`.npmrc`** file, copy the following and replace your registry token generated from Tiptap Pro.
```
@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=YOUR_REGISTRY_TOKEN
```
- Run Docker compose up
```bash
docker compose up -d
```
<strong>You can use the default email and password for your first login `captain@plane.so` and `password123`.</strong>
<strong>You can use the default email and password for your first login `captain@plane.so` and `password123`.</strong>
## 🚀 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/)

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 IssueViewSerializer, IssueViewFavoriteSerializer
@ -44,6 +45,7 @@ from .issue import (
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
IssuePublicSerializer,
)
from .module import (

View File

@ -113,7 +113,11 @@ 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
@ -510,6 +514,9 @@ class IssueAttachmentSerializer(BaseSerializer):
class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueReaction
fields = "__all__"
@ -521,19 +528,6 @@ class IssueReactionSerializer(BaseSerializer):
]
class IssueReactionLiteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueReaction
fields = [
"id",
"reaction",
"issue",
"actor_detail",
]
class CommentReactionLiteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
@ -554,12 +548,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 +564,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 +577,6 @@ class IssueCommentSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"access",
]
@ -632,7 +626,7 @@ class IssueSerializer(BaseSerializer):
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 +652,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 +670,33 @@ class IssueLiteSerializer(BaseSerializer):
]
class IssuePublicSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateLiteSerializer(read_only=True, source="state")
reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions")
votes = IssueVoteSerializer(read_only=True, many=True)
class Meta:
model = Issue
fields = [
"id",
"name",
"description_html",
"sequence_id",
"state",
"state_detail",
"project",
"project_detail",
"workspace",
"priority",
"target_date",
"reactions",
"votes",
]
read_only_fields = fields
class IssueSubscriberSerializer(BaseSerializer):
class Meta:
model = IssueSubscriber

View File

@ -15,6 +15,7 @@ from plane.db.models import (
ProjectIdentifier,
ProjectFavorite,
ProjectDeployBoard,
ProjectPublicMember,
)
@ -112,7 +113,7 @@ class ProjectDetailSerializer(BaseSerializer):
class ProjectMemberSerializer(BaseSerializer):
workspace = WorkSpaceSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
project = ProjectLiteSerializer(read_only=True)
member = UserLiteSerializer(read_only=True)
@ -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,
@ -89,7 +91,6 @@ from plane.api.views import (
IssueCommentPublicViewSet,
IssueReactionViewSet,
CommentReactionViewSet,
ExportIssuesEndpoint,
## End Issues
# States
StateViewSet,
@ -165,16 +166,23 @@ 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,
## End Exporter
)
@ -231,7 +239,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/",
@ -435,6 +443,11 @@ urlpatterns = [
WorkspaceLabelsEndpoint.as_view(),
name="workspace-labels",
),
path(
"workspaces/<str:slug>/members/leave/",
LeaveWorkspaceEndpoint.as_view(),
name="workspace-labels",
),
## End Workspaces ##
# Projects
path(
@ -548,6 +561,11 @@ urlpatterns = [
),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/leave/",
LeaveProjectEndpoint.as_view(),
name="project",
),
# End Projects
# States
path(
@ -1490,6 +1508,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(
@ -1520,9 +1547,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(
@ -1614,5 +1646,10 @@ urlpatterns = [
),
name="issue-vote-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/",
WorkspaceProjectDeployBoardEndpoint.as_view(),
name="workspace-project-boards",
),
## End Public Boards
]

View File

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

View File

@ -18,10 +18,21 @@ class FileAssetEndpoint(BaseAPIView):
"""
def get(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
files = FileAsset.objects.filter(asset=asset_key)
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
return Response(serializer.data)
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({"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)
serializer = FileAssetSerializer(files, context={"request": request})
return Response(serializer.data)
except FileAsset.DoesNotExist:
if files.exists():
serializer = FileAssetSerializer(files, context={"request": request})
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,13 +333,21 @@ 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():
return Response(
{
"error": "The Cycle has already been completed so it cannot be edited"
},
status=status.HTTP_400_BAD_REQUEST,
)
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"
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True)
if serializer.is_valid():
@ -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(
@ -710,7 +719,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
class CycleFavoriteViewSet(BaseViewSet):
serializer_class = CycleFavoriteSerializer
model = CycleFavorite

View File

@ -17,17 +17,19 @@ from django.db.models import (
When,
Exists,
Max,
IntegerField,
)
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db.models.functions import Coalesce
from django.db import IntegrityError
from django.conf import settings
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from sentry_sdk import capture_exception
# Module imports
@ -49,6 +51,7 @@ from plane.api.serializers import (
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
IssuePublicSerializer,
)
from plane.api.permissions import (
WorkspaceEntityPermission,
@ -73,10 +76,12 @@ from plane.db.models import (
CommentReaction,
ProjectDeployBoard,
IssueVote,
ProjectPublicMember,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.export_task import issue_export_task
class IssueViewSet(BaseViewSet):
@ -333,7 +338,11 @@ class UserWorkSpaceIssues(BaseAPIView):
issue_queryset = (
Issue.issue_objects.filter(
(Q(assignees__in=[request.user]) | Q(created_by=request.user)),
(
Q(assignees__in=[request.user])
| Q(created_by=request.user)
| Q(issue_subscribers__subscriber=request.user)
),
workspace__slug=slug,
)
.annotate(
@ -482,7 +491,7 @@ class IssueActivityEndpoint(BaseAPIView):
issue_activities = (
IssueActivity.objects.filter(issue_id=issue_id)
.filter(
~Q(field="comment"),
~Q(field__in=["comment", "vote", "reaction"]),
project__project_projectmember__member=self.request.user,
)
.select_related("actor", "workspace", "issue", "project")
@ -492,6 +501,12 @@ class IssueActivityEndpoint(BaseAPIView):
.filter(project__project_projectmember__member=self.request.user)
.order_by("created_at")
.select_related("actor", "issue", "project", "workspace")
.prefetch_related(
Prefetch(
"comment_reactions",
queryset=CommentReaction.objects.select_related("actor"),
)
)
)
issue_activities = IssueActivitySerializer(issue_activities, many=True).data
issue_comments = IssueCommentSerializer(issue_comments, many=True).data
@ -588,6 +603,15 @@ class IssueCommentViewSet(BaseViewSet):
.select_related("project")
.select_related("workspace")
.select_related("issue")
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
member_id=self.request.user.id,
)
)
)
.distinct()
)
@ -769,7 +793,9 @@ class SubIssuesEndpoint(BaseAPIView):
.order_by("state_group")
)
result = {item["state_group"]: item["state_count"] for item in state_distribution}
result = {
item["state_group"]: item["state_count"] for item in state_distribution
}
serializer = IssueLiteSerializer(
sub_issues,
@ -1384,6 +1410,14 @@ class IssueReactionViewSet(BaseViewSet):
project_id=self.kwargs.get("project_id"),
actor=self.request.user,
)
issue_activity.delay(
type="issue_reaction.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
try:
@ -1394,6 +1428,19 @@ class IssueReactionViewSet(BaseViewSet):
reaction=reaction_code,
actor=request.user,
)
issue_activity.delay(
type="issue_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(issue_reaction.id),
}
),
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except IssueReaction.DoesNotExist:
@ -1434,6 +1481,14 @@ class CommentReactionViewSet(BaseViewSet):
comment_id=self.kwargs.get("comment_id"),
project_id=self.kwargs.get("project_id"),
)
issue_activity.delay(
type="comment_reaction.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
try:
@ -1444,6 +1499,20 @@ class CommentReactionViewSet(BaseViewSet):
reaction=reaction_code,
actor=request.user,
)
issue_activity.delay(
type="comment_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(comment_reaction.id),
"comment_id": str(comment_id),
}
),
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except CommentReaction.DoesNotExist:
@ -1468,23 +1537,48 @@ class IssueCommentPublicViewSet(BaseViewSet):
"workspace__id",
]
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.comments:
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(issue_id=self.kwargs.get("issue_id"))
.select_related("project")
.select_related("workspace")
.select_related("issue")
.distinct()
)
def get_permissions(self):
if self.action in ["list", "retrieve"]:
self.permission_classes = [
AllowAny,
]
else:
self.permission_classes = [
IsAuthenticated,
]
return super(IssueCommentPublicViewSet, self).get_permissions()
def get_queryset(self):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.comments:
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(access="EXTERNAL")
.select_related("project")
.select_related("workspace")
.select_related("issue")
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
member_id=self.request.user.id,
)
)
)
.distinct()
)
else:
return IssueComment.objects.none()
except ProjectDeployBoard.DoesNotExist:
return IssueComment.objects.none()
def create(self, request, slug, project_id, issue_id):
@ -1499,21 +1593,13 @@ class IssueCommentPublicViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
access = (
"INTERNAL"
if ProjectMember.objects.filter(
project_id=project_id, member=request.user
).exists()
else "EXTERNAL"
)
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
issue_id=issue_id,
actor=request.user,
access=access,
access="EXTERNAL",
)
issue_activity.delay(
type="comment.activity.created",
@ -1523,6 +1609,16 @@ class IssueCommentPublicViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=None,
)
if not ProjectMember.objects.filter(
project_id=project_id,
member=request.user,
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
member=request.user,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
@ -1567,7 +1663,8 @@ class IssueCommentPublicViewSet(BaseViewSet):
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
return Response(
{"error": "IssueComent Does not exists"},
status=status.HTTP_400_BAD_REQUEST,)
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, issue_id, pk):
try:
@ -1614,21 +1711,24 @@ class IssueReactionPublicViewSet(BaseViewSet):
model = IssueReaction
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.reactions:
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.order_by("-created_at")
.distinct()
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
else:
if project_deploy_board.reactions:
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.order_by("-created_at")
.distinct()
)
else:
return IssueReaction.objects.none()
except ProjectDeployBoard.DoesNotExist:
return IssueReaction.objects.none()
def create(self, request, slug, project_id, issue_id):
@ -1648,6 +1748,23 @@ class IssueReactionPublicViewSet(BaseViewSet):
serializer.save(
project_id=project_id, issue_id=issue_id, actor=request.user
)
if not ProjectMember.objects.filter(
project_id=project_id,
member=request.user,
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
member=request.user,
)
issue_activity.delay(
type="issue_reaction.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except ProjectDeployBoard.DoesNotExist:
@ -1679,6 +1796,19 @@ class IssueReactionPublicViewSet(BaseViewSet):
reaction=reaction_code,
actor=request.user,
)
issue_activity.delay(
type="issue_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(issue_reaction.id),
}
),
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except IssueReaction.DoesNotExist:
@ -1699,21 +1829,24 @@ class CommentReactionPublicViewSet(BaseViewSet):
model = CommentReaction
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.reactions:
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(comment_id=self.kwargs.get("comment_id"))
.order_by("-created_at")
.distinct()
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
else:
if project_deploy_board.reactions:
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(comment_id=self.kwargs.get("comment_id"))
.order_by("-created_at")
.distinct()
)
else:
return CommentReaction.objects.none()
except ProjectDeployBoard.DoesNotExist:
return CommentReaction.objects.none()
def create(self, request, slug, project_id, comment_id):
@ -1733,8 +1866,29 @@ class CommentReactionPublicViewSet(BaseViewSet):
serializer.save(
project_id=project_id, comment_id=comment_id, actor=request.user
)
if not ProjectMember.objects.filter(
project_id=project_id, member=request.user
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
member=request.user,
)
issue_activity.delay(
type="comment_reaction.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IssueComment.DoesNotExist:
return Response(
{"error": "Comment does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project board does not exist"},
@ -1765,6 +1919,20 @@ class CommentReactionPublicViewSet(BaseViewSet):
reaction=reaction_code,
actor=request.user,
)
issue_activity.delay(
type="comment_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(comment_reaction.id),
"comment_id": str(comment_id),
}
),
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except CommentReaction.DoesNotExist:
@ -1785,13 +1953,23 @@ class IssueVotePublicViewSet(BaseViewSet):
serializer_class = IssueVoteSerializer
def get_queryset(self):
return (
super()
.get_queryset()
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
)
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.votes:
return (
super()
.get_queryset()
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
)
else:
return IssueVote.objects.none()
except ProjectDeployBoard.DoesNotExist:
return IssueVote.objects.none()
def create(self, request, slug, project_id, issue_id):
try:
@ -1799,10 +1977,31 @@ class IssueVotePublicViewSet(BaseViewSet):
actor_id=request.user.id,
project_id=project_id,
issue_id=issue_id,
vote=request.data.get("vote", 1),
)
# Add the user for workspace tracking
if not ProjectMember.objects.filter(
project_id=project_id, member=request.user
).exists():
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
member=request.user,
)
issue_vote.vote = request.data.get("vote", 1)
issue_vote.save()
issue_activity.delay(
type="issue_vote.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except IntegrityError:
return Response(
{"error": "Reaction already exists"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
capture_exception(e)
return Response(
@ -1818,6 +2017,19 @@ class IssueVotePublicViewSet(BaseViewSet):
issue_id=issue_id,
actor_id=request.user.id,
)
issue_activity.delay(
type="issue_vote.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"vote": str(issue_vote.vote),
"identifier": str(issue_vote.id),
}
),
)
issue_vote.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
@ -1828,27 +2040,196 @@ class IssueVotePublicViewSet(BaseViewSet):
)
class ExportIssuesEndpoint(BaseAPIView):
class IssueRetrievePublicEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
AllowAny,
]
def post(self, request, slug):
def get(self, request, slug, project_id, issue_id):
try:
issue_export_task.delay(
email=request.user.email, data=request.data, slug=slug ,exporter_name=request.user.first_name
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=issue_id
)
serializer = IssuePublicSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
except Issue.DoesNotExist:
return Response(
{"error": "Issue Does not exist"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
print(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssuePublicSerializer(issue_queryset, many=True).data
state_group_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
states = (
State.objects.filter(
workspace__slug=slug,
project_id=project_id,
)
.annotate(
custom_order=Case(
*[
When(group=value, then=Value(index))
for index, value in enumerate(state_group_order)
],
default=Value(len(state_group_order)),
output_field=IntegerField(),
),
)
.values("name", "group", "color", "id")
.order_by("custom_order", "sequence")
)
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(
{
"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
)

View File

@ -10,7 +10,13 @@ from plane.utils.paginator import BasePaginator
# Module imports
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):
@ -287,7 +275,10 @@ class ProjectViewSet(BaseViewSet):
)
data = serializer.data
# Additional fields of the member
data["sort_order"] = project_member.sort_order
data["member_role"] = project_member.role
data["is_member"] = True
return Response(data, status=status.HTTP_201_CREATED)
return Response(
serializer.errors,
@ -626,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)
@ -1140,145 +1131,78 @@ class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
)
class ProjectDeployBoardIssuesPublicEndpoint(BaseAPIView):
class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
def get(self, request, slug):
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"))
projects = (
Project.objects.filter(workspace__slug=slug)
.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")
is_public=Exists(
ProjectDeployBoard.objects.filter(
workspace__slug=slug, project_id=OuterRef("pk")
)
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(is_public=True)
).values(
"id",
"identifier",
"name",
"description",
"emoji",
"icon_prop",
"cover_image",
)
# Priority Ordering
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(projects, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
except ProjectDeployBoard.DoesNotExist:
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": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND
{"error": "Workspace member does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)

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

@ -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,9 +24,11 @@ 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")
return time.strftime("%a, %d %b %Y")
def create_csv_file(data):
csv_buffer = io.StringIO()
@ -66,28 +69,53 @@ def create_zip_file(files):
def upload_to_s3(zip_file, workspace_id, token_id, slug):
s3 = boto3.client(
"s3",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
s3.upload_fileobj(
zip_file,
settings.AWS_S3_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
expires_in = 7 * 24 * 60 * 60
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)
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,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
s3.upload_fileobj(
zip_file,
settings.AWS_S3_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)
exporter_instance = ExporterHistory.objects.get(token=token_id)
@ -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):
@ -145,7 +173,7 @@ def generate_json_row(issue):
else "",
"Labels": issue["labels__name"],
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
@ -242,7 +270,9 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
workspace_issues = (
(
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,18 +21,29 @@ 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")
s3 = boto3.client(
"s3",
region_name="ap-south-1",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
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",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
for file_name, exporter_id in expired_exporter_history:
# Delete object from S3
if file_name:
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
if settings.DOCKERIZED and settings.USE_MINIO:
s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name)
else:
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
ExporterHistory.objects.filter(id=exporter_id).update(url=None)

View File

@ -24,6 +24,9 @@ from plane.db.models import (
IssueSubscriber,
Notification,
IssueAssignee,
IssueReaction,
CommentReaction,
IssueComment,
)
from plane.api.serializers import IssueActivitySerializer
@ -629,7 +632,7 @@ 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,
@ -1022,6 +1025,150 @@ def delete_attachment_activity(
)
)
def create_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None:
issue_reaction = IssueReaction.objects.filter(reaction=requested_data.get("reaction"), project=project, actor=actor).values_list('id', flat=True).first()
if issue_reaction is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="created",
old_value=None,
new_value=requested_data.get("reaction"),
field="reaction",
project=project,
workspace=project.workspace,
comment="added the reaction",
old_identifier=None,
new_identifier=issue_reaction,
)
)
def delete_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance and current_instance.get("reaction") is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="deleted",
old_value=current_instance.get("reaction"),
new_value=None,
field="reaction",
project=project,
workspace=project.workspace,
comment="removed the reaction",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
)
)
def create_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None:
comment_reaction_id, comment_id = CommentReaction.objects.filter(reaction=requested_data.get("reaction"), project=project, actor=actor).values_list('id', 'comment__id').first()
comment = IssueComment.objects.get(pk=comment_id,project=project)
if comment is not None and comment_reaction_id is not None and comment_id is not None:
issue_activities.append(
IssueActivity(
issue_id=comment.issue_id,
actor=actor,
verb="created",
old_value=None,
new_value=requested_data.get("reaction"),
field="reaction",
project=project,
workspace=project.workspace,
comment="added the reaction",
old_identifier=None,
new_identifier=comment_reaction_id,
)
)
def delete_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance and current_instance.get("reaction") is not None:
issue_id = IssueComment.objects.filter(pk=current_instance.get("comment_id"), project=project).values_list('issue_id', flat=True).first()
if issue_id is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="deleted",
old_value=current_instance.get("reaction"),
new_value=None,
field="reaction",
project=project,
workspace=project.workspace,
comment="removed the reaction",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
)
)
def create_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("vote") is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="created",
old_value=None,
new_value=requested_data.get("vote"),
field="vote",
project=project,
workspace=project.workspace,
comment="added the vote",
old_identifier=None,
new_identifier=None,
)
)
def delete_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance and current_instance.get("vote") is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="deleted",
old_value=current_instance.get("vote"),
new_value=None,
field="vote",
project=project,
workspace=project.workspace,
comment="removed the vote",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
)
)
# Receive message from room group
@shared_task
@ -1045,6 +1192,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 +1233,12 @@ def issue_activity(
"link.activity.deleted": delete_link_activity,
"attachment.activity.created": create_attachment_activity,
"attachment.activity.deleted": delete_attachment_activity,
"issue_reaction.activity.created": create_issue_reaction_activity,
"issue_reaction.activity.deleted": delete_issue_reaction_activity,
"comment_reaction.activity.created": create_comment_reaction_activity,
"comment_reaction.activity.deleted": delete_comment_reaction_activity,
"issue_vote.activity.created": create_issue_vote_activity,
"issue_vote.activity.deleted": delete_issue_vote_activity,
}
func = ACTIVITY_MAPPER.get(type)
@ -1119,6 +1278,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

@ -64,7 +64,7 @@ def archive_old_issues():
issues_to_update.append(issue)
# Bulk Update the issues and log the activity
Issue.objects.bulk_update(
updated_issues = Issue.objects.bulk_update(
issues_to_update, ["archived_at"], batch_size=100
)
[
@ -77,7 +77,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:
@ -136,7 +136,7 @@ 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)
updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
[
issue_activity.delay(
type="issue.activity.updated",
@ -147,7 +147,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

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

View File

@ -293,7 +293,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 +476,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

@ -254,3 +254,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

@ -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

@ -1 +1 @@
python-3.11.4
python-3.11.5

View File

@ -1,4 +0,0 @@
export * from "./due-date-filter-modal";
export * from "./due-date-filter-select";
export * from "./filters-list";
export * from "./issues-view-filter";

View File

@ -1,368 +0,0 @@
import React, { useCallback, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
// components
import {
ViewAssigneeSelect,
ViewDueDateSelect,
ViewEstimateSelect,
ViewIssueLabel,
ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect,
} from "components/issues";
import { Popover2 } from "@blueprintjs/popover2";
// icons
import { Icon } from "components/ui";
import {
EllipsisHorizontalIcon,
LinkIcon,
PencilIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
// hooks
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
import useToast from "hooks/use-toast";
// services
import issuesService from "services/issues.service";
// constant
import {
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
VIEW_ISSUES,
} from "constants/fetch-keys";
// types
import { ICurrentUserResponse, IIssue, ISubIssueResponse, Properties, UserAuth } from "types";
// helper
import { copyTextToClipboard } from "helpers/string.helper";
import { renderLongDetailDateFormat } from "helpers/date-time.helper";
type Props = {
issue: IIssue;
index: number;
expanded: boolean;
handleToggleExpand: (issueId: string) => void;
properties: Properties;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
gridTemplateColumns: string;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
nestingLevel: number;
};
export const SingleSpreadsheetIssue: React.FC<Props> = ({
issue,
index,
expanded,
handleToggleExpand,
properties,
handleEditIssue,
handleDeleteIssue,
gridTemplateColumns,
disableUserActions,
user,
userAuth,
nestingLevel,
}) => {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { params } = useSpreadsheetIssuesView();
const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, issue: IIssue) => {
if (!workspaceSlug || !projectId) return;
const fetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: viewId
? VIEW_ISSUES(viewId.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
if (issue.parent) {
mutate<ISubIssueResponse>(
SUB_ISSUES(issue.parent.toString()),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
sub_issues: (prevData.sub_issues ?? []).map((i) => {
if (i.id === issue.id) {
return {
...i,
...formData,
};
}
return i;
}),
};
},
false
);
} else {
mutate<IIssue[]>(
fetchKey,
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === issue.id) {
return {
...p,
...formData,
};
}
return p;
}),
false
);
}
issuesService
.patchIssue(
workspaceSlug as string,
projectId as string,
issue.id as string,
formData,
user
)
.then(() => {
if (issue.parent) {
mutate(SUB_ISSUES(issue.parent as string));
} else {
mutate(fetchKey);
if (cycleId) mutate(CYCLE_DETAILS(cycleId as string));
if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
}
})
.catch((error) => {
console.log(error);
});
},
[workspaceSlug, projectId, cycleId, moduleId, viewId, params, user]
);
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
const paddingLeft = `${nestingLevel * 68}px`;
const tooltipPosition = index === 0 ? "bottom" : "top";
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<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 }}
>
<div className="flex gap-1.5 items-center px-4 sticky z-[1] left-0 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">
<div className="flex gap-1.5 items-center" style={issue.parent ? { paddingLeft } : {}}>
<div className="relative flex items-center cursor-pointer text-xs text-center hover:text-custom-text-100 w-14">
{properties.key && (
<span className="flex items-center justify-center opacity-100 group-hover:opacity-0">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
{!isNotAllowed && !disableUserActions && (
<div className="absolute top-0 left-2.5 opacity-0 group-hover:opacity-100">
<Popover2
isOpen={isOpen}
canEscapeKeyClose
onInteraction={(nextOpenState) => setIsOpen(nextOpenState)}
content={
<div
className={`flex flex-col gap-1.5 overflow-y-scroll whitespace-nowrap rounded-md border p-1 text-xs shadow-lg focus:outline-none max-h-44 min-w-full border-custom-border-200 bg-custom-background-90`}
>
<button
type="button"
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
onClick={() => {
handleEditIssue(issue);
setIsOpen(false);
}}
>
<div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
<span>Edit issue</span>
</div>
</button>
<button
type="button"
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
onClick={() => {
handleDeleteIssue(issue);
setIsOpen(false);
}}
>
<div className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete issue</span>
</div>
</button>
<button
type="button"
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
onClick={() => {
handleCopyText();
setIsOpen(false);
}}
>
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy issue link</span>
</div>
</button>
</div>
}
placement="bottom-start"
>
<EllipsisHorizontalIcon className="h-5 w-5 text-custom-text-200" />
</Popover2>
</div>
)}
</div>
{issue.sub_issues_count > 0 && (
<div className="h-6 w-6 flex justify-center items-center">
<button
className="h-5 w-5 hover:bg-custom-background-90 hover:text-custom-text-100 rounded-sm cursor-pointer"
onClick={() => handleToggleExpand(issue.id)}
>
<Icon iconName="chevron_right" className={`${expanded ? "rotate-90" : ""}`} />
</button>
</div>
)}
</div>
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="truncate text-custom-text-100 cursor-pointer w-full text-[0.825rem]">
{issue.name}
</a>
</Link>
</div>
{properties.state && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
className="max-w-full"
tooltipPosition={tooltipPosition}
customButton
user={user}
isNotAllowed={isNotAllowed}
/>
</div>
)}
{properties.priority && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
tooltipPosition={tooltipPosition}
noBorder
user={user}
isNotAllowed={isNotAllowed}
/>
</div>
)}
{properties.assignee && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
tooltipPosition={tooltipPosition}
customButton
user={user}
isNotAllowed={isNotAllowed}
/>
</div>
)}
{properties.labels && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewIssueLabel issue={issue} maxRender={1} />
</div>
)}
{properties.start_date && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewStartDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
tooltipPosition={tooltipPosition}
noBorder
user={user}
isNotAllowed={isNotAllowed}
/>
</div>
)}
{properties.due_date && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
tooltipPosition={tooltipPosition}
noBorder
user={user}
isNotAllowed={isNotAllowed}
/>
</div>
)}
{properties.estimate && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewEstimateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
tooltipPosition={tooltipPosition}
user={user}
isNotAllowed={isNotAllowed}
/>
</div>
)}
{properties.created_on && (
<div className="flex items-center text-xs cursor-default text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
{renderLongDetailDateFormat(issue.created_at)}
</div>
)}
{properties.updated_on && (
<div className="flex items-center text-xs cursor-default text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
{renderLongDetailDateFormat(issue.updated_at)}
</div>
)}
</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, Icon, 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,103 +0,0 @@
import Link from "next/link";
import { useRouter } from "next/router";
// ui
import { Tooltip } from "components/ui";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
// types
import { ICycle, IIssue, IModule } from "types";
// constants
import { MODULE_STATUS } from "constants/module";
export const IssueGanttBlock = ({ issue }: { issue: IIssue }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
<div
className="flex-shrink-0 w-0.5 h-full"
style={{ backgroundColor: issue.state_detail?.color }}
/>
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{issue.name}</h5>
<div>
{renderShortDate(issue.start_date ?? "")} to{" "}
{renderShortDate(issue.target_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{issue.name}
</div>
</Tooltip>
</a>
</Link>
);
};
export const CycleGanttBlock = ({ cycle }: { cycle: ICycle }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Link href={`/${workspaceSlug}/projects/${cycle.project}/cycles/${cycle.id}`}>
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
<div className="flex-shrink-0 w-0.5 h-full bg-custom-primary-100" />
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{cycle.name}</h5>
<div>
{renderShortDate(cycle.start_date ?? "")} to {renderShortDate(cycle.end_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{cycle.name}
</div>
</Tooltip>
</a>
</Link>
);
};
export const ModuleGanttBlock = ({ module }: { module: IModule }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
<div
className="flex-shrink-0 w-0.5 h-full"
style={{ backgroundColor: MODULE_STATUS.find((s) => s.value === module.status)?.color }}
/>
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{module.name}</h5>
<div>
{renderShortDate(module.start_date ?? "")} to{" "}
{renderShortDate(module.target_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{module.name}
</div>
</Tooltip>
</a>
</Link>
);
};

View File

@ -1,178 +0,0 @@
import { FC } from "react";
// react-beautiful-dnd
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// helpers
import { ChartDraggable } from "../helpers/draggable";
import { renderDateFormat } from "helpers/date-time.helper";
// types
import { IBlockUpdateData, IGanttBlock } from "../types";
export const GanttChartBlocks: FC<{
itemsContainerWidth: number;
blocks: IGanttBlock[] | null;
sidebarBlockRender: FC;
blockRender: FC;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
enableLeftDrag: boolean;
enableRightDrag: boolean;
enableReorder: boolean;
}> = ({
itemsContainerWidth,
blocks,
sidebarBlockRender,
blockRender,
blockUpdateHandler,
enableLeftDrag,
enableRightDrag,
enableReorder,
}) => {
const handleChartBlockPosition = (
block: IGanttBlock,
totalBlockShifts: number,
dragDirection: "left" | "right"
) => {
let updatedDate = new Date();
if (dragDirection === "left") {
const originalDate = new Date(block.start_date);
const currentDay = originalDate.getDate();
updatedDate = new Date(originalDate);
updatedDate.setDate(currentDay - totalBlockShifts);
} else {
const originalDate = new Date(block.target_date);
const currentDay = originalDate.getDate();
updatedDate = new Date(originalDate);
updatedDate.setDate(currentDay + totalBlockShifts);
}
blockUpdateHandler(block.data, {
[dragDirection === "left" ? "start_date" : "target_date"]: renderDateFormat(updatedDate),
});
};
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
const { source, destination, draggableId } = result;
if (!destination) return;
if (source.index === destination.index && document) {
// const draggedBlock = document.querySelector(`#${draggableId}`) as HTMLElement;
// const blockStyles = window.getComputedStyle(draggedBlock);
// console.log(blockStyles.marginLeft);
return;
}
let updatedSortOrder = blocks[source.index].sort_order;
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
else if (destination.index === blocks.length - 1)
updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
});
};
return (
<div
className="relative z-[5] mt-[72px] h-full overflow-hidden overflow-y-auto"
style={{ width: `${itemsContainerWidth}px` }}
>
<DragDropContext onDragEnd={handleOrderChange}>
<StrictModeDroppable droppableId="gantt">
{(droppableProvided, droppableSnapshot) => (
<div
className="w-full space-y-2"
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
>
<>
{blocks &&
blocks.length > 0 &&
blocks.map(
(block, index: number) =>
block.start_date &&
block.target_date && (
<Draggable
key={`block-${block.id}`}
draggableId={`block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided) => (
<div
className={
droppableSnapshot.isDraggingOver ? "bg-custom-border-100/10" : ""
}
ref={provided.innerRef}
{...provided.draggableProps}
>
<ChartDraggable
block={block}
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
enableLeftDrag={enableLeftDrag}
enableRightDrag={enableRightDrag}
provided={provided}
>
<div
className="rounded shadow-sm bg-custom-background-80 overflow-hidden h-9 flex items-center transition-all"
style={{
width: `${block.position?.width}px`,
}}
>
{blockRender({
...block.data,
})}
</div>
</ChartDraggable>
</div>
)}
</Draggable>
)
)}
{droppableProvided.placeholder}
</>
</div>
)}
</StrictModeDroppable>
</DragDropContext>
{/* sidebar */}
{/* <div className="fixed top-0 bottom-0 w-[300px] flex-shrink-0 divide-y divide-custom-border-200 border-r border-custom-border-200 overflow-y-auto">
{blocks &&
blocks.length > 0 &&
blocks.map((block: any, _idx: number) => (
<div className="relative h-[40px] bg-custom-background-100" key={`sidebar-blocks-${_idx}`}>
{sidebarBlockRender(block?.data)}
</div>
))}
</div> */}
</div>
);
};

View File

@ -1,205 +0,0 @@
import React, { useRef, useState } from "react";
// react-beautiful-dnd
import { DraggableProvided } from "react-beautiful-dnd";
import { useChart } from "../hooks";
// types
import { IGanttBlock } from "../types";
type Props = {
children: any;
block: IGanttBlock;
handleBlock: (totalBlockShifts: number, dragDirection: "left" | "right") => void;
enableLeftDrag: boolean;
enableRightDrag: boolean;
provided: DraggableProvided;
};
export const ChartDraggable: React.FC<Props> = ({
children,
block,
handleBlock,
enableLeftDrag = true,
enableRightDrag = true,
provided,
}) => {
const [isLeftResizing, setIsLeftResizing] = useState(false);
const [isRightResizing, setIsRightResizing] = useState(false);
const parentDivRef = useRef<HTMLDivElement>(null);
const resizableRef = useRef<HTMLDivElement>(null);
const { currentViewData } = useChart();
const checkScrollEnd = (e: MouseEvent): number => {
let delWidth = 0;
const scrollContainer = document.querySelector("#scroll-container") as HTMLElement;
const appSidebar = document.querySelector("#app-sidebar") as HTMLElement;
const posFromLeft = e.clientX;
// manually scroll to left if reached the left end while dragging
if (posFromLeft - appSidebar.clientWidth <= 70) {
if (e.movementX > 0) return 0;
delWidth = -5;
scrollContainer.scrollBy(delWidth, 0);
} else delWidth = e.movementX;
// manually scroll to right if reached the right end while dragging
const posFromRight = window.innerWidth - e.clientX;
if (posFromRight <= 70) {
if (e.movementX < 0) return 0;
delWidth = 5;
scrollContainer.scrollBy(delWidth, 0);
} else delWidth = e.movementX;
return delWidth;
};
const handleLeftDrag = () => {
if (!currentViewData || !resizableRef.current || !parentDivRef.current || !block.position)
return;
const resizableDiv = resizableRef.current;
const parentDiv = parentDivRef.current;
const columnWidth = currentViewData.data.width;
const blockInitialWidth =
resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
let initialMarginLeft = parseInt(parentDiv.style.marginLeft);
const handleMouseMove = (e: MouseEvent) => {
if (!window) return;
let delWidth = 0;
delWidth = checkScrollEnd(e);
// calculate new width and update the initialMarginLeft using -=
const newWidth = Math.round((initialWidth -= delWidth) / columnWidth) * columnWidth;
// calculate new marginLeft and update the initial marginLeft to the newly calculated one
const newMarginLeft = initialMarginLeft - (newWidth - (block.position?.width ?? 0));
initialMarginLeft = newMarginLeft;
// block needs to be at least 1 column wide
if (newWidth < columnWidth) return;
resizableDiv.style.width = `${newWidth}px`;
parentDiv.style.marginLeft = `${newMarginLeft}px`;
if (block.position) {
block.position.width = newWidth;
block.position.marginLeft = newMarginLeft;
}
};
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
const totalBlockShifts = Math.ceil(
(resizableDiv.clientWidth - blockInitialWidth) / columnWidth
);
handleBlock(totalBlockShifts, "left");
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
const handleRightDrag = () => {
if (!currentViewData || !resizableRef.current || !parentDivRef.current || !block.position)
return;
const resizableDiv = resizableRef.current;
const columnWidth = currentViewData.data.width;
const blockInitialWidth =
resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
const handleMouseMove = (e: MouseEvent) => {
if (!window) return;
let delWidth = 0;
delWidth = checkScrollEnd(e);
// calculate new width and update the initialMarginLeft using +=
const newWidth = Math.round((initialWidth += delWidth) / columnWidth) * columnWidth;
// block needs to be at least 1 column wide
if (newWidth < columnWidth) return;
resizableDiv.style.width = `${Math.max(newWidth, 80)}px`;
if (block.position) block.position.width = Math.max(newWidth, 80);
};
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
const totalBlockShifts = Math.ceil(
(resizableDiv.clientWidth - blockInitialWidth) / columnWidth
);
handleBlock(totalBlockShifts, "right");
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
return (
<div
id={`block-${block.id}`}
ref={parentDivRef}
className="relative group inline-flex cursor-pointer items-center font-medium transition-all"
style={{
marginLeft: `${block.position?.marginLeft}px`,
}}
>
{enableLeftDrag && (
<>
<div
onMouseDown={handleLeftDrag}
onMouseEnter={() => setIsLeftResizing(true)}
onMouseLeave={() => setIsLeftResizing(false)}
className="absolute top-1/2 -left-2.5 -translate-y-1/2 z-[1] w-6 h-10 bg-brand-backdrop rounded-md cursor-col-resize"
/>
<div
className={`absolute top-1/2 -translate-y-1/2 w-1 h-4/5 rounded-sm bg-custom-background-80 transition-all duration-300 ${
isLeftResizing ? "-left-2.5" : "left-1"
}`}
/>
</>
)}
{React.cloneElement(children, { ref: resizableRef, ...provided.dragHandleProps })}
{enableRightDrag && (
<>
<div
onMouseDown={handleRightDrag}
onMouseEnter={() => setIsRightResizing(true)}
onMouseLeave={() => setIsRightResizing(false)}
className="absolute top-1/2 -right-2.5 -translate-y-1/2 z-[1] w-6 h-6 bg-brand-backdrop rounded-md cursor-col-resize"
/>
<div
className={`absolute top-1/2 -translate-y-1/2 w-1 h-4/5 rounded-sm bg-custom-background-80 transition-all duration-300 ${
isRightResizing ? "-right-2.5" : "right-1"
}`}
/>
</>
)}
</div>
);
};

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,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,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,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,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,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,122 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { useForm, Controller } from "react-hook-form";
// services
import issuesServices from "services/issues.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { SecondaryButton } from "components/ui";
// types
import type { ICurrentUserResponse, IIssueComment } from "types";
// fetch-keys
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
(props, ref) => <Tiptap {...props} forwardedRef={ref} />
);
TiptapEditor.displayName = "TiptapEditor";
const defaultValues: Partial<IIssueComment> = {
comment_json: "",
comment_html: "",
};
type Props = {
issueId: string;
user: ICurrentUserResponse | undefined;
disabled?: boolean;
};
export const AddComment: React.FC<Props> = ({ issueId, user, disabled = false }) => {
const {
handleSubmit,
control,
setValue,
watch,
formState: { isSubmitting },
reset,
} = useForm<IIssueComment>({ defaultValues });
const editorRef = React.useRef<any>(null);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const onSubmit = async (formData: IIssueComment) => {
if (
!workspaceSlug ||
!projectId ||
!issueId ||
isSubmitting ||
!formData.comment_html ||
!formData.comment_json
)
return;
await issuesServices
.createIssueComment(
workspaceSlug as string,
projectId as string,
issueId as string,
formData,
user
)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
reset(defaultValues);
editorRef.current?.clearEditor();
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Comment could not be posted. Please try again.",
})
);
};
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<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,85 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import projectService from "services/project.service";
// ui
import { CustomSearchSelect } from "components/ui";
import { AssigneesList, Avatar } from "components/ui/avatar";
// icons
import { UserGroupIcon } from "@heroicons/react/24/outline";
// types
import { UserAuth } from "types";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
value: string[];
onChange: (val: string[]) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarAssigneeSelect: React.FC<Props> = ({
value,
onChange,
userAuth,
disabled = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const options = members?.map((member) => ({
value: member.member.id,
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
}));
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">
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
<p>Assignees</p>
</div>
<div className="sm:basis-1/2">
<CustomSearchSelect
value={value}
label={
<div className="flex items-center gap-2 text-custom-text-200">
{value && value.length > 0 && Array.isArray(value) ? (
<div className="-my-0.5 flex items-center justify-center gap-2">
<AssigneesList userIds={value} length={3} showLength={false} />
<span className="text-custom-text-100">{value.length} Assignees</span>
</div>
) : (
"No assignees"
)}
</div>
}
options={options}
onChange={onChange}
position="right"
multiple
disabled={isNotAllowed}
/>
</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,80 +0,0 @@
import React from "react";
// hooks
import useEstimateOption from "hooks/use-estimate-option";
// ui
import { CustomSelect } from "components/ui";
// icons
import { PlayIcon } from "@heroicons/react/24/outline";
// types
import { UserAuth } from "types";
type Props = {
value: number | null;
onChange: (val: number | null) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarEstimateSelect: React.FC<Props> = ({
value,
onChange,
userAuth,
disabled = false,
}) => {
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
const { isEstimateActive, estimatePoints } = useEstimateOption();
if (!isEstimateActive) return null;
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">
<PlayIcon className="h-4 w-4 flex-shrink-0 -rotate-90" />
<p>Estimate</p>
</div>
<div className="sm:basis-1/2">
<CustomSelect
value={value}
label={
<div className="flex items-center gap-2 text-xs">
<PlayIcon
className={`h-4 w-4 -rotate-90 ${
value !== null ? "text-custom-text-100" : "text-custom-text-200"
}`}
/>
{estimatePoints?.find((e) => e.key === value)?.value ?? (
<span className="text-custom-text-200">No estimates</span>
)}
</div>
}
onChange={onChange}
position="right"
width="w-full"
disabled={isNotAllowed || disabled}
>
<CustomSelect.Option value={null}>
<>
<span>
<PlayIcon className="h-4 w-4 -rotate-90" />
</span>
None
</>
</CustomSelect.Option>
{estimatePoints &&
estimatePoints.map((point) => (
<CustomSelect.Option key={point.key} value={point.key}>
<>
<span>
<PlayIcon className="h-4 w-4 -rotate-90" />
</span>
{point.value}
</>
</CustomSelect.Option>
))}
</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,64 +0,0 @@
import React from "react";
// ui
import { CustomSelect } from "components/ui";
// icons
import { ChartBarIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon } from "components/icons/priority-icon";
// types
import { UserAuth } from "types";
// constants
import { PRIORITIES } from "constants/project";
type Props = {
value: string | null;
onChange: (val: string) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarPrioritySelect: React.FC<Props> = ({
value,
onChange,
userAuth,
disabled = false,
}) => {
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">
<ChartBarIcon className="h-4 w-4 flex-shrink-0" />
<p>Priority</p>
</div>
<div className="sm:basis-1/2">
<CustomSelect
label={
<div className="flex items-center gap-2 text-left capitalize">
<span className={`${value ? "text-custom-text-100" : "text-custom-text-200"}`}>
{getPriorityIcon(value ?? "None", "text-sm")}
</span>
<span className={`${value ? "text-custom-text-100" : "text-custom-text-200"}`}>
{value ?? "None"}
</span>
</div>
}
value={value}
onChange={onChange}
width="w-full"
position="right"
disabled={isNotAllowed}
>
{PRIORITIES.map((option) => (
<CustomSelect.Option key={option} value={option} className="capitalize">
<>
{getPriorityIcon(option, "text-sm")}
{option ?? "None"}
</>
</CustomSelect.Option>
))}
</CustomSelect>
</div>
</div>
);
};

View File

@ -1,104 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import stateService from "services/state.service";
// ui
import { Spinner, CustomSelect } from "components/ui";
// icons
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { getStateGroupIcon } from "components/icons";
// helpers
import { getStatesList } from "helpers/state.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { UserAuth } from "types";
// constants
import { STATES_LIST } from "constants/fetch-keys";
type Props = {
value: string;
onChange: (val: string) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarStateSelect: React.FC<Props> = ({
value,
onChange,
userAuth,
disabled = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, inboxIssueId } = 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 selectedState = states?.find((s) => s.id === value);
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">
<Squares2X2Icon className="h-4 w-4 flex-shrink-0" />
<p>State</p>
</div>
<div className="sm:basis-1/2">
<CustomSelect
label={
selectedState ? (
<div className="flex items-center gap-2 text-left text-custom-text-100">
{getStateGroupIcon(
selectedState?.group ?? "backlog",
"16",
"16",
selectedState?.color ?? ""
)}
{addSpaceIfCamelCase(selectedState?.name ?? "")}
</div>
) : inboxIssueId ? (
<div className="flex items-center gap-2 text-left text-custom-text-100">
{getStateGroupIcon("backlog", "16", "16", "#ff7700")}
Triage
</div>
) : (
"None"
)
}
value={value}
onChange={onChange}
width="w-full"
position="right"
disabled={isNotAllowed}
>
{states ? (
states.length > 0 ? (
states.map((state) => (
<CustomSelect.Option key={state.id} value={state.id}>
<>
{getStateGroupIcon(state.group, "16", "16", state.color)}
{state.name}
</>
</CustomSelect.Option>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
</div>
</div>
);
};

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,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,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 +0,0 @@
NEXT_PUBLIC_API_BASE_URL=''

View File

@ -1,66 +0,0 @@
// next imports
import Link from "next/link";
import Image from "next/image";
import { Metadata, ResolvingMetadata } from "next";
// components
import IssueNavbar from "components/issues/navbar";
import IssueFilter from "components/issues/filters-render";
// service
import ProjectService from "services/project.service";
import { redirect } from "next/navigation";
type LayoutProps = {
params: { workspace_slug: string; project_slug: string };
};
export async function generateMetadata({ params }: LayoutProps): Promise<Metadata> {
// read route params
const { workspace_slug, project_slug } = params;
const projectServiceInstance = new ProjectService();
try {
const project = await projectServiceInstance?.getProjectSettingsAsync(workspace_slug, project_slug);
return {
title: `${project?.project_details?.name} | ${workspace_slug}`,
description: `${
project?.project_details?.description || `${project?.project_details?.name} | ${workspace_slug}`
}`,
icons: `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${
typeof project?.project_details?.emoji != "object"
? String.fromCodePoint(parseInt(project?.project_details?.emoji))
: "✈️"
}</text></svg>`,
};
} catch (error: any) {
if (error?.data?.error) {
redirect(`/project-not-published`);
}
return {};
}
}
const RootLayout = ({ children }: { children: React.ReactNode }) => (
<div className="relative w-screen min-h-[500px] h-screen overflow-hidden flex flex-col">
<div className="flex-shrink-0 h-[60px] border-b border-gray-300 relative flex items-center bg-white select-none">
<IssueNavbar />
</div>
{/* <div className="flex-shrink-0 min-h-[50px] h-auto py-1.5 border-b border-gray-300 relative flex items-center shadow-md bg-white select-none">
<IssueFilter />
</div> */}
<div className="w-full h-full relative bg-gray-100/50 overflow-hidden">{children}</div>
<div className="absolute z-[99999] bottom-[10px] right-[10px] bg-white rounded-sm shadow-lg border border-gray-100">
<Link href="https://plane.so" className="p-1 px-2 flex items-center gap-1" target="_blank">
<div className="w-[24px] h-[24px] relative flex justify-center items-center">
<Image src="/plane-logo.webp" alt="plane logo" className="w-[24px] h-[24px]" height="24" width="24" />
</div>
<div className="text-xs">
Powered by <b>Plane Deploy</b>
</div>
</Link>
</div>
</div>
);
export default RootLayout;

View File

@ -1,113 +0,0 @@
"use client";
import { useEffect } from "react";
// next imports
import { useRouter, useParams, useSearchParams } from "next/navigation";
// mobx
import { observer } from "mobx-react-lite";
// components
import { IssueListView } from "components/issues/board-views/list";
import { IssueKanbanView } from "components/issues/board-views/kanban";
import { IssueCalendarView } from "components/issues/board-views/calendar";
import { IssueSpreadsheetView } from "components/issues/board-views/spreadsheet";
import { IssueGanttView } from "components/issues/board-views/gantt";
// mobx store
import { RootStore } from "store/root";
import { useMobxStore } from "lib/mobx/store-provider";
// types
import { TIssueBoardKeys } from "store/types";
const WorkspaceProjectPage = observer(() => {
const store: RootStore = useMobxStore();
const router = useRouter();
const routerParams = useParams();
const routerSearchparams = useSearchParams();
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const board =
routerSearchparams &&
routerSearchparams.get("board") != null &&
(routerSearchparams.get("board") as TIssueBoardKeys | "");
// updating default board view when we are in the issues page
useEffect(() => {
if (workspace_slug && project_slug && store?.project?.workspaceProjectSettings) {
const workspacePRojectSettingViews = store?.project?.workspaceProjectSettings?.views;
const userAccessViews: TIssueBoardKeys[] = [];
Object.keys(workspacePRojectSettingViews).filter((_key) => {
if (_key === "list" && workspacePRojectSettingViews.list === true) userAccessViews.push(_key);
if (_key === "kanban" && workspacePRojectSettingViews.kanban === true) userAccessViews.push(_key);
if (_key === "calendar" && workspacePRojectSettingViews.calendar === true) userAccessViews.push(_key);
if (_key === "spreadsheet" && workspacePRojectSettingViews.spreadsheet === true) userAccessViews.push(_key);
if (_key === "gantt" && workspacePRojectSettingViews.gantt === true) userAccessViews.push(_key);
});
if (userAccessViews && userAccessViews.length > 0) {
if (!board) {
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
router.replace(`/${workspace_slug}/${project_slug}?board=${userAccessViews[0]}`);
} else {
if (userAccessViews.includes(board)) {
if (store.issue.currentIssueBoardView === null) store.issue.setCurrentIssueBoardView(board);
else {
if (board === store.issue.currentIssueBoardView)
router.replace(`/${workspace_slug}/${project_slug}?board=${board}`);
else {
store.issue.setCurrentIssueBoardView(board);
router.replace(`/${workspace_slug}/${project_slug}?board=${board}`);
}
}
} else {
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
router.replace(`/${workspace_slug}/${project_slug}?board=${userAccessViews[0]}`);
}
}
}
}
}, [workspace_slug, project_slug, board, router, store?.issue, store?.project?.workspaceProjectSettings]);
useEffect(() => {
if (workspace_slug && project_slug) {
store?.project?.getProjectSettingsAsync(workspace_slug, project_slug);
store?.issue?.getIssuesAsync(workspace_slug, project_slug);
}
}, [workspace_slug, project_slug, store?.project, store?.issue]);
return (
<div className="relative w-full h-full overflow-hidden">
{store?.issue?.loader && !store.issue.issues ? (
<div className="text-sm text-center py-10 text-gray-500">Loading...</div>
) : (
<>
{store?.issue?.error ? (
<div className="text-sm text-center py-10 text-gray-500">Something went wrong.</div>
) : (
store?.issue?.currentIssueBoardView && (
<>
{store?.issue?.currentIssueBoardView === "list" && (
<div className="relative w-full h-full overflow-y-auto">
<div className="container mx-auto px-5 py-3">
<IssueListView />
</div>
</div>
)}
{store?.issue?.currentIssueBoardView === "kanban" && (
<div className="relative w-full h-full mx-auto px-5">
<IssueKanbanView />
</div>
)}
{store?.issue?.currentIssueBoardView === "calendar" && <IssueCalendarView />}
{store?.issue?.currentIssueBoardView === "spreadsheet" && <IssueSpreadsheetView />}
{store?.issue?.currentIssueBoardView === "gantt" && <IssueGanttView />}
</>
)
)}
</>
)}
</div>
);
});
export default WorkspaceProjectPage;

View File

@ -1,20 +0,0 @@
"use client";
// root styles
import "styles/globals.css";
// mobx store provider
import { MobxStoreProvider } from "lib/mobx/store-provider";
import MobxStoreInit from "lib/mobx/store-init";
const RootLayout = ({ children }: { children: React.ReactNode }) => (
<html lang="en">
<body className="antialiased w-100">
<MobxStoreProvider>
<MobxStoreInit />
<main>{children}</main>
</MobxStoreProvider>
</body>
</html>
);
export default RootLayout;

View File

@ -1,9 +0,0 @@
"use client";
import React from "react";
const HomePage = () => (
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">Plane Deploy</div>
);
export default HomePage;

View File

@ -1,5 +0,0 @@
export * from "./issue-group/backlog-state-icon";
export * from "./issue-group/unstarted-state-icon";
export * from "./issue-group/started-state-icon";
export * from "./issue-group/completed-state-icon";
export * from "./issue-group/cancelled-state-icon";

View File

@ -1,32 +0,0 @@
"use client";
// helpers
import { renderDateFormat } from "constants/helpers";
export const findHowManyDaysLeft = (date: string | Date) => {
const today = new Date();
const eventDate = new Date(date);
const timeDiff = Math.abs(eventDate.getTime() - today.getTime());
return Math.ceil(timeDiff / (1000 * 3600 * 24));
};
const validDate = (date: any, state: string): string => {
if (date === null || ["backlog", "unstarted", "cancelled"].includes(state))
return `bg-gray-500/10 text-gray-500 border-gray-500/50`;
else {
const today = new Date();
const dueDate = new Date(date);
if (dueDate < today) return `bg-red-500/10 text-red-500 border-red-500/50`;
else return `bg-green-500/10 text-green-500 border-green-500/50`;
}
};
export const IssueBlockDueDate = ({ due_date, state }: any) => (
<div
className={`h-[24px] rounded-sm flex px-2 items-center border border-gray-300 gap-1 text-gray-700 text-xs font-medium
${validDate(due_date, state)}`}
>
{renderDateFormat(due_date)}
</div>
);

View File

@ -1,17 +0,0 @@
"use client";
export const IssueBlockLabels = ({ labels }: any) => (
<div className="relative flex items-center flex-wrap gap-1">
{labels &&
labels.length > 0 &&
labels.map((_label: any) => (
<div
className={`h-[24px] rounded-sm flex px-1 items-center border gap-1 !bg-transparent !text-gray-700`}
style={{ backgroundColor: `${_label?.color}10`, borderColor: `${_label?.color}50` }}
>
<div className="w-[10px] h-[10px] rounded-full" style={{ backgroundColor: `${_label?.color}` }} />
<div className="text-sm">{_label?.name}</div>
</div>
))}
</div>
);

View File

@ -1,18 +0,0 @@
"use client";
// constants
import { issueGroupFilter } from "constants/data";
export const IssueBlockState = ({ state }: any) => {
const stateGroup = issueGroupFilter(state.group);
if (stateGroup === null) return <></>;
return (
<div
className={`h-[24px] rounded-sm flex px-1 items-center border ${stateGroup?.className} gap-1 !bg-transparent !text-gray-700`}
>
<stateGroup.icon />
<div className="text-sm">{state?.name}</div>
</div>
);
};

View File

@ -1,57 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// components
import { IssueBlockPriority } from "components/issues/board-views/block-priority";
import { IssueBlockState } from "components/issues/board-views/block-state";
import { IssueBlockLabels } from "components/issues/board-views/block-labels";
import { IssueBlockDueDate } from "components/issues/board-views/block-due-date";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
// interfaces
import { IIssue } from "store/types/issue";
import { RootStore } from "store/root";
export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
const store: RootStore = useMobxStore();
return (
<div className="p-2 px-3 bg-white space-y-2 rounded-sm shadow">
{/* id */}
<div className="flex-shrink-0 text-sm text-gray-600 w-[60px]">
{store?.project?.project?.identifier}-{issue?.sequence_id}
</div>
{/* name */}
<div className="font-medium text-gray-800 h-full line-clamp-2">{issue.name}</div>
{/* priority */}
<div className="relative flex flex-wrap items-center gap-2 w-full">
{issue?.priority && (
<div className="flex-shrink-0">
<IssueBlockPriority priority={issue?.priority} />
</div>
)}
{/* state */}
{issue?.state_detail && (
<div className="flex-shrink-0">
<IssueBlockState state={issue?.state_detail} />
</div>
)}
{/* labels */}
{issue?.label_details && issue?.label_details.length > 0 && (
<div className="flex-shrink-0">
<IssueBlockLabels labels={issue?.label_details} />
</div>
)}
{/* due date */}
{issue?.target_date && (
<div className="flex-shrink-0">
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
</div>
)}
</div>
</div>
);
};

View File

@ -1,59 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// components
import { IssueBlockPriority } from "components/issues/board-views/block-priority";
import { IssueBlockState } from "components/issues/board-views/block-state";
import { IssueBlockLabels } from "components/issues/board-views/block-labels";
import { IssueBlockDueDate } from "components/issues/board-views/block-due-date";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
// interfaces
import { IIssue } from "store/types/issue";
import { RootStore } from "store/root";
export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
const store: RootStore = useMobxStore();
return (
<div className="p-2 px-3 relative flex items-center gap-3">
<div className="relative flex items-center gap-3 w-full">
{/* id */}
<div className="flex-shrink-0 text-sm text-gray-600 w-[60px]">
{store?.project?.project?.identifier}-{issue?.sequence_id}
</div>
{/* name */}
<div className="font-medium text-gray-800 h-full line-clamp-1">{issue.name}</div>
</div>
{/* priority */}
{issue?.priority && (
<div className="flex-shrink-0">
<IssueBlockPriority priority={issue?.priority} />
</div>
)}
{/* state */}
{issue?.state_detail && (
<div className="flex-shrink-0">
<IssueBlockState state={issue?.state_detail} />
</div>
)}
{/* labels */}
{issue?.label_details && issue?.label_details.length > 0 && (
<div className="flex-shrink-0">
<IssueBlockLabels labels={issue?.label_details} />
</div>
)}
{/* due date */}
{issue?.target_date && (
<div className="flex-shrink-0">
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
</div>
)}
</div>
);
};

View File

@ -1,38 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// components
import { IssueListHeader } from "components/issues/board-views/list/header";
import { IssueListBlock } from "components/issues/board-views/list/block";
// interfaces
import { IIssueState, IIssue } from "store/types/issue";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
export const IssueListView = observer(() => {
const store: RootStore = useMobxStore();
return (
<>
{store?.issue?.states &&
store?.issue?.states.length > 0 &&
store?.issue?.states.map((_state: IIssueState) => (
<div className="relative w-full">
<IssueListHeader state={_state} />
{store.issue.getFilteredIssuesByState(_state.id) &&
store.issue.getFilteredIssuesByState(_state.id).length > 0 ? (
<div className="bg-white divide-y">
{store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
<IssueListBlock issue={_issue} />
))}
</div>
) : (
<div className="bg-white p-5 text-sm text-gray-600">No Issues are available.</div>
)}
</div>
))}
</>
);
});

View File

@ -1,38 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
const IssueDateFilter = observer(() => {
const store = useMobxStore();
return (
<>
<div className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded">
<div className="flex-shrink-0 font-medium">Due Date</div>
<div className="relative flex flex-wrap items-center gap-1">
{/* <div className="flex items-center gap-1 border border-gray-300 px-[2px] py-0.5 rounded-full">
<div className="w-[18px] h-[18px] cursor-pointer flex justify-center items-center overflow-hidden rounded-full border border-gray-300">
<span className={`material-symbols-rounded text-[16px]`}>close</span>
</div>
<div>Backlog</div>
<div
className={`w-[18px] h-[18px] cursor-pointer flex justify-center items-center overflow-hidden rounded-full text-gray-500 hover:bg-gray-200/60 hover:text-gray-600`}
>
<span className={`material-symbols-rounded text-[16px]`}>close</span>
</div>
</div> */}
</div>
<div
className={`w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-sm text-gray-500 hover:bg-gray-200/60 hover:text-gray-600`}
>
<span className={`material-symbols-rounded text-[16px]`}>close</span>
</div>
</div>
</>
);
});
export default IssueDateFilter;

View File

@ -1,40 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// components
import IssueStateFilter from "./state";
import IssueLabelFilter from "./label";
import IssuePriorityFilter from "./priority";
import IssueDateFilter from "./date";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
const IssueFilter = observer(() => {
const store: RootStore = useMobxStore();
const clearAllFilters = () => {};
return (
<div className="container mx-auto px-5 flex justify-start items-center flex-wrap gap-2 text-sm">
{/* state */}
{store?.issue?.states && <IssueStateFilter />}
{/* labels */}
{store?.issue?.labels && <IssueLabelFilter />}
{/* priority */}
<IssuePriorityFilter />
{/* due date */}
<IssueDateFilter />
{/* clear all filters */}
<div
className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded cursor-pointer hover:bg-gray-200/60"
onClick={clearAllFilters}
>
<div>Clear all filters</div>
</div>
</div>
);
});
export default IssueFilter;

View File

@ -1,34 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
// interfaces
import { IIssueLabel } from "store/types/issue";
// constants
import { issueGroupFilter } from "constants/data";
export const RenderIssueLabel = observer(({ label }: { label: IIssueLabel }) => {
const store = useMobxStore();
const removeLabelFromFilter = () => {};
return (
<div
className="flex-shrink-0 relative flex items-center flex-wrap gap-1 border px-[2px] py-0.5 rounded-full select-none"
style={{ color: label?.color, backgroundColor: `${label?.color}10`, borderColor: `${label?.color}50` }}
>
<div className="flex-shrink-0 w-[20px] h-[20px] flex justify-center items-center overflow-hidden rounded-full">
<div className="w-[10px] h-[10px] rounded-full" style={{ backgroundColor: `${label?.color}` }} />
</div>
<div className="text-sm font-medium whitespace-nowrap">{label?.name}</div>
<div
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-full text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
onClick={removeLabelFromFilter}
>
<span className="material-symbols-rounded text-[14px]">close</span>
</div>
</div>
);
});

View File

@ -1,37 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// components
import { RenderIssueLabel } from "./filter-label-block";
// interfaces
import { IIssueLabel } from "store/types/issue";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
const IssueLabelFilter = observer(() => {
const store: RootStore = useMobxStore();
const clearLabelFilters = () => {};
return (
<>
<div className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded">
<div className="flex-shrink-0 font-medium">Labels</div>
<div className="relative flex flex-wrap items-center gap-1">
{store?.issue?.labels &&
store?.issue?.labels.map((_label: IIssueLabel, _index: number) => <RenderIssueLabel label={_label} />)}
</div>
<div
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-sm text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
onClick={clearLabelFilters}
>
<span className="material-symbols-rounded text-[16px]">close</span>
</div>
</div>
</>
);
});
export default IssueLabelFilter;

View File

@ -1,33 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
// interfaces
import { IIssuePriorityFilters } from "store/types/issue";
export const RenderIssuePriority = observer(({ priority }: { priority: IIssuePriorityFilters }) => {
const store = useMobxStore();
const removePriorityFromFilter = () => {};
return (
<div
className={`flex-shrink-0 relative flex items-center flex-wrap gap-1 border px-[2px] py-0.5 rounded-full select-none ${
priority.className || ``
}`}
>
<div className="flex-shrink-0 w-[20px] h-[20px] flex justify-center items-center overflow-hidden rounded-full">
<span className="material-symbols-rounded text-[14px]">{priority?.icon}</span>
</div>
<div className="text-sm font-medium whitespace-nowrap">{priority?.title}</div>
<div
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-full text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
onClick={removePriorityFromFilter}
>
<span className="material-symbols-rounded text-[14px]">close</span>
</div>
</div>
);
});

View File

@ -1,36 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { RenderIssuePriority } from "./filter-priority-block";
// interfaces
import { IIssuePriorityFilters } from "store/types/issue";
// constants
import { issuePriorityFilters } from "constants/data";
const IssuePriorityFilter = observer(() => {
const store = useMobxStore();
return (
<>
<div className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded">
<div className="flex-shrink-0 font-medium">Priority</div>
<div className="relative flex flex-wrap items-center gap-1">
{issuePriorityFilters.map((_priority: IIssuePriorityFilters, _index: number) => (
<RenderIssuePriority priority={_priority} />
))}
</div>
<div
className={`w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-sm text-gray-500 hover:bg-gray-200/60 hover:text-gray-600`}
>
<span className={`material-symbols-rounded text-[16px]`}>close</span>
</div>
</div>{" "}
</>
);
});
export default IssuePriorityFilter;

View File

@ -1,38 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
// interfaces
import { IIssueState } from "store/types/issue";
// constants
import { issueGroupFilter } from "constants/data";
export const RenderIssueState = observer(({ state }: { state: IIssueState }) => {
const store = useMobxStore();
const stateGroup = issueGroupFilter(state.group);
const removeStateFromFilter = () => {};
if (stateGroup === null) return <></>;
return (
<div
className={`flex-shrink-0 relative flex items-center flex-wrap gap-1 border px-[2px] py-0.5 rounded-full select-none ${
stateGroup.className || ``
}`}
>
<div className="flex-shrink-0 w-[20px] h-[20px] flex justify-center items-center overflow-hidden rounded-full">
<stateGroup.icon />
</div>
<div className="text-sm font-medium whitespace-nowrap">{state?.name}</div>
<div
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-full text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
onClick={removeStateFromFilter}
>
<span className="material-symbols-rounded text-[14px]">close</span>
</div>
</div>
);
});

View File

@ -1,37 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// components
import { RenderIssueState } from "./filter-state-block";
// interfaces
import { IIssueState } from "store/types/issue";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
const IssueStateFilter = observer(() => {
const store: RootStore = useMobxStore();
const clearStateFilters = () => {};
return (
<>
<div className="flex items-center gap-2 border border-gray-300 px-2 py-1 pr-1 rounded">
<div className="flex-shrink-0 font-medium">State</div>
<div className="relative flex flex-wrap items-center gap-1">
{store?.issue?.states &&
store?.issue?.states.map((_state: IIssueState, _index: number) => <RenderIssueState state={_state} />)}
</div>
<div
className="flex-shrink-0 w-[20px] h-[20px] cursor-pointer flex justify-center items-center overflow-hidden rounded-sm text-gray-500 hover:bg-gray-200/60 hover:text-gray-600"
onClick={clearStateFilters}
>
<span className="material-symbols-rounded text-[16px]">close</span>
</div>
</div>
</>
);
});
export default IssueStateFilter;

View File

@ -1,72 +0,0 @@
"use client";
// next imports
import Image from "next/image";
// components
import { NavbarSearch } from "./search";
import { NavbarIssueBoardView } from "./issue-board-view";
import { NavbarIssueFilter } from "./issue-filter";
import { NavbarIssueView } from "./issue-view";
import { NavbarTheme } from "./theme";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
const renderEmoji = (emoji: string | { name: string; color: string }) => {
if (!emoji) return;
if (typeof emoji === "object")
return (
<span style={{ color: emoji.color }} className="material-symbols-rounded text-lg">
{emoji.name}
</span>
);
else return isNaN(parseInt(emoji)) ? emoji : String.fromCodePoint(parseInt(emoji));
};
const IssueNavbar = observer(() => {
const store: RootStore = useMobxStore();
return (
<div className="px-5 relative w-full flex items-center gap-4">
{/* project detail */}
<div className="flex-shrink-0 flex items-center gap-2">
<div className="w-[32px] h-[32px] rounded-sm flex justify-center items-center bg-gray-100 text-[24px]">
{store?.project?.project && store?.project?.project?.emoji ? (
renderEmoji(store?.project?.project?.emoji)
) : (
<Image src="/plane-logo.webp" alt="plane logo" className="w-[24px] h-[24px]" height="24" width="24" />
)}
</div>
<div className="font-medium text-lg max-w-[300px] line-clamp-1 overflow-hidden">
{store?.project?.project?.name || `...`}
</div>
</div>
{/* issue search bar */}
<div className="w-full">
<NavbarSearch />
</div>
{/* issue views */}
<div className="flex-shrink-0 relative flex items-center gap-1 transition-all ease-in-out delay-150">
<NavbarIssueBoardView />
</div>
{/* issue filters */}
{/* <div className="flex-shrink-0 relative flex items-center gap-2">
<NavbarIssueFilter />
<NavbarIssueView />
</div> */}
{/* theming */}
{/* <div className="flex-shrink-0 relative">
<NavbarTheme />
</div> */}
</div>
);
});
export default IssueNavbar;

View File

@ -1,54 +0,0 @@
"use client";
// next imports
import { useRouter, useParams } from "next/navigation";
// mobx react lite
import { observer } from "mobx-react-lite";
// constants
import { issueViews } from "constants/data";
// interfaces
import { TIssueBoardKeys } from "store/types";
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
export const NavbarIssueBoardView = observer(() => {
const store: RootStore = useMobxStore();
const router = useRouter();
const routerParams = useParams();
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const handleCurrentBoardView = (boardView: TIssueBoardKeys) => {
store?.issue?.setCurrentIssueBoardView(boardView);
router.replace(`/${workspace_slug}/${project_slug}?board=${boardView}`);
};
return (
<>
{store?.project?.workspaceProjectSettings &&
issueViews &&
issueViews.length > 0 &&
issueViews.map(
(_view) =>
store?.project?.workspaceProjectSettings?.views[_view?.key] && (
<div
key={_view?.key}
className={`w-[28px] h-[28px] flex justify-center items-center rounded-sm cursor-pointer text-gray-500 ${
_view?.key === store?.issue?.currentIssueBoardView
? `bg-gray-200/60 text-gray-800`
: `hover:bg-gray-200/60 text-gray-600`
}`}
onClick={() => handleCurrentBoardView(_view?.key)}
title={_view?.title}
>
<span className={`material-symbols-rounded text-[18px] ${_view?.className ? _view?.className : ``}`}>
{_view?.icon}
</span>
</div>
)
)}
</>
);
});

View File

@ -1,13 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
export const NavbarIssueFilter = observer(() => {
const store: RootStore = useMobxStore();
return <div>Filter</div>;
});

View File

@ -1,28 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
export const NavbarTheme = observer(() => {
const store: RootStore = useMobxStore();
const handleTheme = () => {
store?.theme?.setTheme(store?.theme?.theme === "light" ? "dark" : "light");
};
return (
<div
className="relative w-[28px] h-[28px] flex justify-center items-center rounded-sm cursor-pointer bg-gray-100 hover:bg-gray-200 hover:bg-gray-200/60 text-gray-600 transition-all"
onClick={handleTheme}
>
{store?.theme?.theme === "light" ? (
<span className={`material-symbols-rounded text-[18px]`}>dark_mode</span>
) : (
<span className={`material-symbols-rounded text-[18px]`}>light_mode</span>
)}
</div>
);
});

View File

@ -1,13 +0,0 @@
export const renderDateFormat = (date: string | Date | null) => {
if (!date) return "N/A";
var d = new Date(date),
month = "" + (d.getMonth() + 1),
day = "" + d.getDate(),
year = d.getFullYear();
if (month.length < 2) month = "0" + month;
if (day.length < 2) day = "0" + day;
return [year, month, day].join("-");
};

View File

@ -1,21 +0,0 @@
"use client";
import { useEffect } from "react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
const MobxStoreInit = () => {
const store: RootStore = useMobxStore();
useEffect(() => {
// theme
const _theme = localStorage && localStorage.getItem("app_theme") ? localStorage.getItem("app_theme") : "light";
if (_theme && store?.theme?.theme != _theme) store.theme.setTheme(_theme);
else localStorage.setItem("app_theme", _theme && _theme != "light" ? "dark" : "light");
}, [store?.theme]);
return <></>;
};
export default MobxStoreInit;

View File

@ -1,14 +0,0 @@
/** @type {import('next').NextConfig} */
const path = require("path");
const nextConfig = {
reactStrictMode: false,
swcMinify: true,
experimental: {
outputFileTracingRoot: path.join(__dirname, "../../"),
appDir: true,
},
output: "standalone",
};
module.exports = nextConfig;

View File

@ -1,37 +0,0 @@
{
"name": "space",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev -p 4000",
"build": "next build",
"start": "next start -p 4000",
"lint": "next lint"
},
"dependencies": {
"@headlessui/react": "^1.7.13",
"@types/node": "18.14.1",
"@types/nprogress": "^0.2.0",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"axios": "^1.3.4",
"eslint": "8.34.0",
"eslint-config-next": "13.2.1",
"js-cookie": "^3.0.1",
"mobx": "^6.10.0",
"mobx-react-lite": "^4.0.3",
"next": "^13.4.16",
"nprogress": "^0.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "4.9.5",
"uuid": "^9.0.0"
},
"devDependencies": {
"@types/js-cookie": "^3.0.3",
"@types/uuid": "^9.0.1",
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.7"
}
}

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