mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of https://github.com/makeplane/plane into develop
This commit is contained in:
commit
649748f801
@ -1,10 +1,10 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
// This tells ESLint to load the config from the package `config`
|
||||
// extends: ["custom"],
|
||||
// This tells ESLint to load the config from the package `eslint-config-custom`
|
||||
extends: ["custom"],
|
||||
settings: {
|
||||
next: {
|
||||
rootDir: ["apps/*/"],
|
||||
rootDir: ["apps/*"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
7
.gitignore
vendored
7
.gitignore
vendored
@ -64,4 +64,9 @@ package-lock.json
|
||||
.vscode
|
||||
|
||||
# Sentry
|
||||
.sentryclirc
|
||||
.sentryclirc
|
||||
|
||||
# lock files
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
116
Dockerfile
Normal file
116
Dockerfile
Normal file
@ -0,0 +1,116 @@
|
||||
FROM node:18-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
|
||||
RUN turbo prune --scope=app --docker
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:18-alpine AS installer
|
||||
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install
|
||||
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
RUN yarn turbo run build --filter=app
|
||||
|
||||
|
||||
FROM python:3.11.1-alpine3.17 AS backend
|
||||
|
||||
# set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
RUN apk --update --no-cache add \
|
||||
"libpq~=15" \
|
||||
"libxslt~=1.1" \
|
||||
"nodejs-current~=19" \
|
||||
"xmlsec~=1.2" \
|
||||
"nginx" \
|
||||
"nodejs" \
|
||||
"npm" \
|
||||
"supervisor"
|
||||
|
||||
COPY apiserver/requirements.txt ./
|
||||
COPY apiserver/requirements ./requirements
|
||||
RUN apk add libffi-dev
|
||||
RUN apk --update --no-cache --virtual .build-deps add \
|
||||
"bash~=5.2" \
|
||||
"g++~=12.2" \
|
||||
"gcc~=12.2" \
|
||||
"cargo~=1.64" \
|
||||
"git~=2" \
|
||||
"make~=4.3" \
|
||||
"postgresql13-dev~=13" \
|
||||
"libc-dev" \
|
||||
"linux-headers" \
|
||||
&& \
|
||||
pip install -r requirements.txt --compile --no-cache-dir \
|
||||
&& \
|
||||
apk del .build-deps
|
||||
|
||||
# Add in Django deps and generate Django's static files
|
||||
COPY apiserver/manage.py manage.py
|
||||
COPY apiserver/plane plane/
|
||||
COPY apiserver/templates templates/
|
||||
|
||||
COPY apiserver/gunicorn.config.py ./
|
||||
RUN apk --update --no-cache add "bash~=5.2"
|
||||
COPY apiserver/bin ./bin/
|
||||
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker
|
||||
RUN chmod -R 777 /code
|
||||
|
||||
# Expose container port and run entry point script
|
||||
EXPOSE 8000
|
||||
EXPOSE 3000
|
||||
EXPOSE 80
|
||||
|
||||
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 plane
|
||||
RUN adduser --system --uid 1001 captain
|
||||
|
||||
COPY --from=installer /app/apps/app/next.config.js .
|
||||
COPY --from=installer /app/apps/app/package.json .
|
||||
|
||||
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
|
||||
|
||||
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# RUN rm /etc/nginx/conf.d/default.conf
|
||||
#######################################################################
|
||||
COPY nginx/nginx-single-docker-image.conf /etc/nginx/http.d/default.conf
|
||||
#######################################################################
|
||||
|
||||
COPY nginx/supervisor.conf /code/supervisor.conf
|
||||
|
||||
|
||||
CMD ["supervisord","-c","/code/supervisor.conf"]
|
||||
|
||||
|
||||
|
||||
|
@ -1,18 +1,22 @@
|
||||
# Backend
|
||||
SECRET_KEY="<-- django secret -->"
|
||||
DJANGO_SETTINGS_MODULE="plane.settings.production"
|
||||
# Database
|
||||
DATABASE_URL=postgres://plane:plane@plane-db-1:5432/plane
|
||||
# Cache
|
||||
REDIS_URL=redis://redis:6379/
|
||||
# SMPT
|
||||
EMAIL_HOST="<-- email smtp -->"
|
||||
EMAIL_HOST_USER="<-- email host user -->"
|
||||
EMAIL_HOST_PASSWORD="<-- email host password -->"
|
||||
|
||||
# AWS
|
||||
AWS_REGION="<-- aws region -->"
|
||||
AWS_ACCESS_KEY_ID="<-- aws access key -->"
|
||||
AWS_SECRET_ACCESS_KEY="<-- aws secret acess key -->"
|
||||
AWS_S3_BUCKET_NAME="<-- aws s3 bucket name -->"
|
||||
|
||||
SENTRY_DSN="<-- sentry dsn -->"
|
||||
WEB_URL="<-- frontend web url -->"
|
||||
|
||||
# FE
|
||||
WEB_URL="localhost/"
|
||||
# OAUTH
|
||||
GITHUB_CLIENT_SECRET="<-- github secret -->"
|
||||
|
||||
# Flags
|
||||
DISABLE_COLLECTSTATIC=1
|
||||
DOCKERIZED=0 //True if running docker compose else 0
|
||||
DOCKERIZED=1
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM python:3.8.14-alpine3.16 AS backend
|
||||
FROM python:3.11.1-alpine3.17 AS backend
|
||||
|
||||
# set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
@ -8,19 +8,19 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
WORKDIR /code
|
||||
|
||||
RUN apk --update --no-cache add \
|
||||
"libpq~=14" \
|
||||
"libpq~=15" \
|
||||
"libxslt~=1.1" \
|
||||
"nodejs-current~=18" \
|
||||
"nodejs-current~=19" \
|
||||
"xmlsec~=1.2"
|
||||
|
||||
COPY requirements.txt ./
|
||||
COPY requirements ./requirements
|
||||
RUN apk add libffi-dev
|
||||
RUN apk --update --no-cache --virtual .build-deps add \
|
||||
"bash~=5.1" \
|
||||
"g++~=11.2" \
|
||||
"gcc~=11.2" \
|
||||
"cargo~=1.60" \
|
||||
"bash~=5.2" \
|
||||
"g++~=12.2" \
|
||||
"gcc~=12.2" \
|
||||
"cargo~=1.64" \
|
||||
"git~=2" \
|
||||
"make~=4.3" \
|
||||
"postgresql13-dev~=13" \
|
||||
@ -46,15 +46,16 @@ COPY templates templates/
|
||||
|
||||
COPY gunicorn.config.py ./
|
||||
USER root
|
||||
RUN apk --update --no-cache add "bash~=5.1"
|
||||
RUN apk --update --no-cache add "bash~=5.2"
|
||||
COPY ./bin ./bin/
|
||||
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker
|
||||
RUN chmod -R 777 /code
|
||||
|
||||
USER captain
|
||||
|
||||
# Expose container port and run entry point script
|
||||
EXPOSE 8000
|
||||
|
||||
CMD [ "./bin/takeoff" ]
|
||||
# CMD [ "./bin/takeoff" ]
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
# All the python scripts that are used for back migrations
|
||||
import uuid
|
||||
import random
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from plane.db.models import ProjectIdentifier
|
||||
from plane.db.models import Issue, IssueComment, User
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
|
||||
# Update description and description html values for old descriptions
|
||||
@ -79,3 +80,19 @@ def update_user_empty_password():
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def updated_issue_sort_order():
|
||||
try:
|
||||
issues = Issue.objects.all()
|
||||
updated_issues = []
|
||||
|
||||
for issue in issues:
|
||||
issue.sort_order = issue.sequence_id * random.randint(100, 500)
|
||||
updated_issues.append(issue)
|
||||
|
||||
Issue.objects.bulk_update(updated_issues, ["sort_order"], batch_size=100)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
@ -2,4 +2,8 @@
|
||||
set -e
|
||||
python manage.py wait_for_db
|
||||
python manage.py migrate
|
||||
|
||||
# Create a Default User
|
||||
python bin/user_script.py
|
||||
|
||||
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
|
28
apiserver/bin/user_script.py
Normal file
28
apiserver/bin/user_script.py
Normal file
@ -0,0 +1,28 @@
|
||||
import os, sys
|
||||
import uuid
|
||||
|
||||
sys.path.append("/code")
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
def populate():
|
||||
default_email = os.environ.get("DEFAULT_EMAIL", "captain@plane.so")
|
||||
default_password = os.environ.get("DEFAULT_PASSWORD", "password123")
|
||||
|
||||
if not User.objects.filter(email=default_email).exists():
|
||||
user = User.objects.create(email=default_email, username=uuid.uuid4().hex)
|
||||
user.set_password(default_password)
|
||||
user.save()
|
||||
print("User created")
|
||||
|
||||
print("Success")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
populate()
|
@ -40,4 +40,13 @@ from .issue import (
|
||||
|
||||
from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer
|
||||
|
||||
from .api_token import APITokenSerializer
|
||||
from .api_token import APITokenSerializer
|
||||
|
||||
from .integration import (
|
||||
IntegrationSerializer,
|
||||
WorkspaceIntegrationSerializer,
|
||||
GithubIssueSyncSerializer,
|
||||
GithubRepositorySerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
||||
|
7
apiserver/plane/api/serializers/integration/__init__.py
Normal file
7
apiserver/plane/api/serializers/integration/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
from .base import IntegrationSerializer, WorkspaceIntegrationSerializer
|
||||
from .github import (
|
||||
GithubRepositorySerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubIssueSyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
20
apiserver/plane/api/serializers/integration/base.py
Normal file
20
apiserver/plane/api/serializers/integration/base.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Module imports
|
||||
from plane.api.serializers import BaseSerializer
|
||||
from plane.db.models import Integration, WorkspaceIntegration
|
||||
|
||||
|
||||
class IntegrationSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Integration
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"verified",
|
||||
]
|
||||
|
||||
|
||||
class WorkspaceIntegrationSerializer(BaseSerializer):
|
||||
integration_detail = IntegrationSerializer(read_only=True, source="integration")
|
||||
|
||||
class Meta:
|
||||
model = WorkspaceIntegration
|
||||
fields = "__all__"
|
45
apiserver/plane/api/serializers/integration/github.py
Normal file
45
apiserver/plane/api/serializers/integration/github.py
Normal file
@ -0,0 +1,45 @@
|
||||
# Module imports
|
||||
from plane.api.serializers import BaseSerializer
|
||||
from plane.db.models import (
|
||||
GithubIssueSync,
|
||||
GithubRepository,
|
||||
GithubRepositorySync,
|
||||
GithubCommentSync,
|
||||
)
|
||||
|
||||
|
||||
class GithubRepositorySerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = GithubRepository
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class GithubRepositorySyncSerializer(BaseSerializer):
|
||||
repo_detail = GithubRepositorySerializer(source="repository")
|
||||
|
||||
class Meta:
|
||||
model = GithubRepositorySync
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class GithubIssueSyncSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = GithubIssueSync
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"project",
|
||||
"workspace",
|
||||
"repository_sync",
|
||||
]
|
||||
|
||||
|
||||
class GithubCommentSyncSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = GithubCommentSync
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"project",
|
||||
"workspace",
|
||||
"repository_sync",
|
||||
"issue_sync",
|
||||
]
|
@ -24,9 +24,15 @@ from plane.db.models import (
|
||||
Cycle,
|
||||
Module,
|
||||
ModuleIssue,
|
||||
IssueLink,
|
||||
)
|
||||
|
||||
|
||||
class IssueLinkCreateSerializer(serializers.Serializer):
|
||||
url = serializers.CharField(required=True)
|
||||
title = serializers.CharField(required=False)
|
||||
|
||||
|
||||
class IssueFlatSerializer(BaseSerializer):
|
||||
## Contain only flat fields
|
||||
|
||||
@ -44,16 +50,6 @@ class IssueFlatSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
# Issue Serializer with state details
|
||||
class IssueStateSerializer(BaseSerializer):
|
||||
state_detail = StateSerializer(read_only=True, source="state")
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
##TODO: Find a better way to write this serializer
|
||||
## Find a better approach to save manytomany?
|
||||
class IssueCreateSerializer(BaseSerializer):
|
||||
@ -86,6 +82,11 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
links_list = serializers.ListField(
|
||||
child=IssueLinkCreateSerializer(),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
@ -104,6 +105,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
assignees = validated_data.pop("assignees_list", None)
|
||||
labels = validated_data.pop("labels_list", None)
|
||||
blocks = validated_data.pop("blocks_list", None)
|
||||
links = validated_data.pop("links_list", None)
|
||||
|
||||
project = self.context["project"]
|
||||
issue = Issue.objects.create(**validated_data, project=project)
|
||||
@ -172,6 +174,24 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
if links is not None:
|
||||
IssueLink.objects.bulk_create(
|
||||
[
|
||||
IssueLink(
|
||||
issue=issue,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
created_by=issue.created_by,
|
||||
updated_by=issue.updated_by,
|
||||
title=link.get("title", None),
|
||||
url=link.get("url", None),
|
||||
)
|
||||
for link in links
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
return issue
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@ -179,6 +199,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
assignees = validated_data.pop("assignees_list", None)
|
||||
labels = validated_data.pop("labels_list", None)
|
||||
blocks = validated_data.pop("blocks_list", None)
|
||||
links = validated_data.pop("links_list", None)
|
||||
|
||||
if blockers is not None:
|
||||
IssueBlocker.objects.filter(block=instance).delete()
|
||||
@ -248,6 +269,25 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
if links is not None:
|
||||
IssueLink.objects.filter(issue=instance).delete()
|
||||
IssueLink.objects.bulk_create(
|
||||
[
|
||||
IssueLink(
|
||||
issue=instance,
|
||||
project=instance.project,
|
||||
workspace=instance.project.workspace,
|
||||
created_by=instance.created_by,
|
||||
updated_by=instance.updated_by,
|
||||
title=link.get("title", None),
|
||||
url=link.get("url", None),
|
||||
)
|
||||
for link in links
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
@ -410,6 +450,26 @@ class IssueModuleDetailSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class IssueLinkSerializer(BaseSerializer):
|
||||
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
||||
|
||||
class Meta:
|
||||
model = IssueLink
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
# Issue Serializer with state details
|
||||
class IssueStateSerializer(BaseSerializer):
|
||||
state_detail = StateSerializer(read_only=True, source="state")
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
label_details = LabelSerializer(read_only=True, source="labels", many=True)
|
||||
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class IssueSerializer(BaseSerializer):
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
state_detail = StateSerializer(read_only=True, source="state")
|
||||
@ -422,6 +482,7 @@ class IssueSerializer(BaseSerializer):
|
||||
blocker_issues = BlockerIssueSerializer(read_only=True, many=True)
|
||||
issue_cycle = IssueCycleDetailSerializer(read_only=True)
|
||||
issue_module = IssueModuleDetailSerializer(read_only=True)
|
||||
issue_link = IssueLinkSerializer(read_only=True, many=True)
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
@ -21,6 +21,7 @@ class UserSerializer(BaseSerializer):
|
||||
"last_login_uagent",
|
||||
"token_updated_at",
|
||||
"is_onboarded",
|
||||
"is_bot",
|
||||
]
|
||||
extra_kwargs = {"password": {"write_only": True}}
|
||||
|
||||
@ -34,7 +35,9 @@ class UserLiteSerializer(BaseSerializer):
|
||||
"last_name",
|
||||
"email",
|
||||
"avatar",
|
||||
"is_bot",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"is_bot",
|
||||
]
|
||||
|
@ -86,6 +86,14 @@ from plane.api.views import (
|
||||
# Api Tokens
|
||||
ApiTokenEndpoint,
|
||||
## End Api Tokens
|
||||
# Integrations
|
||||
IntegrationViewSet,
|
||||
WorkspaceIntegrationViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubIssueSyncViewSet,
|
||||
GithubCommentSyncViewSet,
|
||||
## End Integrations
|
||||
)
|
||||
|
||||
|
||||
@ -681,7 +689,118 @@ urlpatterns = [
|
||||
),
|
||||
## End Modules
|
||||
# API Tokens
|
||||
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-token"),
|
||||
path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-token"),
|
||||
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
|
||||
path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-tokens"),
|
||||
## End API Tokens
|
||||
# Integrations
|
||||
path(
|
||||
"integrations/",
|
||||
IntegrationViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="integrations",
|
||||
),
|
||||
path(
|
||||
"integrations/<uuid:pk>/",
|
||||
IntegrationViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="integrations",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/",
|
||||
WorkspaceIntegrationViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
name="workspace-integrations",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/<str:provider>/",
|
||||
WorkspaceIntegrationViewSet.as_view(
|
||||
{
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="workspace-integrations",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/<uuid:pk>/",
|
||||
WorkspaceIntegrationViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="workspace-integrations",
|
||||
),
|
||||
# Github Integrations
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-integrations/<uuid:workspace_integration_id>/github-repositories/",
|
||||
GithubRepositoriesEndpoint.as_view(),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/",
|
||||
GithubRepositorySyncViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/<uuid:pk>/",
|
||||
GithubRepositorySyncViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/",
|
||||
GithubIssueSyncViewSet.as_view(
|
||||
{
|
||||
"post": "create",
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:pk>/",
|
||||
GithubIssueSyncViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/",
|
||||
GithubCommentSyncViewSet.as_view(
|
||||
{
|
||||
"post": "create",
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/<uuid:pk>/",
|
||||
GithubCommentSyncViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
),
|
||||
## End Github Integrations
|
||||
## End Integrations
|
||||
]
|
||||
|
@ -72,4 +72,13 @@ from .authentication import (
|
||||
|
||||
from .module import ModuleViewSet, ModuleIssueViewSet
|
||||
|
||||
from .api_token import ApiTokenEndpoint
|
||||
from .api_token import ApiTokenEndpoint
|
||||
|
||||
from .integration import (
|
||||
WorkspaceIntegrationViewSet,
|
||||
IntegrationViewSet,
|
||||
GithubIssueSyncViewSet,
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubCommentSyncViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
)
|
||||
|
7
apiserver/plane/api/views/integration/__init__.py
Normal file
7
apiserver/plane/api/views/integration/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
from .base import IntegrationViewSet, WorkspaceIntegrationViewSet
|
||||
from .github import (
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubIssueSyncViewSet,
|
||||
GithubCommentSyncViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
)
|
159
apiserver/plane/api/views/integration/base.py
Normal file
159
apiserver/plane/api/views/integration/base.py
Normal file
@ -0,0 +1,159 @@
|
||||
# Python improts
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.api.views import BaseViewSet
|
||||
from plane.db.models import (
|
||||
Integration,
|
||||
WorkspaceIntegration,
|
||||
Workspace,
|
||||
User,
|
||||
WorkspaceMember,
|
||||
APIToken,
|
||||
)
|
||||
from plane.api.serializers import IntegrationSerializer, WorkspaceIntegrationSerializer
|
||||
from plane.utils.integrations.github import get_github_metadata
|
||||
|
||||
|
||||
class IntegrationViewSet(BaseViewSet):
|
||||
serializer_class = IntegrationSerializer
|
||||
model = Integration
|
||||
|
||||
def create(self, request):
|
||||
try:
|
||||
serializer = IntegrationSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def partial_update(self, request, pk):
|
||||
try:
|
||||
integration = Integration.objects.get(pk=pk)
|
||||
if integration.verified:
|
||||
return Response(
|
||||
{"error": "Verified integrations cannot be updated"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = IntegrationSerializer(
|
||||
integration, data=request.data, partial=True
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
except Integration.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Integration Does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceIntegrationViewSet(BaseViewSet):
|
||||
serializer_class = WorkspaceIntegrationSerializer
|
||||
model = WorkspaceIntegration
|
||||
|
||||
def create(self, request, slug, provider):
|
||||
try:
|
||||
installation_id = request.data.get("installation_id", None)
|
||||
|
||||
if not installation_id:
|
||||
return Response(
|
||||
{"error": "Installation ID is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
integration = Integration.objects.get(provider=provider)
|
||||
config = {}
|
||||
if provider == "github":
|
||||
metadata = get_github_metadata(installation_id)
|
||||
config = {"installation_id": installation_id}
|
||||
|
||||
# Create a bot user
|
||||
bot_user = User.objects.create(
|
||||
email=f"{uuid.uuid4().hex}@plane.so",
|
||||
username=uuid.uuid4().hex,
|
||||
password=make_password(uuid.uuid4().hex),
|
||||
is_password_autoset=True,
|
||||
is_bot=True,
|
||||
first_name=integration.title,
|
||||
avatar=integration.avatar_url
|
||||
if integration.avatar_url is not None
|
||||
else "",
|
||||
)
|
||||
|
||||
# Create an API Token for the bot user
|
||||
api_token = APIToken.objects.create(
|
||||
user=bot_user,
|
||||
user_type=1, # bot user
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
workspace_integration = WorkspaceIntegration.objects.create(
|
||||
workspace=workspace,
|
||||
integration=integration,
|
||||
actor=bot_user,
|
||||
api_token=api_token,
|
||||
metadata=metadata,
|
||||
config=config,
|
||||
)
|
||||
|
||||
# Add bot user as a member of workspace
|
||||
_ = WorkspaceMember.objects.create(
|
||||
workspace=workspace_integration.workspace,
|
||||
member=bot_user,
|
||||
role=20,
|
||||
)
|
||||
return Response(
|
||||
WorkspaceIntegrationSerializer(workspace_integration).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"error": "Integration is already active in the workspace"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
else:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except (Workspace.DoesNotExist, Integration.DoesNotExist) as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Workspace or Integration not found"},
|
||||
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,
|
||||
)
|
145
apiserver/plane/api/views/integration/github.py
Normal file
145
apiserver/plane/api/views/integration/github.py
Normal file
@ -0,0 +1,145 @@
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.api.views import BaseViewSet, BaseAPIView
|
||||
from plane.db.models import (
|
||||
GithubIssueSync,
|
||||
GithubRepositorySync,
|
||||
GithubRepository,
|
||||
WorkspaceIntegration,
|
||||
ProjectMember,
|
||||
Label,
|
||||
GithubCommentSync,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
GithubIssueSyncSerializer,
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
||||
from plane.utils.integrations.github import get_github_repos
|
||||
|
||||
|
||||
class GithubRepositoriesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, workspace_integration_id):
|
||||
try:
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
workspace__slug=slug, pk=workspace_integration_id
|
||||
)
|
||||
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
|
||||
repositories_url = workspace_integration.metadata["repositories_url"]
|
||||
repositories = get_github_repos(access_tokens_url, repositories_url)
|
||||
return Response(repositories, status=status.HTTP_200_OK)
|
||||
except WorkspaceIntegration.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace Integration Does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class GithubRepositorySyncViewSet(BaseViewSet):
|
||||
serializer_class = GithubRepositorySyncSerializer
|
||||
model = GithubRepositorySync
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||
|
||||
def create(self, request, slug, project_id, workspace_integration_id):
|
||||
try:
|
||||
name = request.data.get("name", False)
|
||||
url = request.data.get("url", False)
|
||||
config = request.data.get("config", {})
|
||||
repository_id = request.data.get("repository_id", False)
|
||||
owner = request.data.get("owner", False)
|
||||
|
||||
if not name or not url or not repository_id or not owner:
|
||||
return Response(
|
||||
{"error": "Name, url, repository_id and owner are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Create repository
|
||||
repo = GithubRepository.objects.create(
|
||||
name=name,
|
||||
url=url,
|
||||
config=config,
|
||||
repository_id=repository_id,
|
||||
owner=owner,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# Get the workspace integration
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
pk=workspace_integration_id
|
||||
)
|
||||
|
||||
# Create a Label for github
|
||||
label = Label.objects.filter(
|
||||
name="GitHub",
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if label is None:
|
||||
label = Label.objects.create(
|
||||
name="GitHub",
|
||||
project_id=project_id,
|
||||
description="Label to sync Plane issues with GitHub issues",
|
||||
color="#003773",
|
||||
)
|
||||
|
||||
# Create repo sync
|
||||
repo_sync = GithubRepositorySync.objects.create(
|
||||
repository=repo,
|
||||
workspace_integration=workspace_integration,
|
||||
actor=workspace_integration.actor,
|
||||
credentials=request.data.get("credentials", {}),
|
||||
project_id=project_id,
|
||||
label=label,
|
||||
)
|
||||
|
||||
# Add bot as a member in the project
|
||||
_ = ProjectMember.objects.create(
|
||||
member=workspace_integration.actor, role=20, project_id=project_id
|
||||
)
|
||||
|
||||
# Return Response
|
||||
return Response(
|
||||
GithubRepositorySyncSerializer(repo_sync).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
except WorkspaceIntegration.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace Integration does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class GithubIssueSyncViewSet(BaseViewSet):
|
||||
serializer_class = GithubIssueSyncSerializer
|
||||
model = GithubIssueSync
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
repository_sync_id=self.kwargs.get("repo_sync_id"),
|
||||
)
|
||||
|
||||
|
||||
class GithubCommentSyncViewSet(BaseViewSet):
|
||||
serializer_class = GithubCommentSyncSerializer
|
||||
model = GithubCommentSync
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
issue_sync_id=self.kwargs.get("issue_sync_id"),
|
||||
)
|
@ -3,7 +3,7 @@ import json
|
||||
from itertools import groupby, chain
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Prefetch, OuterRef, Func, F
|
||||
from django.db.models import Prefetch, OuterRef, Func, F, Q
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third Party imports
|
||||
@ -22,6 +22,7 @@ from plane.api.serializers import (
|
||||
LabelSerializer,
|
||||
IssueSerializer,
|
||||
LabelSerializer,
|
||||
IssueFlatSerializer,
|
||||
)
|
||||
from plane.api.permissions import (
|
||||
ProjectEntityPermission,
|
||||
@ -39,8 +40,10 @@ from plane.db.models import (
|
||||
IssueBlocker,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
IssueLink,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
|
||||
|
||||
class IssueViewSet(BaseViewSet):
|
||||
@ -75,10 +78,9 @@ class IssueViewSet(BaseViewSet):
|
||||
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
||||
)
|
||||
if current_instance is not None:
|
||||
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "issue.activity",
|
||||
"type": "issue.activity.updated",
|
||||
"requested_data": requested_data,
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("pk", None)),
|
||||
@ -91,8 +93,28 @@ class IssueViewSet(BaseViewSet):
|
||||
|
||||
return super().perform_update(serializer)
|
||||
|
||||
def get_queryset(self):
|
||||
def perform_destroy(self, instance):
|
||||
current_instance = (
|
||||
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
||||
)
|
||||
if current_instance is not None:
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "issue.activity.deleted",
|
||||
"requested_data": json.dumps(
|
||||
{"issue_id": str(self.kwargs.get("pk", None))}
|
||||
),
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("pk", None)),
|
||||
"project_id": str(self.kwargs.get("project_id", None)),
|
||||
"current_instance": json.dumps(
|
||||
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
||||
),
|
||||
},
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
@ -136,52 +158,42 @@ class IssueViewSet(BaseViewSet):
|
||||
).prefetch_related("module__members"),
|
||||
),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
queryset=IssueLink.objects.select_related("issue").select_related(
|
||||
"created_by"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def grouper(self, issue, group_by):
|
||||
group_by = issue.get(group_by, "")
|
||||
|
||||
if isinstance(group_by, list):
|
||||
if len(group_by):
|
||||
return group_by[0]
|
||||
else:
|
||||
return ""
|
||||
|
||||
else:
|
||||
return group_by
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
issue_queryset = self.get_queryset()
|
||||
# Issue State groups
|
||||
type = request.GET.get("type", "all")
|
||||
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
if type == "backlog":
|
||||
group = ["backlog"]
|
||||
if type == "active":
|
||||
group = ["unstarted", "started"]
|
||||
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.order_by(request.GET.get("order_by", "created_at"))
|
||||
.filter(state__group__in=group)
|
||||
)
|
||||
|
||||
issues = IssueSerializer(issue_queryset, many=True).data
|
||||
|
||||
## Grouping the results
|
||||
group_by = request.GET.get("group_by", False)
|
||||
# TODO: Move this group by from ittertools to ORM for better performance - nk
|
||||
if group_by:
|
||||
issue_dict = dict()
|
||||
return Response(
|
||||
group_results(issues, group_by), status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
issues = IssueSerializer(issue_queryset, many=True).data
|
||||
|
||||
for key, value in groupby(
|
||||
issues, lambda issue: self.grouper(issue, group_by)
|
||||
):
|
||||
issue_dict[str(key)] = list(value)
|
||||
|
||||
return Response(issue_dict, status=status.HTTP_200_OK)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"next_cursor": str(0),
|
||||
"prev_cursor": str(0),
|
||||
"next_page_results": False,
|
||||
"prev_page_results": False,
|
||||
"count": issue_queryset.count(),
|
||||
"total_pages": 1,
|
||||
"extra_stats": {},
|
||||
"results": IssueSerializer(issue_queryset, many=True).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
@ -202,15 +214,18 @@ class IssueViewSet(BaseViewSet):
|
||||
serializer.save()
|
||||
|
||||
# Track the issue
|
||||
IssueActivity.objects.create(
|
||||
issue_id=serializer.data["id"],
|
||||
project_id=project_id,
|
||||
workspace_id=serializer["workspace"],
|
||||
comment=f"{request.user.email} created the issue",
|
||||
verb="created",
|
||||
actor=request.user,
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "issue.activity.created",
|
||||
"requested_data": json.dumps(
|
||||
self.request.data, cls=DjangoJSONEncoder
|
||||
),
|
||||
"actor_id": str(request.user.id),
|
||||
"issue_id": str(serializer.data.get("id", None)),
|
||||
"project_id": str(project_id),
|
||||
"current_instance": None,
|
||||
},
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@ -265,6 +280,14 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
queryset=ModuleIssue.objects.select_related("module", "issue"),
|
||||
),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
queryset=IssueLink.objects.select_related(
|
||||
"issue"
|
||||
).select_related("created_by"),
|
||||
)
|
||||
)
|
||||
)
|
||||
serializer = IssueSerializer(issues, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@ -277,7 +300,6 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
|
||||
|
||||
class WorkSpaceIssuesEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
@ -298,7 +320,6 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class IssueActivityEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
@ -307,7 +328,10 @@ class IssueActivityEndpoint(BaseAPIView):
|
||||
try:
|
||||
issue_activities = (
|
||||
IssueActivity.objects.filter(issue_id=issue_id)
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(
|
||||
~Q(field="comment"),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
)
|
||||
.select_related("actor")
|
||||
).order_by("created_by")
|
||||
issue_comments = (
|
||||
@ -333,7 +357,6 @@ class IssueActivityEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class IssueCommentViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = IssueCommentSerializer
|
||||
model = IssueComment
|
||||
permission_classes = [
|
||||
@ -351,6 +374,60 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
issue_id=self.kwargs.get("issue_id"),
|
||||
actor=self.request.user if self.request.user is not None else None,
|
||||
)
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "comment.activity.created",
|
||||
"requested_data": json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("issue_id")),
|
||||
"project_id": str(self.kwargs.get("project_id")),
|
||||
"current_instance": None,
|
||||
},
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
||||
current_instance = (
|
||||
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
||||
)
|
||||
if current_instance is not None:
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "comment.activity.updated",
|
||||
"requested_data": requested_data,
|
||||
"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(
|
||||
IssueCommentSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return super().perform_update(serializer)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
current_instance = (
|
||||
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
||||
)
|
||||
if current_instance is not None:
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "comment.activity.deleted",
|
||||
"requested_data": json.dumps(
|
||||
{"comment_id": str(self.kwargs.get("pk", 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(
|
||||
IssueCommentSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
},
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
@ -436,7 +513,6 @@ class IssuePropertyViewSet(BaseViewSet):
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
|
||||
issue_property, created = IssueProperty.objects.get_or_create(
|
||||
user=request.user,
|
||||
project_id=project_id,
|
||||
@ -463,7 +539,6 @@ class IssuePropertyViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class LabelViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = LabelSerializer
|
||||
model = Label
|
||||
permission_classes = [
|
||||
@ -490,14 +565,12 @@ class LabelViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class BulkDeleteIssuesEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def delete(self, request, slug, project_id):
|
||||
try:
|
||||
|
||||
issue_ids = request.data.get("issue_ids", [])
|
||||
|
||||
if not len(issue_ids):
|
||||
@ -527,14 +600,12 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class SubIssuesEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
|
||||
sub_issues = (
|
||||
Issue.objects.filter(
|
||||
parent_id=issue_id, workspace__slug=slug, project_id=project_id
|
||||
@ -583,3 +654,39 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Assign multiple sub issues
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
parent_issue = Issue.objects.get(pk=issue_id)
|
||||
sub_issue_ids = request.data.get("sub_issue_ids", [])
|
||||
|
||||
if not len(sub_issue_ids):
|
||||
return Response(
|
||||
{"error": "Sub Issue IDs are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
sub_issues = Issue.objects.filter(id__in=sub_issue_ids)
|
||||
|
||||
for sub_issue in sub_issues:
|
||||
sub_issue.parent = parent_issue
|
||||
|
||||
_ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10)
|
||||
|
||||
updated_sub_issues = Issue.objects.filter(id__in=sub_issue_ids)
|
||||
|
||||
return Response(
|
||||
IssueFlatSerializer(updated_sub_issues, many=True).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Issue.DoesNotExist:
|
||||
return Response(
|
||||
{"Parent Issue 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,
|
||||
)
|
||||
|
@ -1,5 +1,10 @@
|
||||
# Python imports
|
||||
import json
|
||||
import requests
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third Party imports
|
||||
from django_rq import job
|
||||
@ -16,6 +21,7 @@ from plane.db.models import (
|
||||
Cycle,
|
||||
Module,
|
||||
)
|
||||
from plane.api.serializers import IssueActivitySerializer
|
||||
|
||||
|
||||
# Track Chnages in name
|
||||
@ -612,14 +618,136 @@ def track_modules(
|
||||
)
|
||||
|
||||
|
||||
def create_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} created the issue",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
ISSUE_ACTIVITY_MAPPER = {
|
||||
"name": track_name,
|
||||
"parent": track_parent,
|
||||
"priority": track_priority,
|
||||
"state": track_state,
|
||||
"description": track_description,
|
||||
"target_date": track_target_date,
|
||||
"start_date": track_start_date,
|
||||
"labels_list": track_labels,
|
||||
"assignees_list": track_assignees,
|
||||
"blocks_list": track_blocks,
|
||||
"blockers_list": track_blockings,
|
||||
"cycles_list": track_cycles,
|
||||
"modules_list": track_modules,
|
||||
}
|
||||
for key in requested_data:
|
||||
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
|
||||
if func is not None:
|
||||
func(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
)
|
||||
|
||||
|
||||
def create_comment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} created a comment",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
field="comment",
|
||||
new_value=requested_data.get("comment_html"),
|
||||
new_identifier=requested_data.get("id"),
|
||||
issue_comment_id=requested_data.get("id", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_comment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
if current_instance.get("comment_html") != requested_data.get("comment_html"):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated a comment",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
field="comment",
|
||||
old_value=current_instance.get("comment_html"),
|
||||
old_identifier=current_instance.get("id"),
|
||||
new_value=requested_data.get("comment_html"),
|
||||
new_identifier=current_instance.get("id"),
|
||||
issue_comment_id=current_instance.get("id"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} deleted the issue",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="issue",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_comment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} deleted the comment",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="comment",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Receive message from room group
|
||||
@job("default")
|
||||
def issue_activity(event):
|
||||
try:
|
||||
issue_activities = []
|
||||
|
||||
type = event.get("type")
|
||||
requested_data = json.loads(event.get("requested_data"))
|
||||
current_instance = json.loads(event.get("current_instance"))
|
||||
current_instance = (
|
||||
json.loads(event.get("current_instance"))
|
||||
if event.get("current_instance") is not None
|
||||
else None
|
||||
)
|
||||
issue_id = event.get("issue_id", None)
|
||||
actor_id = event.get("actor_id")
|
||||
project_id = event.get("project_id")
|
||||
@ -628,37 +756,43 @@ def issue_activity(event):
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
ISSUE_ACTIVITY_MAPPER = {
|
||||
"name": track_name,
|
||||
"parent": track_parent,
|
||||
"priority": track_priority,
|
||||
"state": track_state,
|
||||
"description": track_description,
|
||||
"target_date": track_target_date,
|
||||
"start_date": track_start_date,
|
||||
"labels_list": track_labels,
|
||||
"assignees_list": track_assignees,
|
||||
"blocks_list": track_blocks,
|
||||
"blockers_list": track_blockings,
|
||||
"cycles_list": track_cycles,
|
||||
"modules_list": track_modules,
|
||||
ACTIVITY_MAPPER = {
|
||||
"issue.activity.created": create_issue_activity,
|
||||
"issue.activity.updated": update_issue_activity,
|
||||
"issue.activity.deleted": delete_issue_activity,
|
||||
"comment.activity.created": create_comment_activity,
|
||||
"comment.activity.updated": update_comment_activity,
|
||||
"comment.activity.deleted": delete_comment_activity,
|
||||
}
|
||||
|
||||
for key in requested_data:
|
||||
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
|
||||
if func is not None:
|
||||
func(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
)
|
||||
func = ACTIVITY_MAPPER.get(type)
|
||||
if func is not None:
|
||||
func(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
)
|
||||
|
||||
# Save all the values to database
|
||||
_ = IssueActivity.objects.bulk_create(issue_activities)
|
||||
|
||||
issue_activities_created = IssueActivity.objects.bulk_create(issue_activities)
|
||||
# Post the updates to segway for integrations and webhooks
|
||||
if len(issue_activities_created):
|
||||
# Don't send activities if the actor is a bot
|
||||
if settings.PROXY_BASE_URL:
|
||||
for issue_activity in issue_activities_created:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
issue_activity_json = json.dumps(
|
||||
IssueActivitySerializer(issue_activity).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
)
|
||||
_ = requests.post(
|
||||
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/",
|
||||
json=issue_activity_json,
|
||||
headers=headers,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
@ -1,3 +1,7 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
|
||||
|
@ -10,7 +10,13 @@ from .workspace import (
|
||||
TeamMember,
|
||||
)
|
||||
|
||||
from .project import Project, ProjectMember, ProjectBaseModel, ProjectMemberInvite, ProjectIdentifier
|
||||
from .project import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
ProjectBaseModel,
|
||||
ProjectMemberInvite,
|
||||
ProjectIdentifier,
|
||||
)
|
||||
|
||||
from .issue import (
|
||||
Issue,
|
||||
@ -23,6 +29,7 @@ from .issue import (
|
||||
IssueAssignee,
|
||||
Label,
|
||||
IssueBlocker,
|
||||
IssueLink,
|
||||
)
|
||||
|
||||
from .asset import FileAsset
|
||||
@ -37,6 +44,15 @@ from .shortcut import Shortcut
|
||||
|
||||
from .view import View
|
||||
|
||||
from .module import Module, ModuleMember, ModuleIssue, ModuleLink
|
||||
from .module import Module, ModuleMember, ModuleIssue, ModuleLink
|
||||
|
||||
from .api_token import APIToken
|
||||
from .api_token import APIToken
|
||||
|
||||
from .integration import (
|
||||
WorkspaceIntegration,
|
||||
Integration,
|
||||
GithubRepository,
|
||||
GithubRepositorySync,
|
||||
GithubIssueSync,
|
||||
GithubCommentSync,
|
||||
)
|
||||
|
2
apiserver/plane/db/models/integration/__init__.py
Normal file
2
apiserver/plane/db/models/integration/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .base import Integration, WorkspaceIntegration
|
||||
from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync
|
68
apiserver/plane/db/models/integration/base.py
Normal file
68
apiserver/plane/db/models/integration/base.py
Normal file
@ -0,0 +1,68 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import BaseModel
|
||||
from plane.db.mixins import AuditModel
|
||||
|
||||
|
||||
class Integration(AuditModel):
|
||||
id = models.UUIDField(
|
||||
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
|
||||
)
|
||||
title = models.CharField(max_length=400)
|
||||
provider = models.CharField(max_length=400, unique=True)
|
||||
network = models.PositiveIntegerField(
|
||||
default=1, choices=((1, "Private"), (2, "Public"))
|
||||
)
|
||||
description = models.JSONField(default=dict)
|
||||
author = models.CharField(max_length=400, blank=True)
|
||||
webhook_url = models.TextField(blank=True)
|
||||
webhook_secret = models.TextField(blank=True)
|
||||
redirect_url = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict)
|
||||
verified = models.BooleanField(default=False)
|
||||
avatar_url = models.URLField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
"""Return provider of the integration"""
|
||||
return f"{self.provider}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Integration"
|
||||
verbose_name_plural = "Integrations"
|
||||
db_table = "integrations"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class WorkspaceIntegration(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", related_name="workspace_integrations", on_delete=models.CASCADE
|
||||
)
|
||||
# Bot user
|
||||
actor = models.ForeignKey(
|
||||
"db.User", related_name="integrations", on_delete=models.CASCADE
|
||||
)
|
||||
integration = models.ForeignKey(
|
||||
"db.Integration", related_name="integrated_workspaces", on_delete=models.CASCADE
|
||||
)
|
||||
api_token = models.ForeignKey(
|
||||
"db.APIToken", related_name="integrations", on_delete=models.CASCADE
|
||||
)
|
||||
metadata = models.JSONField(default=dict)
|
||||
|
||||
config = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the integration and workspace"""
|
||||
return f"{self.workspace.name} <{self.integration.provider}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "integration"]
|
||||
verbose_name = "Workspace Integration"
|
||||
verbose_name_plural = "Workspace Integrations"
|
||||
db_table = "workspace_integrations"
|
||||
ordering = ("-created_at",)
|
99
apiserver/plane/db/models/integration/github.py
Normal file
99
apiserver/plane/db/models/integration/github.py
Normal file
@ -0,0 +1,99 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import ProjectBaseModel
|
||||
from plane.db.mixins import AuditModel
|
||||
|
||||
|
||||
class GithubRepository(ProjectBaseModel):
|
||||
name = models.CharField(max_length=500)
|
||||
url = models.URLField(null=True)
|
||||
config = models.JSONField(default=dict)
|
||||
repository_id = models.BigIntegerField()
|
||||
owner = models.CharField(max_length=500)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the repo name"""
|
||||
return f"{self.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Repository"
|
||||
verbose_name_plural = "Repositories"
|
||||
db_table = "github_repositories"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class GithubRepositorySync(ProjectBaseModel):
|
||||
repository = models.OneToOneField(
|
||||
"db.GithubRepository", on_delete=models.CASCADE, related_name="syncs"
|
||||
)
|
||||
credentials = models.JSONField(default=dict)
|
||||
# Bot user
|
||||
actor = models.ForeignKey(
|
||||
"db.User", related_name="user_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
workspace_integration = models.ForeignKey(
|
||||
"db.WorkspaceIntegration", related_name="github_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
label = models.ForeignKey(
|
||||
"db.Label", on_delete=models.SET_NULL, null=True, related_name="repo_syncs"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the repo sync"""
|
||||
return f"{self.repository.name} <{self.project.name}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "repository"]
|
||||
verbose_name = "Github Repository Sync"
|
||||
verbose_name_plural = "Github Repository Syncs"
|
||||
db_table = "github_repository_syncs"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class GithubIssueSync(ProjectBaseModel):
|
||||
repo_issue_id = models.BigIntegerField()
|
||||
github_issue_id = models.BigIntegerField()
|
||||
issue_url = models.URLField(blank=False)
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", related_name="github_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
repository_sync = models.ForeignKey(
|
||||
"db.GithubRepositorySync", related_name="issue_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the github issue sync"""
|
||||
return f"{self.repository.name}-{self.project.name}-{self.issue.name}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["repository_sync", "issue"]
|
||||
verbose_name = "Github Issue Sync"
|
||||
verbose_name_plural = "Github Issue Syncs"
|
||||
db_table = "github_issue_syncs"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class GithubCommentSync(ProjectBaseModel):
|
||||
repo_comment_id = models.BigIntegerField()
|
||||
comment = models.ForeignKey(
|
||||
"db.IssueComment", related_name="comment_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
issue_sync = models.ForeignKey(
|
||||
"db.GithubIssueSync", related_name="comment_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the github issue sync"""
|
||||
return f"{self.comment.id}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue_sync", "comment"]
|
||||
verbose_name = "Github Comment Sync"
|
||||
verbose_name_plural = "Github Comment Syncs"
|
||||
db_table = "github_comment_syncs"
|
||||
ordering = ("-created_at",)
|
@ -69,16 +69,6 @@ class Issue(ProjectBaseModel):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# This means that the model isn't saved to the database yet
|
||||
if self._state.adding:
|
||||
# Get the maximum display_id value from the database
|
||||
|
||||
last_id = IssueSequence.objects.filter(project=self.project).aggregate(
|
||||
largest=models.Max("sequence")
|
||||
)["largest"]
|
||||
# aggregate can return None! Check it first.
|
||||
# If it isn't none, just use the last ID specified (which should be the greatest) and add one to it
|
||||
if last_id is not None:
|
||||
self.sequence_id = last_id + 1
|
||||
if self.state is None:
|
||||
try:
|
||||
from plane.db.models import State
|
||||
@ -109,6 +99,23 @@ class Issue(ProjectBaseModel):
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
if self._state.adding:
|
||||
# Get the maximum display_id value from the database
|
||||
|
||||
last_id = IssueSequence.objects.filter(project=self.project).aggregate(
|
||||
largest=models.Max("sequence")
|
||||
)["largest"]
|
||||
# aggregate can return None! Check it first.
|
||||
# If it isn't none, just use the last ID specified (which should be the greatest) and add one to it
|
||||
if last_id is not None:
|
||||
self.sequence_id = last_id + 1
|
||||
|
||||
largest_sort_order = Issue.objects.filter(
|
||||
project=self.project, state=self.state
|
||||
).aggregate(largest=models.Max("sort_order"))["largest"]
|
||||
if largest_sort_order is not None:
|
||||
self.sort_order = largest_sort_order + 10000
|
||||
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
@ -161,9 +168,26 @@ class IssueAssignee(ProjectBaseModel):
|
||||
return f"{self.issue.name} {self.assignee.email}"
|
||||
|
||||
|
||||
class IssueLink(ProjectBaseModel):
|
||||
title = models.CharField(max_length=255, null=True)
|
||||
url = models.URLField()
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, related_name="issue_link"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Link"
|
||||
verbose_name_plural = "Issue Links"
|
||||
db_table = "issue_links"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.url}"
|
||||
|
||||
|
||||
class IssueActivity(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
Issue, on_delete=models.CASCADE, related_name="issue_activity"
|
||||
Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity"
|
||||
)
|
||||
verb = models.CharField(max_length=255, verbose_name="Action", default="created")
|
||||
field = models.CharField(
|
||||
|
@ -38,4 +38,13 @@ class State(ProjectBaseModel):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.slug = slugify(self.name)
|
||||
if self._state.adding:
|
||||
# Get the maximum sequence value from the database
|
||||
last_id = State.objects.filter(project=self.project).aggregate(
|
||||
largest=models.Max("sequence")
|
||||
)["largest"]
|
||||
# if last_id is not None
|
||||
if last_id is not None:
|
||||
self.sequence = last_id + 15000
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
@ -1,12 +1,13 @@
|
||||
import os
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY")
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import dj_database_url
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
@ -24,6 +25,10 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
DOCKERIZED = os.environ.get("DOCKERIZED", False)
|
||||
|
||||
if DOCKERIZED:
|
||||
DATABASES["default"] = dj_database_url.config()
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
@ -41,15 +46,16 @@ INTERNAL_IPS = ("127.0.0.1",)
|
||||
|
||||
CORS_ORIGIN_ALLOW_ALL = True
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=os.environ.get("SENTRY_DSN"),
|
||||
integrations=[DjangoIntegration(), RedisIntegration()],
|
||||
# If you wish to associate users to errors (assuming you are using
|
||||
# django.contrib.auth) you may enable sending PII data.
|
||||
send_default_pii=True,
|
||||
environment="local",
|
||||
traces_sample_rate=0.7,
|
||||
)
|
||||
if os.environ.get("SENTRY_DSN", False):
|
||||
sentry_sdk.init(
|
||||
dsn=os.environ.get("SENTRY_DSN"),
|
||||
integrations=[DjangoIntegration(), RedisIntegration()],
|
||||
# If you wish to associate users to errors (assuming you are using
|
||||
# django.contrib.auth) you may enable sending PII data.
|
||||
send_default_pii=True,
|
||||
environment="local",
|
||||
traces_sample_rate=0.7,
|
||||
)
|
||||
|
||||
REDIS_HOST = "localhost"
|
||||
REDIS_PORT = 6379
|
||||
@ -64,5 +70,11 @@ RQ_QUEUES = {
|
||||
},
|
||||
}
|
||||
|
||||
WEB_URL = "http://localhost:3000"
|
||||
MEDIA_URL = "/uploads/"
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
|
||||
|
||||
if DOCKERIZED:
|
||||
REDIS_URL = os.environ.get("REDIS_URL")
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL", "localhost:3000")
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
@ -33,6 +33,10 @@ CORS_ORIGIN_WHITELIST = [
|
||||
DATABASES["default"] = dj_database_url.config()
|
||||
SITE_ID = 1
|
||||
|
||||
DOCKERIZED = os.environ.get(
|
||||
"DOCKERIZED", False
|
||||
) # Set the variable true if running in docker-compose environment
|
||||
|
||||
# Enable Connection Pooling (if desired)
|
||||
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
|
||||
|
||||
@ -48,99 +52,110 @@ CORS_ALLOW_ALL_ORIGINS = True
|
||||
# Simplified static file serving.
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
if os.environ.get("SENTRY_DSN", False):
|
||||
sentry_sdk.init(
|
||||
dsn=os.environ.get("SENTRY_DSN", ""),
|
||||
integrations=[DjangoIntegration(), RedisIntegration()],
|
||||
# If you wish to associate users to errors (assuming you are using
|
||||
# django.contrib.auth) you may enable sending PII data.
|
||||
traces_sample_rate=1,
|
||||
send_default_pii=True,
|
||||
environment="production",
|
||||
)
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=os.environ.get("SENTRY_DSN"),
|
||||
integrations=[DjangoIntegration(), RedisIntegration()],
|
||||
# If you wish to associate users to errors (assuming you are using
|
||||
# django.contrib.auth) you may enable sending PII data.
|
||||
traces_sample_rate=1,
|
||||
send_default_pii=True,
|
||||
environment="production",
|
||||
)
|
||||
if (
|
||||
os.environ.get("AWS_REGION", False)
|
||||
and os.environ.get("AWS_ACCESS_KEY_ID", False)
|
||||
and os.environ.get("AWS_SECRET_ACCESS_KEY", False)
|
||||
and os.environ.get("AWS_S3_BUCKET_NAME", False)
|
||||
):
|
||||
# The AWS region to connect to.
|
||||
AWS_REGION = os.environ.get("AWS_REGION", "")
|
||||
|
||||
# The AWS region to connect to.
|
||||
AWS_REGION = os.environ.get("AWS_REGION")
|
||||
# The AWS access key to use.
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
|
||||
|
||||
# The AWS access key to use.
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID")
|
||||
# The AWS secret access key to use.
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
||||
|
||||
# The AWS secret access key to use.
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY")
|
||||
# The optional AWS session token to use.
|
||||
# AWS_SESSION_TOKEN = ""
|
||||
|
||||
# The optional AWS session token to use.
|
||||
# AWS_SESSION_TOKEN = ""
|
||||
# The name of the bucket to store files in.
|
||||
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "")
|
||||
|
||||
# How to construct S3 URLs ("auto", "path", "virtual").
|
||||
AWS_S3_ADDRESSING_STYLE = "auto"
|
||||
|
||||
# The name of the bucket to store files in.
|
||||
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
|
||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||
AWS_S3_ENDPOINT_URL = ""
|
||||
|
||||
# How to construct S3 URLs ("auto", "path", "virtual").
|
||||
AWS_S3_ADDRESSING_STYLE = "auto"
|
||||
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
|
||||
AWS_S3_KEY_PREFIX = ""
|
||||
|
||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||
AWS_S3_ENDPOINT_URL = ""
|
||||
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
|
||||
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
|
||||
# and their permissions will be set to "public-read".
|
||||
AWS_S3_BUCKET_AUTH = False
|
||||
|
||||
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
|
||||
AWS_S3_KEY_PREFIX = ""
|
||||
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
|
||||
# is True. It also affects the "Cache-Control" header of the files.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
|
||||
|
||||
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
|
||||
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
|
||||
# and their permissions will be set to "public-read".
|
||||
AWS_S3_BUCKET_AUTH = False
|
||||
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
|
||||
# cannot be used with `AWS_S3_BUCKET_AUTH`.
|
||||
AWS_S3_PUBLIC_URL = ""
|
||||
|
||||
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
|
||||
# is True. It also affects the "Cache-Control" header of the files.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
|
||||
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
|
||||
# understand the consequences before enabling.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_REDUCED_REDUNDANCY = False
|
||||
|
||||
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
|
||||
# cannot be used with `AWS_S3_BUCKET_AUTH`.
|
||||
AWS_S3_PUBLIC_URL = ""
|
||||
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_CONTENT_DISPOSITION = ""
|
||||
|
||||
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
|
||||
# understand the consequences before enabling.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_REDUCED_REDUNDANCY = False
|
||||
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_CONTENT_LANGUAGE = ""
|
||||
|
||||
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_CONTENT_DISPOSITION = ""
|
||||
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_METADATA = {}
|
||||
|
||||
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_CONTENT_LANGUAGE = ""
|
||||
# If True, then files will be stored using AES256 server-side encryption.
|
||||
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
|
||||
# Otherwise, server-side encryption is not be enabled.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_ENCRYPT_KEY = False
|
||||
|
||||
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_METADATA = {}
|
||||
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
|
||||
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
|
||||
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
|
||||
|
||||
# If True, then files will be stored using AES256 server-side encryption.
|
||||
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
|
||||
# Otherwise, server-side encryption is not be enabled.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_ENCRYPT_KEY = False
|
||||
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
|
||||
# compressed size is smaller than their uncompressed size.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_GZIP = True
|
||||
|
||||
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
|
||||
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
|
||||
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
|
||||
# The signature version to use for S3 requests.
|
||||
AWS_S3_SIGNATURE_VERSION = None
|
||||
|
||||
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
|
||||
# compressed size is smaller than their uncompressed size.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_GZIP = True
|
||||
# If True, then files with the same name will overwrite each other. By default it's set to False to have
|
||||
# extra characters appended.
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
|
||||
# The signature version to use for S3 requests.
|
||||
AWS_S3_SIGNATURE_VERSION = None
|
||||
# AWS Settings End
|
||||
|
||||
# If True, then files with the same name will overwrite each other. By default it's set to False to have
|
||||
# extra characters appended.
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
|
||||
|
||||
# AWS Settings End
|
||||
else:
|
||||
MEDIA_URL = "/uploads/"
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
|
||||
|
||||
|
||||
# Enable Connection Pooling (if desired)
|
||||
@ -155,7 +170,6 @@ ALLOWED_HOSTS = [
|
||||
]
|
||||
|
||||
|
||||
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
|
||||
# Simplified static file serving.
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
@ -165,16 +179,27 @@ CSRF_COOKIE_SECURE = True
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL")
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": REDIS_URL,
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
|
||||
},
|
||||
if DOCKERIZED:
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": REDIS_URL,
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
}
|
||||
}
|
||||
else:
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": REDIS_URL,
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RQ_QUEUES = {
|
||||
"default": {
|
||||
@ -183,10 +208,6 @@ RQ_QUEUES = {
|
||||
}
|
||||
|
||||
|
||||
url = urlparse(os.environ.get("REDIS_URL"))
|
||||
|
||||
DOCKERIZED = os.environ.get(
|
||||
"DOCKERIZED", False
|
||||
) # Set the variable true if running in docker-compose environment
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL")
|
||||
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
@ -185,3 +185,5 @@ RQ_QUEUES = {
|
||||
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL")
|
||||
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
31
apiserver/plane/utils/grouper.py
Normal file
31
apiserver/plane/utils/grouper.py
Normal file
@ -0,0 +1,31 @@
|
||||
def group_results(results_data, group_by):
|
||||
"""
|
||||
Utility function to group data into a given attribute.
|
||||
Function can group attributes of string and list type.
|
||||
"""
|
||||
response_dict = dict()
|
||||
|
||||
for value in results_data:
|
||||
group_attribute = value.get(group_by, None)
|
||||
if isinstance(group_attribute, list):
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
if str(attrib) in response_dict:
|
||||
response_dict[str(attrib)].append(value)
|
||||
else:
|
||||
response_dict[str(attrib)] = []
|
||||
response_dict[str(attrib)].append(value)
|
||||
else:
|
||||
if str(None) in response_dict:
|
||||
response_dict[str(None)].append(value)
|
||||
else:
|
||||
response_dict[str(None)] = []
|
||||
response_dict[str(None)].append(value)
|
||||
else:
|
||||
if str(group_attribute) in response_dict:
|
||||
response_dict[str(group_attribute)].append(value)
|
||||
else:
|
||||
response_dict[str(group_attribute)] = []
|
||||
response_dict[str(group_attribute)].append(value)
|
||||
|
||||
return response_dict
|
0
apiserver/plane/utils/integrations/__init__.py
Normal file
0
apiserver/plane/utils/integrations/__init__.py
Normal file
62
apiserver/plane/utils/integrations/github.py
Normal file
62
apiserver/plane/utils/integrations/github.py
Normal file
@ -0,0 +1,62 @@
|
||||
import os
|
||||
import jwt
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
|
||||
def get_jwt_token():
|
||||
app_id = os.environ.get("GITHUB_APP_ID", "")
|
||||
secret = bytes(os.environ.get("GITHUB_APP_PRIVATE_KEY", ""), encoding="utf8")
|
||||
current_timestamp = int(datetime.now().timestamp())
|
||||
due_date = datetime.now() + timedelta(minutes=10)
|
||||
expiry = int(due_date.timestamp())
|
||||
payload = {
|
||||
"iss": app_id,
|
||||
"sub": app_id,
|
||||
"exp": expiry,
|
||||
"iat": current_timestamp,
|
||||
"aud": "https://github.com/login/oauth/access_token",
|
||||
}
|
||||
|
||||
priv_rsakey = load_pem_private_key(secret, None, default_backend())
|
||||
token = jwt.encode(payload, priv_rsakey, algorithm="RS256")
|
||||
return token
|
||||
|
||||
|
||||
def get_github_metadata(installation_id):
|
||||
token = get_jwt_token()
|
||||
|
||||
url = f"https://api.github.com/app/installations/{installation_id}"
|
||||
headers = {
|
||||
"Authorization": "Bearer " + token,
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
response = requests.get(url, headers=headers).json()
|
||||
return response
|
||||
|
||||
|
||||
def get_github_repos(access_tokens_url, repositories_url):
|
||||
token = get_jwt_token()
|
||||
|
||||
headers = {
|
||||
"Authorization": "Bearer " + token,
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
|
||||
oauth_response = requests.post(
|
||||
access_tokens_url,
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
oauth_token = oauth_response.get("token")
|
||||
headers = {
|
||||
"Authorization": "Bearer " + oauth_token,
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
response = requests.get(
|
||||
repositories_url,
|
||||
headers=headers,
|
||||
).json()
|
||||
return response
|
@ -1,6 +1,6 @@
|
||||
# base requirements
|
||||
|
||||
Django==3.2.17
|
||||
Django==3.2.18
|
||||
django-braces==1.15.0
|
||||
django-taggit==3.1.0
|
||||
psycopg2==2.9.5
|
||||
|
@ -17,7 +17,7 @@
|
||||
color: #FFFFFF;
|
||||
}
|
||||
</style>
|
||||
<h1 id="site-name">{% trans 'plane Admin' %} </h1>
|
||||
<h1 id="site-name">{% trans 'Plane Django Admin' %} </h1>
|
||||
|
||||
|
||||
{% endblock %}{% block nav-global %}{% endblock %}
|
||||
|
14
app.json
14
app.json
@ -6,8 +6,16 @@
|
||||
"website": "https://plane.so/",
|
||||
"success_url": "/",
|
||||
"stack": "heroku-22",
|
||||
"keywords": ["plane", "project management", "django", "next"],
|
||||
"addons": ["heroku-postgresql:mini", "heroku-redis:mini"],
|
||||
"keywords": [
|
||||
"plane",
|
||||
"project management",
|
||||
"django",
|
||||
"next"
|
||||
],
|
||||
"addons": [
|
||||
"heroku-postgresql:mini",
|
||||
"heroku-redis:mini"
|
||||
],
|
||||
"buildpacks": [
|
||||
{
|
||||
"url": "https://github.com/heroku/heroku-buildpack-python.git"
|
||||
@ -74,4 +82,4 @@
|
||||
"value": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
6
apps/app/.env.example
Normal file
6
apps/app/.env.example
Normal file
@ -0,0 +1,6 @@
|
||||
NEXT_PUBLIC_API_BASE_URL = "localhost/"
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID="<-- google client id -->"
|
||||
NEXT_PUBLIC_GITHUB_ID="<-- github client id -->"
|
||||
NEXT_PUBLIC_SENTRY_DSN="<-- sentry dns -->"
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
NEXT_PUBLIC_ENABLE_SENTRY=0
|
@ -1 +1,4 @@
|
||||
module.exports = require("config/.eslintrc");
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["custom"],
|
||||
};
|
||||
|
12
apps/app/Dockerfile.dev
Normal file
12
apps/app/Dockerfile.dev
Normal file
@ -0,0 +1,12 @@
|
||||
FROM node:18-alpine
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
COPY . .
|
||||
RUN yarn global add turbo
|
||||
RUN yarn install
|
||||
EXPOSE 3000
|
||||
CMD ["yarn","dev"]
|
@ -4,33 +4,14 @@ RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add curl
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
|
||||
|
||||
RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm;
|
||||
|
||||
ENV PNPM_HOME="pnpm"
|
||||
ENV PATH="${PATH}:./pnpm"
|
||||
|
||||
COPY ./apps ./apps
|
||||
COPY ./package.json ./package.json
|
||||
COPY ./.eslintrc.js ./.eslintrc.js
|
||||
COPY ./turbo.json ./turbo.json
|
||||
COPY ./pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY ./pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
|
||||
RUN pnpm add -g turbo
|
||||
RUN turbo prune --scope=app --docker
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:18-alpine AS installer
|
||||
|
||||
RUN apk add curl
|
||||
|
||||
RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm;
|
||||
|
||||
ENV PNPM_HOME="pnpm"
|
||||
ENV PATH="${PATH}:./pnpm"
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
@ -39,14 +20,14 @@ WORKDIR /app
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN pnpm install
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install
|
||||
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
RUN pnpm turbo run build --filter=app...
|
||||
RUN yarn turbo run build --filter=app
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
@ -62,8 +43,9 @@ COPY --from=installer /app/apps/app/package.json .
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
|
||||
# COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone/node_modules ./apps/app/node_modules
|
||||
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
|
||||
|
||||
EXPOSE 3000
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
CMD node apps/app/server.js
|
||||
EXPOSE 3000
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
@ -6,6 +6,7 @@ import { Button, Input } from "components/ui";
|
||||
// services
|
||||
import authenticationService from "services/authentication.service";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useTimer from "hooks/use-timer";
|
||||
// icons
|
||||
|
||||
// types
|
||||
@ -17,12 +18,19 @@ type EmailCodeFormValues = {
|
||||
|
||||
export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [codeResent, setCodeResent] = useState(false);
|
||||
const [isCodeResending, setIsCodeResending] = useState(false);
|
||||
const [errorResendingCode, setErrorResendingCode] = useState(false);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<EmailCodeFormValues>({
|
||||
defaultValues: {
|
||||
@ -34,7 +42,11 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const isResendDisabled =
|
||||
resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode;
|
||||
|
||||
const onSubmit = async ({ email }: EmailCodeFormValues) => {
|
||||
setErrorResendingCode(false);
|
||||
await authenticationService
|
||||
.emailCode({ email })
|
||||
.then((res) => {
|
||||
@ -42,7 +54,12 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
setCodeSent(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
setErrorResendingCode(true);
|
||||
setToastAlert({
|
||||
title: "Oops!",
|
||||
type: "error",
|
||||
message: err?.error,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -53,11 +70,10 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
onSuccess(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
setToastAlert({
|
||||
title: "Oops!",
|
||||
type: "error",
|
||||
message: "Enter the correct code to sign in",
|
||||
message: error?.response?.data?.error ?? "Enter the correct code to sign in",
|
||||
});
|
||||
setError("token" as keyof EmailCodeFormValues, {
|
||||
type: "manual",
|
||||
@ -66,10 +82,16 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
const emailOld = getValues("email");
|
||||
|
||||
useEffect(() => {
|
||||
setErrorResendingCode(false);
|
||||
}, [emailOld]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="mt-5 space-y-5">
|
||||
{codeSent && (
|
||||
{(codeSent || codeResent) && (
|
||||
<div className="rounded-md bg-green-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
@ -77,7 +99,9 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-green-800">
|
||||
Please check your mail for code.
|
||||
{codeResent
|
||||
? "Please check your mail for new code."
|
||||
: "Please check your mail for code."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -114,15 +138,33 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
error={errors.token}
|
||||
placeholder="Enter code"
|
||||
/>
|
||||
{/* <span
|
||||
className="text-xs outline-none hover:text-theme"
|
||||
<button
|
||||
type="button"
|
||||
className={`text-xs mt-5 w-full flex justify-end outline-none ${
|
||||
isResendDisabled ? "text-gray-400 cursor-default" : "cursor-pointer text-theme"
|
||||
} `}
|
||||
onClick={() => {
|
||||
console.log("Triggered");
|
||||
handleSubmit(onSubmit);
|
||||
setIsCodeResending(true);
|
||||
onSubmit({ email: getValues("email") }).then(() => {
|
||||
setCodeResent(true);
|
||||
setIsCodeResending(false);
|
||||
setResendCodeTimer(30);
|
||||
});
|
||||
}}
|
||||
disabled={isResendDisabled}
|
||||
>
|
||||
Resend code
|
||||
</span> */}
|
||||
{resendCodeTimer > 0 ? (
|
||||
<p className="text-right">
|
||||
Didn{"'"}t receive code? Get new code in {resendCodeTimer} seconds.
|
||||
</p>
|
||||
) : isCodeResending ? (
|
||||
"Sending code..."
|
||||
) : errorResendingCode ? (
|
||||
"Please try again later"
|
||||
) : (
|
||||
"Resend code"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
@ -139,7 +181,11 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full text-center"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
onClick={() => {
|
||||
handleSubmit(onSubmit)().then(() => {
|
||||
setResendCodeTimer(30);
|
||||
});
|
||||
}}
|
||||
disabled={isSubmitting || (!isValid && isDirty)}
|
||||
>
|
||||
{isSubmitting ? "Sending code..." : "Send code"}
|
||||
|
@ -44,10 +44,10 @@ const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) =>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="px-3 text-sm">
|
||||
<div className="px-3 text-sm max-w-64">
|
||||
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
|
||||
{icon}
|
||||
{title}
|
||||
<span className="break-all">{title}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
@ -17,8 +17,8 @@ import { ShortcutsModal } from "components/command-palette";
|
||||
import { BulkDeleteIssuesModal } from "components/core";
|
||||
import { CreateProjectModal } from "components/project";
|
||||
import { CreateUpdateIssueModal } from "components/issues";
|
||||
import { CreateUpdateCycleModal } from "components/cycles";
|
||||
import { CreateUpdateModuleModal } from "components/modules";
|
||||
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
|
||||
// ui
|
||||
import { Button } from "components/ui";
|
||||
// icons
|
||||
@ -102,10 +102,10 @@ export const CommandPalette: React.FC = () => {
|
||||
!(e.target instanceof HTMLInputElement) &&
|
||||
!(e.target as Element).classList?.contains("remirror-editor")
|
||||
) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
} else if (e.ctrlKey && (e.key === "c" || e.key === "C")) {
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
|
||||
if (e.altKey) {
|
||||
e.preventDefault();
|
||||
if (!router.query.issueId) return;
|
||||
@ -124,26 +124,23 @@ export const CommandPalette: React.FC = () => {
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
console.log("URL Copied");
|
||||
} else {
|
||||
console.log("Text copied");
|
||||
}
|
||||
} else if (e.key === "c" || e.key === "C") {
|
||||
} else if (e.key.toLowerCase() === "c") {
|
||||
e.preventDefault();
|
||||
setIsIssueModalOpen(true);
|
||||
} else if (e.key === "p" || e.key === "P") {
|
||||
} else if (e.key.toLowerCase() === "p") {
|
||||
e.preventDefault();
|
||||
setIsProjectModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && (e.key === "b" || e.key === "B")) {
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") {
|
||||
e.preventDefault();
|
||||
toggleCollapsed();
|
||||
} else if (e.key === "h" || e.key === "H") {
|
||||
} else if (e.key.toLowerCase() === "h") {
|
||||
e.preventDefault();
|
||||
setIsShortcutsModalOpen(true);
|
||||
} else if (e.key === "q" || e.key === "Q") {
|
||||
} else if (e.key.toLowerCase() === "q") {
|
||||
e.preventDefault();
|
||||
setIsCreateCycleModalOpen(true);
|
||||
} else if (e.key === "m" || e.key === "M") {
|
||||
} else if (e.key.toLowerCase() === "m") {
|
||||
e.preventDefault();
|
||||
setIsCreateModuleModalOpen(true);
|
||||
} else if (e.key === "Delete") {
|
||||
@ -172,8 +169,7 @@ export const CommandPalette: React.FC = () => {
|
||||
<>
|
||||
<CreateUpdateCycleModal
|
||||
isOpen={isCreateCycleModalOpen}
|
||||
setIsOpen={setIsCreateCycleModalOpen}
|
||||
projectId={projectId as string}
|
||||
handleClose={() => setIsCreateCycleModalOpen(false)}
|
||||
/>
|
||||
<CreateUpdateModuleModal
|
||||
isOpen={isCreateModuleModalOpen}
|
||||
|
@ -45,9 +45,10 @@ const allShortcuts = shortcuts.map((i) => i.shortcuts).flat(1);
|
||||
|
||||
export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filteredShortcuts = allShortcuts.filter((shortcut) =>
|
||||
shortcut.description.includes(query.trim()) || query === "" ? true : false
|
||||
shortcut.description.toLowerCase().includes(query.trim().toLowerCase()) || query === ""
|
||||
? true
|
||||
: false
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -11,9 +11,11 @@ type Props = {
|
||||
states: IState[] | undefined;
|
||||
members: IProjectMember[] | undefined;
|
||||
addIssueToState: (groupTitle: string, stateId: string | null) => void;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
removeIssue: ((bridgeId: string) => void) | null;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
@ -23,9 +25,11 @@ export const AllBoards: React.FC<Props> = ({
|
||||
states,
|
||||
members,
|
||||
addIssueToState,
|
||||
handleEditIssue,
|
||||
openIssuesListModal,
|
||||
handleDeleteIssue,
|
||||
handleTrashBox,
|
||||
removeIssue,
|
||||
userAuth,
|
||||
}) => {
|
||||
const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssueView(issues);
|
||||
@ -57,11 +61,13 @@ export const AllBoards: React.FC<Props> = ({
|
||||
groupedByIssues={groupedByIssues}
|
||||
selectedGroup={selectedGroup}
|
||||
members={members}
|
||||
handleEditIssue={handleEditIssue}
|
||||
addIssueToState={() => addIssueToState(singleGroup, stateId)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
openIssuesListModal={openIssuesListModal ?? null}
|
||||
orderBy={orderBy}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={removeIssue}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
);
|
||||
|
@ -12,80 +12,100 @@ import {
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import { IIssue, IProjectMember, NestedKeyOf } from "types";
|
||||
type Props = {
|
||||
isCollapsed: boolean;
|
||||
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
groupedByIssues: {
|
||||
[key: string]: IIssue[];
|
||||
};
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
groupTitle: string;
|
||||
createdBy: string | null;
|
||||
bgColor?: string;
|
||||
addIssueToState: () => void;
|
||||
members: IProjectMember[] | undefined;
|
||||
isCollapsed: boolean;
|
||||
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const BoardHeader: React.FC<Props> = ({
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
groupedByIssues,
|
||||
selectedGroup,
|
||||
groupTitle,
|
||||
createdBy,
|
||||
bgColor,
|
||||
addIssueToState,
|
||||
}) => (
|
||||
<div
|
||||
className={`flex justify-between p-3 pb-0 ${
|
||||
!isCollapsed ? "flex-col rounded-md border bg-gray-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
|
||||
<div
|
||||
className={`flex cursor-pointer items-center gap-x-1 rounded-md bg-slate-900 px-2 ${
|
||||
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
|
||||
}`}
|
||||
style={{
|
||||
border: `2px solid ${bgColor}`,
|
||||
backgroundColor: `${bgColor}20`,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
className={`text-[0.9rem] font-medium capitalize`}
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
members,
|
||||
}) => {
|
||||
const createdBy =
|
||||
selectedGroup === "created_by"
|
||||
? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "loading..."
|
||||
: null;
|
||||
|
||||
let assignees: any;
|
||||
if (selectedGroup === "assignees") {
|
||||
assignees = groupTitle && groupTitle !== "" ? groupTitle.split(",") : [];
|
||||
assignees =
|
||||
assignees.length > 0
|
||||
? assignees
|
||||
.map((a: string) => members?.find((m) => m.member.id === a)?.member.first_name)
|
||||
.join(", ")
|
||||
: "No assignee";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-between p-3 pb-0 ${
|
||||
!isCollapsed ? "flex-col rounded-md border bg-gray-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
|
||||
<div
|
||||
className={`flex cursor-pointer items-center gap-x-1 rounded-md bg-slate-900 px-2 ${
|
||||
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
|
||||
}`}
|
||||
style={{
|
||||
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
|
||||
border: `2px solid ${bgColor}`,
|
||||
backgroundColor: `${bgColor}20`,
|
||||
}}
|
||||
>
|
||||
{groupTitle === null || groupTitle === "null"
|
||||
? "None"
|
||||
: createdBy
|
||||
? createdBy
|
||||
: addSpaceIfCamelCase(groupTitle)}
|
||||
</h2>
|
||||
<span className="ml-0.5 text-sm text-gray-500">{groupedByIssues[groupTitle].length}</span>
|
||||
<h2
|
||||
className={`text-[0.9rem] font-medium capitalize`}
|
||||
style={{
|
||||
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
|
||||
}}
|
||||
>
|
||||
{selectedGroup === "created_by"
|
||||
? createdBy
|
||||
: selectedGroup === "assignees"
|
||||
? assignees
|
||||
: addSpaceIfCamelCase(groupTitle)}
|
||||
</h2>
|
||||
<span className="ml-0.5 text-sm text-gray-500">{groupedByIssues[groupTitle].length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center ${!isCollapsed ? "flex-col pb-2" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
|
||||
onClick={() => {
|
||||
setIsCollapsed((prevData) => !prevData);
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ArrowsPointingInIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowsPointingOutIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center ${!isCollapsed ? "flex-col pb-2" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
|
||||
onClick={() => {
|
||||
setIsCollapsed((prevData) => !prevData);
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ArrowsPointingInIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowsPointingOutIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
@ -25,11 +25,13 @@ type Props = {
|
||||
};
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
members: IProjectMember[] | undefined;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
addIssueToState: () => void;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
orderBy: NestedKeyOf<IIssue> | "manual" | null;
|
||||
orderBy: NestedKeyOf<IIssue> | null;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
removeIssue: ((bridgeId: string) => void) | null;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
@ -40,11 +42,13 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
groupedByIssues,
|
||||
selectedGroup,
|
||||
members,
|
||||
handleEditIssue,
|
||||
addIssueToState,
|
||||
handleDeleteIssue,
|
||||
openIssuesListModal,
|
||||
orderBy,
|
||||
handleTrashBox,
|
||||
removeIssue,
|
||||
userAuth,
|
||||
}) => {
|
||||
// collapse/expand
|
||||
@ -55,11 +59,6 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
|
||||
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||
|
||||
const createdBy =
|
||||
selectedGroup === "created_by"
|
||||
? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "loading..."
|
||||
: null;
|
||||
|
||||
if (selectedGroup === "priority")
|
||||
groupTitle === "high"
|
||||
? (bgColor = "#dc2626")
|
||||
@ -77,27 +76,46 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
<BoardHeader
|
||||
addIssueToState={addIssueToState}
|
||||
bgColor={bgColor}
|
||||
createdBy={createdBy}
|
||||
selectedGroup={selectedGroup}
|
||||
groupTitle={groupTitle}
|
||||
groupedByIssues={groupedByIssues}
|
||||
isCollapsed={isCollapsed}
|
||||
setIsCollapsed={setIsCollapsed}
|
||||
members={members}
|
||||
/>
|
||||
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`relative mt-3 h-full space-y-3 px-3 pb-3 ${
|
||||
className={`relative mt-3 h-full px-3 pb-3 ${
|
||||
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
|
||||
} ${!isCollapsed ? "hidden" : "block"}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{orderBy !== "sort_order" && (
|
||||
<>
|
||||
<div
|
||||
className={`absolute ${
|
||||
snapshot.isDraggingOver ? "block" : "hidden"
|
||||
} top-0 left-0 h-full w-full bg-indigo-200 opacity-50 pointer-events-none z-[99999998]`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute ${
|
||||
snapshot.isDraggingOver ? "block" : "hidden"
|
||||
} top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 text-xs whitespace-nowrap bg-white p-2 rounded pointer-events-none z-[99999999]`}
|
||||
>
|
||||
This board is ordered by {orderBy}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{groupedByIssues[groupTitle].map((issue, index: number) => (
|
||||
<Draggable
|
||||
key={issue.id}
|
||||
draggableId={issue.id}
|
||||
index={index}
|
||||
isDragDisabled={isNotAllowed || selectedGroup === "created_by"}
|
||||
isDragDisabled={
|
||||
isNotAllowed || selectedGroup === "created_by" || selectedGroup === "assignees"
|
||||
}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<SingleBoardIssue
|
||||
@ -106,10 +124,15 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
snapshot={snapshot}
|
||||
type={type}
|
||||
issue={issue}
|
||||
selectedGroup={selectedGroup}
|
||||
properties={properties}
|
||||
editIssue={() => handleEditIssue(issue)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
orderBy={orderBy}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={() => {
|
||||
removeIssue && removeIssue(issue.bridge);
|
||||
}}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
)}
|
||||
@ -117,7 +140,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
))}
|
||||
<span
|
||||
style={{
|
||||
display: orderBy === "manual" ? "inline" : "none",
|
||||
display: orderBy === "sort_order" ? "inline" : "none",
|
||||
}}
|
||||
>
|
||||
{provided.placeholder}
|
||||
|
@ -12,10 +12,10 @@ import {
|
||||
DraggingStyle,
|
||||
NotDraggingStyle,
|
||||
} from "react-beautiful-dnd";
|
||||
// constants
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import {
|
||||
ViewAssigneeSelect,
|
||||
@ -23,11 +23,14 @@ import {
|
||||
ViewPrioritySelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues/view-select";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import {
|
||||
CycleIssueResponse,
|
||||
IIssue,
|
||||
IssueResponse,
|
||||
ModuleIssueResponse,
|
||||
NestedKeyOf,
|
||||
Properties,
|
||||
@ -41,9 +44,12 @@ type Props = {
|
||||
provided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
issue: IIssue;
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
properties: Properties;
|
||||
editIssue: () => void;
|
||||
removeIssue?: (() => void) | null;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
orderBy: NestedKeyOf<IIssue> | "manual" | null;
|
||||
orderBy: NestedKeyOf<IIssue> | null;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
@ -53,7 +59,10 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
provided,
|
||||
snapshot,
|
||||
issue,
|
||||
selectedGroup,
|
||||
properties,
|
||||
editIssue,
|
||||
removeIssue,
|
||||
handleDeleteIssue,
|
||||
orderBy,
|
||||
handleTrashBox,
|
||||
@ -62,6 +71,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const partialUpdateIssue = useCallback(
|
||||
(formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
@ -108,15 +119,15 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
false
|
||||
);
|
||||
|
||||
mutate<IssueResponse>(
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) => ({
|
||||
...(prevData as IssueResponse),
|
||||
results: (prevData?.results ?? []).map((p) => {
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === issue.id) return { ...p, ...formData };
|
||||
|
||||
return p;
|
||||
}),
|
||||
}),
|
||||
|
||||
false
|
||||
);
|
||||
|
||||
@ -139,7 +150,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
style: DraggingStyle | NotDraggingStyle | undefined,
|
||||
snapshot: DraggableStateSnapshot
|
||||
) {
|
||||
if (orderBy === "manual") return style;
|
||||
if (orderBy === "sort_order") return style;
|
||||
if (!snapshot.isDragging) return {};
|
||||
if (!snapshot.isDropAnimating) {
|
||||
return style;
|
||||
@ -151,15 +162,33 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
};
|
||||
}
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
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: "Issue link copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
|
||||
}, [snapshot, handleTrashBox]);
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded border bg-white shadow-sm ${
|
||||
className={`rounded border bg-white shadow-sm mb-3 ${
|
||||
snapshot.isDragging ? "border-theme bg-indigo-50 shadow-lg" : ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
@ -170,13 +199,20 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
<div className="group/card relative select-none p-2">
|
||||
{!isNotAllowed && (
|
||||
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded bg-white p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
|
||||
onClick={() => handleDeleteIssue(issue)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
{type && !isNotAllowed && (
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={editIssue}>Edit</CustomMenu.MenuItem>
|
||||
{type !== "issue" && removeIssue && (
|
||||
<CustomMenu.MenuItem onClick={removeIssue}>
|
||||
<>Remove from {type}</>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete permanently
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
@ -195,7 +231,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-x-1 gap-y-2 text-xs">
|
||||
{properties.priority && (
|
||||
{properties.priority && selectedGroup !== "priority" && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@ -203,7 +239,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
position="left"
|
||||
/>
|
||||
)}
|
||||
{properties.state && (
|
||||
{properties.state && selectedGroup !== "state_detail.name" && (
|
||||
<ViewStateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
|
@ -18,7 +18,7 @@ import { Button } from "components/ui";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { LayerDiagonalIcon } from "components/icons";
|
||||
// types
|
||||
import { IIssue, IssueResponse } from "types";
|
||||
import { IIssue } from "types";
|
||||
// fetch keys
|
||||
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
|
||||
@ -35,10 +35,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
query: { workspaceSlug, projectId },
|
||||
} = router;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
@ -65,8 +62,8 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
||||
|
||||
const filteredIssues: IIssue[] =
|
||||
query === ""
|
||||
? issues?.results ?? []
|
||||
: issues?.results.filter(
|
||||
? issues ?? []
|
||||
: issues?.filter(
|
||||
(issue) =>
|
||||
issue.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
`${issue.project_detail.identifier}-${issue.sequence_id}`
|
||||
@ -104,17 +101,9 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
||||
message: res.message,
|
||||
});
|
||||
|
||||
mutate<IssueResponse>(
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) => ({
|
||||
...(prevData as IssueResponse),
|
||||
count: (prevData?.results ?? []).filter(
|
||||
(p) => !data.delete_issue_ids.some((id) => p.id === id)
|
||||
).length,
|
||||
results: (prevData?.results ?? []).filter(
|
||||
(p) => !data.delete_issue_ids.some((id) => p.id === id)
|
||||
),
|
||||
}),
|
||||
(prevData) => (prevData ?? []).filter((p) => !data.delete_issue_ids.includes(p.id)),
|
||||
false
|
||||
);
|
||||
handleClose();
|
||||
|
@ -20,7 +20,6 @@ type FormInput = {
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
type: string;
|
||||
issues: IIssue[];
|
||||
handleOnSubmit: any;
|
||||
};
|
||||
@ -30,7 +29,6 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
handleClose: onClose,
|
||||
issues,
|
||||
handleOnSubmit,
|
||||
type,
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
@ -105,7 +103,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
||||
<form>
|
||||
<Controller
|
||||
control={control}
|
||||
@ -132,7 +130,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
Select issues to add to {type}
|
||||
Select issues to add
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
@ -203,7 +201,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Adding..." : `Add to ${type}`}
|
||||
{isSubmitting ? "Adding..." : "Add selected issues"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
@ -6,4 +6,5 @@ export * from "./existing-issues-list-modal";
|
||||
export * from "./image-upload-modal";
|
||||
export * from "./issues-view-filter";
|
||||
export * from "./issues-view";
|
||||
export * from "./link-modal";
|
||||
export * from "./not-authorized-view";
|
||||
|
@ -130,7 +130,9 @@ export const IssuesFilterView: React.FC<Props> = ({ issues }) => {
|
||||
option.key === "priority" ? null : (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => setOrderBy(option.key)}
|
||||
onClick={() => {
|
||||
setOrderBy(option.key);
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
@ -178,20 +180,29 @@ export const IssuesFilterView: React.FC<Props> = ({ issues }) => {
|
||||
<div className="space-y-2 py-3">
|
||||
<h4 className="text-sm text-gray-600">Display Properties</h4>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{Object.keys(properties).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||
properties[key as keyof Properties]
|
||||
? "border-theme bg-theme text-white"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
onClick={() => setProperties(key as keyof Properties)}
|
||||
>
|
||||
{replaceUnderscoreIfSnakeCase(key)}
|
||||
</button>
|
||||
))}
|
||||
{Object.keys(properties).map((key) => {
|
||||
if (
|
||||
issueView === "kanban" &&
|
||||
((groupByProperty === "state_detail.name" && key === "state") ||
|
||||
(groupByProperty === "priority" && key === "priority"))
|
||||
)
|
||||
return;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||
properties[key as keyof Properties]
|
||||
? "border-theme bg-theme text-white"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
onClick={() => setProperties(key as keyof Properties)}
|
||||
>
|
||||
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -22,7 +22,7 @@ import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
// types
|
||||
import { CycleIssueResponse, IIssue, IssueResponse, ModuleIssueResponse, UserAuth } from "types";
|
||||
import { CycleIssueResponse, IIssue, ModuleIssueResponse, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
CYCLE_ISSUES,
|
||||
@ -67,7 +67,12 @@ export const IssuesView: React.FC<Props> = ({
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const { issueView, groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues);
|
||||
const {
|
||||
issueView,
|
||||
groupedByIssues,
|
||||
groupByProperty: selectedGroup,
|
||||
orderBy,
|
||||
} = useIssueView(issues);
|
||||
|
||||
const { data: stateGroups } = useSWR(
|
||||
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
|
||||
@ -105,185 +110,135 @@ export const IssuesView: React.FC<Props> = ({
|
||||
if (destination.droppableId === "trashBox") {
|
||||
handleDeleteIssue(draggedItem);
|
||||
} else {
|
||||
if (source.droppableId !== destination.droppableId) {
|
||||
if (orderBy === "sort_order") {
|
||||
let newSortOrder = draggedItem.sort_order;
|
||||
|
||||
const destinationGroupArray = groupedByIssues[destination.droppableId];
|
||||
|
||||
if (destinationGroupArray.length !== 0) {
|
||||
// check if dropping in the same group
|
||||
if (source.droppableId === destination.droppableId) {
|
||||
// check if dropping at beginning
|
||||
if (destination.index === 0)
|
||||
newSortOrder = destinationGroupArray[0].sort_order - 10000;
|
||||
// check if dropping at last
|
||||
else if (destination.index === destinationGroupArray.length - 1)
|
||||
newSortOrder =
|
||||
destinationGroupArray[destinationGroupArray.length - 1].sort_order + 10000;
|
||||
else {
|
||||
if (destination.index > source.index)
|
||||
newSortOrder =
|
||||
(destinationGroupArray[source.index + 1].sort_order +
|
||||
destinationGroupArray[source.index + 2].sort_order) /
|
||||
2;
|
||||
else if (destination.index < source.index)
|
||||
newSortOrder =
|
||||
(destinationGroupArray[source.index - 1].sort_order +
|
||||
destinationGroupArray[source.index - 2].sort_order) /
|
||||
2;
|
||||
}
|
||||
} else {
|
||||
// check if dropping at beginning
|
||||
if (destination.index === 0)
|
||||
newSortOrder = destinationGroupArray[0].sort_order - 10000;
|
||||
// check if dropping at last
|
||||
else if (destination.index === destinationGroupArray.length)
|
||||
newSortOrder =
|
||||
destinationGroupArray[destinationGroupArray.length - 1].sort_order + 10000;
|
||||
else
|
||||
newSortOrder =
|
||||
(destinationGroupArray[destination.index - 1].sort_order +
|
||||
destinationGroupArray[destination.index].sort_order) /
|
||||
2;
|
||||
}
|
||||
}
|
||||
|
||||
draggedItem.sort_order = newSortOrder;
|
||||
}
|
||||
|
||||
if (orderBy === "sort_order" || source.droppableId !== destination.droppableId) {
|
||||
const sourceGroup = source.droppableId; // source group id
|
||||
const destinationGroup = destination.droppableId; // destination group id
|
||||
|
||||
if (!sourceGroup || !destinationGroup) return;
|
||||
|
||||
if (selectedGroup === "priority") {
|
||||
// update the removed item for mutation
|
||||
draggedItem.priority = destinationGroup;
|
||||
|
||||
if (cycleId)
|
||||
mutate<CycleIssueResponse[]>(
|
||||
CYCLE_ISSUES(cycleId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
const updatedIssues = prevData.map((issue) => {
|
||||
if (issue.issue_detail.id === draggedItem.id) {
|
||||
return {
|
||||
...issue,
|
||||
issue_detail: {
|
||||
...draggedItem,
|
||||
priority: destinationGroup,
|
||||
},
|
||||
};
|
||||
}
|
||||
return issue;
|
||||
});
|
||||
return [...updatedIssues];
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
if (moduleId)
|
||||
mutate<ModuleIssueResponse[]>(
|
||||
MODULE_ISSUES(moduleId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
const updatedIssues = prevData.map((issue) => {
|
||||
if (issue.issue_detail.id === draggedItem.id) {
|
||||
return {
|
||||
...issue,
|
||||
issue_detail: {
|
||||
...draggedItem,
|
||||
priority: destinationGroup,
|
||||
},
|
||||
};
|
||||
}
|
||||
return issue;
|
||||
});
|
||||
return [...updatedIssues];
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
mutate<IssueResponse>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const updatedIssues = prevData.results.map((issue) => {
|
||||
if (issue.id === draggedItem.id)
|
||||
return {
|
||||
...draggedItem,
|
||||
priority: destinationGroup,
|
||||
};
|
||||
|
||||
return issue;
|
||||
});
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
results: updatedIssues,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
// patch request
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
|
||||
priority: destinationGroup,
|
||||
})
|
||||
.then((res) => {
|
||||
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
|
||||
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
|
||||
|
||||
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
|
||||
});
|
||||
} else if (selectedGroup === "state_detail.name") {
|
||||
if (selectedGroup === "priority") draggedItem.priority = destinationGroup;
|
||||
else if (selectedGroup === "state_detail.name") {
|
||||
const destinationState = states?.find((s) => s.name === destinationGroup);
|
||||
const destinationStateId = destinationState?.id;
|
||||
|
||||
// update the removed item for mutation
|
||||
if (!destinationStateId || !destinationState) return;
|
||||
draggedItem.state = destinationStateId;
|
||||
if (!destinationState) return;
|
||||
|
||||
draggedItem.state = destinationState.id;
|
||||
draggedItem.state_detail = destinationState;
|
||||
}
|
||||
|
||||
if (cycleId)
|
||||
mutate<CycleIssueResponse[]>(
|
||||
CYCLE_ISSUES(cycleId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
const updatedIssues = prevData.map((issue) => {
|
||||
if (issue.issue_detail.id === draggedItem.id) {
|
||||
return {
|
||||
...issue,
|
||||
issue_detail: {
|
||||
...draggedItem,
|
||||
state_detail: destinationState,
|
||||
state: destinationStateId,
|
||||
},
|
||||
};
|
||||
}
|
||||
return issue;
|
||||
});
|
||||
return [...updatedIssues];
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
if (moduleId)
|
||||
mutate<ModuleIssueResponse[]>(
|
||||
MODULE_ISSUES(moduleId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
const updatedIssues = prevData.map((issue) => {
|
||||
if (issue.issue_detail.id === draggedItem.id) {
|
||||
return {
|
||||
...issue,
|
||||
issue_detail: {
|
||||
...draggedItem,
|
||||
state_detail: destinationState,
|
||||
state: destinationStateId,
|
||||
},
|
||||
};
|
||||
}
|
||||
return issue;
|
||||
});
|
||||
return [...updatedIssues];
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
mutate<IssueResponse>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
if (cycleId)
|
||||
mutate<CycleIssueResponse[]>(
|
||||
CYCLE_ISSUES(cycleId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const updatedIssues = prevData.results.map((issue) => {
|
||||
if (issue.id === draggedItem.id)
|
||||
const updatedIssues = prevData.map((issue) => {
|
||||
if (issue.issue_detail.id === draggedItem.id) {
|
||||
return {
|
||||
...draggedItem,
|
||||
state_detail: destinationState,
|
||||
state: destinationStateId,
|
||||
...issue,
|
||||
issue_detail: draggedItem,
|
||||
};
|
||||
|
||||
}
|
||||
return issue;
|
||||
});
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
results: updatedIssues,
|
||||
};
|
||||
return [...updatedIssues];
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
// patch request
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
|
||||
state: destinationStateId,
|
||||
})
|
||||
.then((res) => {
|
||||
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
|
||||
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
|
||||
if (moduleId)
|
||||
mutate<ModuleIssueResponse[]>(
|
||||
MODULE_ISSUES(moduleId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
const updatedIssues = prevData.map((issue) => {
|
||||
if (issue.issue_detail.id === draggedItem.id) {
|
||||
return {
|
||||
...issue,
|
||||
issue_detail: draggedItem,
|
||||
};
|
||||
}
|
||||
return issue;
|
||||
});
|
||||
return [...updatedIssues];
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const updatedIssues = prevData.map((i) => {
|
||||
if (i.id === draggedItem.id) return draggedItem;
|
||||
|
||||
return i;
|
||||
});
|
||||
}
|
||||
|
||||
return updatedIssues;
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
// patch request
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
|
||||
priority: draggedItem.priority,
|
||||
state: draggedItem.state,
|
||||
sort_order: draggedItem.sort_order,
|
||||
})
|
||||
.then((res) => {
|
||||
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
|
||||
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
|
||||
|
||||
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -294,6 +249,7 @@ export const IssuesView: React.FC<Props> = ({
|
||||
groupedByIssues,
|
||||
projectId,
|
||||
selectedGroup,
|
||||
orderBy,
|
||||
states,
|
||||
handleDeleteIssue,
|
||||
]
|
||||
@ -452,9 +408,17 @@ export const IssuesView: React.FC<Props> = ({
|
||||
states={states}
|
||||
members={members}
|
||||
addIssueToState={addIssueToState}
|
||||
handleEditIssue={handleEditIssue}
|
||||
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={
|
||||
type === "cycle"
|
||||
? removeIssueFromCycle
|
||||
: type === "module"
|
||||
? removeIssueFromModule
|
||||
: null
|
||||
}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
)}
|
||||
|
@ -8,19 +8,15 @@ import { mutate } from "swr";
|
||||
import { useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import modulesService from "services/modules.service";
|
||||
// ui
|
||||
import { Button, Input } from "components/ui";
|
||||
// types
|
||||
import type { IModule, ModuleLink } from "types";
|
||||
// fetch-keys
|
||||
import { MODULE_DETAILS } from "constants/fetch-keys";
|
||||
import type { IIssueLink, ModuleLink } from "types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
module: IModule | undefined;
|
||||
handleClose: () => void;
|
||||
onFormSubmit: (formData: IIssueLink | ModuleLink) => void;
|
||||
};
|
||||
|
||||
const defaultValues: ModuleLink = {
|
||||
@ -28,42 +24,20 @@ const defaultValues: ModuleLink = {
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, moduleId } = router.query;
|
||||
|
||||
export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }) => {
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
} = useForm<ModuleLink>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: ModuleLink) => {
|
||||
if (!workspaceSlug || !projectId || !moduleId) return;
|
||||
await onFormSubmit(formData);
|
||||
|
||||
const previousLinks = module?.link_module.map((l) => ({ title: l.title, url: l.url }));
|
||||
|
||||
const payload: Partial<IModule> = {
|
||||
links_list: [...(previousLinks ?? []), formData],
|
||||
};
|
||||
|
||||
await modulesService
|
||||
.patchModule(workspaceSlug as string, projectId as string, moduleId as string, payload)
|
||||
.then((res) => {
|
||||
mutate(MODULE_DETAILS(moduleId as string));
|
||||
onClose();
|
||||
})
|
||||
.catch((err) => {
|
||||
Object.keys(err).map((key) => {
|
||||
setError(key as keyof ModuleLink, {
|
||||
message: err[key].join(", "),
|
||||
});
|
||||
});
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onClose = () => {
|
@ -7,6 +7,8 @@ import { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import {
|
||||
ViewAssigneeSelect,
|
||||
@ -14,19 +16,15 @@ import {
|
||||
ViewPrioritySelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues/view-select";
|
||||
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
import { Tooltip, CustomMenu } from "components/ui";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import {
|
||||
CycleIssueResponse,
|
||||
IIssue,
|
||||
IssueResponse,
|
||||
ModuleIssueResponse,
|
||||
Properties,
|
||||
UserAuth,
|
||||
} from "types";
|
||||
import { CycleIssueResponse, IIssue, ModuleIssueResponse, Properties, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST, STATE_LIST } from "constants/fetch-keys";
|
||||
import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
type?: string;
|
||||
@ -49,7 +47,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const partialUpdateIssue = useCallback(
|
||||
(formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
@ -96,15 +94,15 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
false
|
||||
);
|
||||
|
||||
mutate<IssueResponse>(
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) => ({
|
||||
...(prevData as IssueResponse),
|
||||
results: (prevData?.results ?? []).map((p) => {
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === issue.id) return { ...p, ...formData };
|
||||
|
||||
return p;
|
||||
}),
|
||||
}),
|
||||
|
||||
false
|
||||
);
|
||||
|
||||
@ -123,6 +121,23 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
[workspaceSlug, projectId, cycleId, moduleId, issue]
|
||||
);
|
||||
|
||||
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: "Issue link copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
};
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
@ -137,11 +152,20 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
|
||||
<a className="group relative flex items-center gap-2">
|
||||
{properties.key && (
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<Tooltip
|
||||
tooltipHeading="ID"
|
||||
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span>{issue.name}</span>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="w-auto max-w-lg text-ellipsis overflow-hidden whitespace-nowrap">
|
||||
{issue.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
@ -190,6 +214,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete permanently
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
|
@ -50,9 +50,20 @@ export const SingleList: React.FC<Props> = ({
|
||||
|
||||
const createdBy =
|
||||
selectedGroup === "created_by"
|
||||
? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "loading..."
|
||||
? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "Loading..."
|
||||
: null;
|
||||
|
||||
let assignees: any;
|
||||
if (selectedGroup === "assignees") {
|
||||
assignees = groupTitle && groupTitle !== "" ? groupTitle.split(",") : [];
|
||||
assignees =
|
||||
assignees.length > 0
|
||||
? assignees
|
||||
.map((a: string) => members?.find((m) => m.member.id === a)?.member.first_name)
|
||||
.join(", ")
|
||||
: "No assignee";
|
||||
}
|
||||
|
||||
return (
|
||||
<Disclosure key={groupTitle} as="div" defaultOpen>
|
||||
{({ open }) => (
|
||||
@ -67,10 +78,10 @@ export const SingleList: React.FC<Props> = ({
|
||||
</span>
|
||||
{selectedGroup !== null ? (
|
||||
<h2 className="font-medium capitalize leading-5">
|
||||
{groupTitle === null || groupTitle === "null"
|
||||
? "None"
|
||||
: createdBy
|
||||
{selectedGroup === "created_by"
|
||||
? createdBy
|
||||
: selectedGroup === "assignees"
|
||||
? assignees
|
||||
: addSpaceIfCamelCase(groupTitle)}
|
||||
</h2>
|
||||
) : (
|
||||
|
@ -1,2 +1,3 @@
|
||||
export * from "./links-list";
|
||||
export * from "./sidebar-progress-stats";
|
||||
export * from "./single-progress-stats";
|
||||
|
58
apps/app/components/core/sidebar/links-list.tsx
Normal file
58
apps/app/components/core/sidebar/links-list.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
|
||||
// icons
|
||||
import { LinkIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { timeAgo } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IUserLite, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
links: {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
created_by_detail: IUserLite;
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
handleDeleteLink: (linkId: string) => void;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }) => {
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
<>
|
||||
{links.map((link) => (
|
||||
<div key={link.id} className="group relative">
|
||||
{!isNotAllowed && (
|
||||
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded bg-gray-100 p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
|
||||
onClick={() => handleDeleteLink(link.id)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Link href={link.url} target="_blank">
|
||||
<a className="group relative flex gap-2 rounded-md border bg-gray-100 p-2">
|
||||
<div className="mt-0.5">
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div>
|
||||
<h5>{link.title}</h5>
|
||||
{/* <p className="mt-0.5 text-gray-500">
|
||||
Added {timeAgo(link.created_at)} ago by {link.created_by_detail.email}
|
||||
</p> */}
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
@ -46,6 +46,16 @@ const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
|
||||
const ChartData = getChartData();
|
||||
return (
|
||||
<div className="relative h-[200px] w-full ">
|
||||
<div className="flex justify-start items-start gap-4 text-xs">
|
||||
<div className="flex justify-center items-center gap-1">
|
||||
<span className="h-2 w-2 bg-green-600 rounded-full" />
|
||||
<span>Ideal</span>
|
||||
</div>
|
||||
<div className="flex justify-center items-center gap-1">
|
||||
<span className="h-2 w-2 bg-[#8884d8] rounded-full" />
|
||||
<span>Current</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-full w-full absolute -left-8 py-3 text-xs">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
@ -80,16 +90,6 @@ const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="z-10 flex flex-col absolute top-2 right-2 justify-center items-start gap-1 text-xs">
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<span className="h-2 w-2 bg-green-600" />
|
||||
<span>Ideal</span>
|
||||
</div>
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<span className="h-2 w-2 bg-[#8884d8]" />
|
||||
<span>Current</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -10,6 +10,8 @@ import { Tab } from "@headlessui/react";
|
||||
// services
|
||||
import issuesServices from "services/issues.service";
|
||||
import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
// components
|
||||
import { SingleProgressStats } from "components/core";
|
||||
// ui
|
||||
@ -20,7 +22,6 @@ import User from "public/user.png";
|
||||
import { IIssue, IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
// types
|
||||
type Props = {
|
||||
groupedIssues: any;
|
||||
@ -39,8 +40,10 @@ const stateGroupColours: {
|
||||
|
||||
export const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues }) => {
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useLocalStorage("tab", "Assignees");
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees");
|
||||
|
||||
const { data: issueLabels } = useSWR<IIssueLabels[]>(
|
||||
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
@ -55,7 +58,7 @@ export const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues })
|
||||
: null
|
||||
);
|
||||
|
||||
const currentValue = (tab: string) => {
|
||||
const currentValue = (tab: string | null) => {
|
||||
switch (tab) {
|
||||
case "Assignees":
|
||||
return 0;
|
||||
@ -63,6 +66,8 @@ export const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues })
|
||||
return 1;
|
||||
case "States":
|
||||
return 2;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
return (
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
import { CircularProgressbar } from "react-circular-progressbar";
|
||||
import { ProgressBar } from "components/ui";
|
||||
|
||||
type TSingleProgressStatsProps = {
|
||||
title: any;
|
||||
@ -18,7 +18,7 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
|
||||
<div className="flex items-center justify-end w-1/2 gap-1 px-2">
|
||||
<div className="flex h-5 justify-center items-center gap-1 ">
|
||||
<span className="h-4 w-4 ">
|
||||
<CircularProgressbar value={completed} maxValue={total} strokeWidth={10} />
|
||||
<ProgressBar value={completed} maxValue={total} />
|
||||
</span>
|
||||
<span className="w-8 text-right">{Math.floor((completed / total) * 100)}%</span>
|
||||
</div>
|
||||
|
@ -1,8 +1,7 @@
|
||||
// react
|
||||
import { useState } from "react";
|
||||
// components
|
||||
import SingleStat from "components/project/cycles/stats-view/single-stat";
|
||||
import ConfirmCycleDeletion from "components/project/cycles/confirm-cycle-deletion";
|
||||
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
|
||||
// types
|
||||
import { ICycle, SelectCycleType } from "types";
|
||||
import { CompletedCycleIcon, CurrentCycleIcon, UpcomingCycleIcon } from "components/icons";
|
||||
@ -14,7 +13,7 @@ type TCycleStatsViewProps = {
|
||||
type: "current" | "upcoming" | "completed";
|
||||
};
|
||||
|
||||
const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
|
||||
export const CyclesListView: React.FC<TCycleStatsViewProps> = ({
|
||||
cycles,
|
||||
setCreateUpdateCycleModal,
|
||||
setSelectedCycle,
|
||||
@ -35,7 +34,7 @@ const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmCycleDeletion
|
||||
<DeleteCycleModal
|
||||
isOpen={
|
||||
cycleDeleteModal &&
|
||||
!!selectedCycleForDelete &&
|
||||
@ -46,7 +45,7 @@ const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
|
||||
/>
|
||||
{cycles.length > 0 ? (
|
||||
cycles.map((cycle) => (
|
||||
<SingleStat
|
||||
<SingleCycleCard
|
||||
key={cycle.id}
|
||||
cycle={cycle}
|
||||
handleDeleteCycle={() => handleDeleteCycle(cycle)}
|
||||
@ -71,5 +70,3 @@ const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CycleStatsView;
|
@ -23,7 +23,7 @@ type TConfirmCycleDeletionProps = {
|
||||
// fetch-keys
|
||||
import { CYCLE_LIST } from "constants/fetch-keys";
|
||||
|
||||
const ConfirmCycleDeletion: React.FC<TConfirmCycleDeletionProps> = ({
|
||||
export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
data,
|
||||
@ -149,5 +149,3 @@ const ConfirmCycleDeletion: React.FC<TConfirmCycleDeletionProps> = ({
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmCycleDeletion;
|
@ -1,39 +1,59 @@
|
||||
import { FC } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// components
|
||||
import { Button, Input, TextArea, CustomSelect } from "components/ui";
|
||||
// ui
|
||||
import { Button, CustomDatePicker, CustomSelect, Input, TextArea } from "components/ui";
|
||||
// types
|
||||
import type { ICycle } from "types";
|
||||
import { ICycle } from "types";
|
||||
|
||||
type Props = {
|
||||
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
|
||||
handleClose: () => void;
|
||||
status: boolean;
|
||||
data?: ICycle;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<ICycle> = {
|
||||
name: "",
|
||||
description: "",
|
||||
status: "draft",
|
||||
start_date: new Date().toString(),
|
||||
end_date: new Date().toString(),
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
};
|
||||
|
||||
export interface CycleFormProps {
|
||||
handleFormSubmit: (values: Partial<ICycle>) => void;
|
||||
handleFormCancel?: () => void;
|
||||
initialData?: Partial<ICycle>;
|
||||
}
|
||||
|
||||
export const CycleForm: FC<CycleFormProps> = (props) => {
|
||||
const { handleFormSubmit, handleFormCancel = () => {}, initialData = null } = props;
|
||||
// form handler
|
||||
export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status, data }) => {
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
} = useForm<ICycle>({
|
||||
defaultValues: initialData || defaultValues,
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const handleCreateUpdateCycle = async (formData: Partial<ICycle>) => {
|
||||
await handleFormSubmit(formData);
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...data,
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<form onSubmit={handleSubmit(handleCreateUpdateCycle)}>
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
||||
{status ? "Update" : "Create"} Cycle
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Input
|
||||
@ -47,6 +67,10 @@ export const CycleForm: FC<CycleFormProps> = (props) => {
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Name is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Name should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -86,42 +110,56 @@ export const CycleForm: FC<CycleFormProps> = (props) => {
|
||||
</div>
|
||||
<div className="flex gap-x-2">
|
||||
<div className="w-full">
|
||||
<Input
|
||||
id="start_date"
|
||||
label="Start Date"
|
||||
name="start_date"
|
||||
type="date"
|
||||
placeholder="Enter start date"
|
||||
error={errors.start_date}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Start date is required",
|
||||
}}
|
||||
/>
|
||||
<h6 className="text-gray-500">Start Date</h6>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
rules={{ required: "Start date is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomDatePicker
|
||||
renderAs="input"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={errors.start_date ? true : false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.start_date && (
|
||||
<h6 className="text-sm text-red-500">{errors.start_date.message}</h6>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Input
|
||||
id="end_date"
|
||||
label="End Date"
|
||||
name="end_date"
|
||||
type="date"
|
||||
placeholder="Enter end date"
|
||||
error={errors.end_date}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "End date is required",
|
||||
}}
|
||||
/>
|
||||
<h6 className="text-gray-500">End Date</h6>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="end_date"
|
||||
rules={{ required: "End date is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomDatePicker
|
||||
renderAs="input"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={errors.end_date ? true : false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.end_date && (
|
||||
<h6 className="text-sm text-red-500">{errors.end_date.message}</h6>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button theme="secondary" onClick={handleFormCancel}>
|
||||
<Button theme="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{initialData
|
||||
{status
|
||||
? isSubmitting
|
||||
? "Updating Cycle..."
|
||||
: "Update Cycle"
|
||||
|
@ -1,3 +1,7 @@
|
||||
export * from "./cycles-list-view";
|
||||
export * from "./delete-cycle-modal";
|
||||
export * from "./form";
|
||||
export * from "./modal";
|
||||
export * from "./select";
|
||||
export * from "./form";
|
||||
export * from "./sidebar";
|
||||
export * from "./single-cycle-card";
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
@ -15,67 +17,75 @@ import type { ICycle } from "types";
|
||||
// fetch keys
|
||||
import { CYCLE_LIST } from "constants/fetch-keys";
|
||||
|
||||
export interface CycleModalProps {
|
||||
type CycleModalProps = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
initialData?: ICycle;
|
||||
}
|
||||
data?: ICycle;
|
||||
};
|
||||
|
||||
export const CycleModal: React.FC<CycleModalProps> = ({
|
||||
export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
|
||||
isOpen,
|
||||
handleClose,
|
||||
initialData,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
data,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const createCycle = (payload: Partial<ICycle>) => {
|
||||
cycleService
|
||||
.createCycle(workspaceSlug as string, projectId, payload)
|
||||
const createCycle = async (payload: Partial<ICycle>) => {
|
||||
await cycleService
|
||||
.createCycle(workspaceSlug as string, projectId as string, payload)
|
||||
.then((res) => {
|
||||
mutate(CYCLE_LIST(projectId));
|
||||
mutate(CYCLE_LIST(projectId as string));
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Cycle created successfully.",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: "Error in creating cycle. Please try again!",
|
||||
title: "Error!",
|
||||
message: "Error in creating cycle. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const updateCycle = (cycleId: string, payload: Partial<ICycle>) => {
|
||||
cycleService
|
||||
.updateCycle(workspaceSlug, projectId, cycleId, payload)
|
||||
const updateCycle = async (cycleId: string, payload: Partial<ICycle>) => {
|
||||
await cycleService
|
||||
.updateCycle(workspaceSlug as string, projectId as string, cycleId, payload)
|
||||
.then((res) => {
|
||||
mutate(CYCLE_LIST(projectId));
|
||||
mutate(CYCLE_LIST(projectId as string));
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Cycle updated successfully.",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: "Error in updating cycle. Please try again!",
|
||||
title: "Error!",
|
||||
message: "Error in updating cycle. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormSubmit = (formValues: Partial<ICycle>) => {
|
||||
if (workspaceSlug && projectId) {
|
||||
const payload = {
|
||||
...formValues,
|
||||
};
|
||||
const handleFormSubmit = async (formData: Partial<ICycle>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
if (initialData) {
|
||||
updateCycle(initialData.id, payload);
|
||||
} else {
|
||||
createCycle(payload);
|
||||
}
|
||||
}
|
||||
const payload: Partial<ICycle> = {
|
||||
...formData,
|
||||
};
|
||||
|
||||
if (!data) await createCycle(payload);
|
||||
else await updateCycle(data.id, payload);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -104,10 +114,12 @@ export const CycleModal: React.FC<CycleModalProps> = ({
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||
{initialData ? "Update" : "Create"} Cycle
|
||||
</Dialog.Title>
|
||||
<CycleForm handleFormSubmit={handleFormSubmit} handleFormCancel={handleClose} />
|
||||
<CycleForm
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
handleClose={handleClose}
|
||||
status={data ? true : false}
|
||||
data={data}
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
|
@ -12,7 +12,7 @@ import { CyclesIcon } from "components/icons";
|
||||
// services
|
||||
import cycleServices from "services/cycles.service";
|
||||
// components
|
||||
import { CycleModal } from "components/cycles";
|
||||
import { CreateUpdateCycleModal } from "components/cycles";
|
||||
// fetch-keys
|
||||
import { CYCLE_LIST } from "constants/fetch-keys";
|
||||
|
||||
@ -54,12 +54,7 @@ export const CycleSelect: React.FC<IssueCycleSelectProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CycleModal
|
||||
isOpen={isCycleModalActive}
|
||||
handleClose={closeCycleModal}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
/>
|
||||
<CreateUpdateCycleModal isOpen={isCycleModalActive} handleClose={closeCycleModal} />
|
||||
<Listbox as="div" className="relative" value={value} onChange={onChange} multiple={multiple}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
|
@ -7,9 +7,6 @@ import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// react-circular-progressbar
|
||||
import { CircularProgressbar } from "react-circular-progressbar";
|
||||
import "react-circular-progressbar/dist/styles.css";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import DatePicker from "react-datepicker";
|
||||
// icons
|
||||
@ -22,7 +19,7 @@ import {
|
||||
UserIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { CustomSelect, Loader } from "components/ui";
|
||||
import { CustomSelect, Loader, ProgressBar } from "components/ui";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
@ -30,7 +27,7 @@ import cyclesService from "services/cycles.service";
|
||||
// components
|
||||
import { SidebarProgressStats } from "components/core";
|
||||
import ProgressChart from "components/core/sidebar/progress-chart";
|
||||
import ConfirmCycleDeletion from "components/project/cycles/confirm-cycle-deletion";
|
||||
import { DeleteCycleModal } from "components/cycles";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { groupBy } from "helpers/array.helper";
|
||||
@ -49,7 +46,7 @@ type Props = {
|
||||
cycleIssues: CycleIssueResponse[];
|
||||
};
|
||||
|
||||
const CycleDetailSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cycleIssues }) => {
|
||||
export const CycleDetailsSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cycleIssues }) => {
|
||||
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
@ -111,11 +108,7 @@ const CycleDetailSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cycleIssue
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmCycleDeletion
|
||||
isOpen={cycleDeleteModal}
|
||||
setIsOpen={setCycleDeleteModal}
|
||||
data={cycle}
|
||||
/>
|
||||
<DeleteCycleModal isOpen={cycleDeleteModal} setIsOpen={setCycleDeleteModal} data={cycle} />
|
||||
<div
|
||||
className={`fixed top-0 ${
|
||||
isOpen ? "right-0" : "-right-[24rem]"
|
||||
@ -152,61 +145,93 @@ const CycleDetailSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cycleIssue
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Popover className="flex justify-center items-center relative rounded-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center gap-2 rounded-md border bg-transparent h-full w-full p-2 px-4 text-xs font-medium text-gray-900 hover:bg-gray-100 hover:text-gray-900 focus:outline-none ${
|
||||
open ? "bg-gray-100" : ""
|
||||
}`}
|
||||
>
|
||||
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
{renderShortNumericDateFormat(`${cycle.start_date}`)
|
||||
? renderShortNumericDateFormat(`${cycle.start_date}`)
|
||||
: "N/A"}{" "}
|
||||
-{" "}
|
||||
{renderShortNumericDateFormat(`${cycle.end_date}`)
|
||||
? renderShortNumericDateFormat(`${cycle.end_date}`)
|
||||
: "N/A"}
|
||||
</span>
|
||||
</Popover.Button>
|
||||
<div className="flex justify-center items-center gap-2 rounded-md border bg-transparent h-full p-2 px-4 text-xs font-medium text-gray-900 hover:bg-gray-100 hover:text-gray-900 focus:outline-none">
|
||||
<Popover className="flex justify-center items-center relative rounded-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center ${open ? "bg-gray-100" : ""}`}
|
||||
>
|
||||
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0 mr-2" />
|
||||
<span>
|
||||
{renderShortNumericDateFormat(`${cycle.start_date}`)
|
||||
? renderShortNumericDateFormat(`${cycle.start_date}`)
|
||||
: "N/A"}
|
||||
</span>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 left-0 z-20 transform overflow-hidden">
|
||||
<DatePicker
|
||||
selected={startDateRange}
|
||||
onChange={(dates) => {
|
||||
const [start, end] = dates;
|
||||
submitChanges({
|
||||
start_date: renderDateFormat(start),
|
||||
end_date: renderDateFormat(end),
|
||||
});
|
||||
if (setStartDateRange) {
|
||||
setStartDateRange(start);
|
||||
}
|
||||
if (setEndDateRange) {
|
||||
setEndDateRange(end);
|
||||
}
|
||||
}}
|
||||
startDate={startDateRange}
|
||||
endDate={endDateRange}
|
||||
selectsRange
|
||||
inline
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 -left-10 z-20 transform overflow-hidden">
|
||||
<DatePicker
|
||||
selected={startDateRange}
|
||||
onChange={(date) => {
|
||||
submitChanges({
|
||||
start_date: renderDateFormat(date),
|
||||
});
|
||||
setStartDateRange(date);
|
||||
}}
|
||||
selectsStart
|
||||
startDate={startDateRange}
|
||||
endDate={endDateRange}
|
||||
inline
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
<Popover className="flex justify-center items-center relative rounded-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center ${open ? "bg-gray-100" : ""}`}
|
||||
>
|
||||
<span>
|
||||
-{" "}
|
||||
{renderShortNumericDateFormat(`${cycle.end_date}`)
|
||||
? renderShortNumericDateFormat(`${cycle.end_date}`)
|
||||
: "N/A"}
|
||||
</span>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 -right-20 z-20 transform overflow-hidden">
|
||||
<DatePicker
|
||||
selected={endDateRange}
|
||||
onChange={(date) => {
|
||||
submitChanges({
|
||||
end_date: renderDateFormat(date),
|
||||
});
|
||||
setEndDateRange(date);
|
||||
}}
|
||||
selectsEnd
|
||||
startDate={startDateRange}
|
||||
endDate={endDateRange}
|
||||
minDate={startDateRange}
|
||||
inline
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pb-3">
|
||||
<h4 className="text-sm font-medium">{cycle.name}</h4>
|
||||
@ -282,10 +307,9 @@ const CycleDetailSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cycleIssue
|
||||
<div className="flex items-center gap-2 sm:basis-1/2">
|
||||
<div className="grid flex-shrink-0 place-items-center">
|
||||
<span className="h-4 w-4">
|
||||
<CircularProgressbar
|
||||
<ProgressBar
|
||||
value={groupedIssues.completed.length}
|
||||
maxValue={cycleIssues?.length}
|
||||
strokeWidth={10}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@ -331,5 +355,3 @@ const CycleDetailSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cycleIssue
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CycleDetailSidebar;
|
@ -8,6 +8,8 @@ import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
// services
|
||||
import cyclesService from "services/cycles.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Button, CustomMenu } from "components/ui";
|
||||
// icons
|
||||
@ -17,6 +19,7 @@ import { CyclesIcon } from "components/icons";
|
||||
// helpers
|
||||
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
import { groupBy } from "helpers/array.helper";
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { CycleIssueResponse, ICycle } from "types";
|
||||
// fetch-keys
|
||||
@ -38,11 +41,12 @@ const stateGroupColours: {
|
||||
completed: "#096e8d",
|
||||
};
|
||||
|
||||
const SingleStat: React.FC<TSingleStatProps> = (props) => {
|
||||
export const SingleCycleCard: React.FC<TSingleStatProps> = (props) => {
|
||||
const { cycle, handleEditCycle, handleDeleteCycle } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { data: cycleIssues } = useSWR<CycleIssueResponse[]>(
|
||||
workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null,
|
||||
@ -63,6 +67,24 @@ const SingleStat: React.FC<TSingleStatProps> = (props) => {
|
||||
...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"),
|
||||
};
|
||||
|
||||
const handleCopyText = () => {
|
||||
const originURL =
|
||||
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Cycle link copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border bg-white p-3">
|
||||
@ -77,6 +99,7 @@ const SingleStat: React.FC<TSingleStatProps> = (props) => {
|
||||
</a>
|
||||
</Link>
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>Copy cycle link</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleEditCycle}>Edit cycle</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
|
||||
Delete cycle permanently
|
||||
@ -161,5 +184,3 @@ const SingleStat: React.FC<TSingleStatProps> = (props) => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleStat;
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import Image from "next/image";
|
||||
import { KeyedMutator } from "swr";
|
||||
import useSWR from "swr";
|
||||
|
||||
// icons
|
||||
import {
|
||||
@ -13,7 +13,7 @@ import {
|
||||
UserIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import issuesServices from "services/issues.service";
|
||||
import issuesService from "services/issues.service";
|
||||
// components
|
||||
import { CommentCard } from "components/issues/comment";
|
||||
// ui
|
||||
@ -24,7 +24,8 @@ import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "co
|
||||
import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper";
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssueActivity, IIssueComment } from "types";
|
||||
import { IIssueComment } from "types";
|
||||
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
|
||||
const activityDetails: {
|
||||
[key: string]: {
|
||||
@ -85,19 +86,27 @@ const activityDetails: {
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
issueActivities: IIssueActivity[];
|
||||
mutate: KeyedMutator<IIssueActivity[]>;
|
||||
};
|
||||
type Props = {};
|
||||
|
||||
export const IssueActivitySection: React.FC<Props> = ({ issueActivities, mutate }) => {
|
||||
export const IssueActivitySection: React.FC<Props> = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const onCommentUpdate = async (comment: IIssueComment) => {
|
||||
const { data: issueActivities, mutate: mutateIssueActivities } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? PROJECT_ISSUES_ACTIVITY(issueId as string) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () =>
|
||||
issuesService.getIssueActivities(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const handleCommentUpdate = async (comment: IIssueComment) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
await issuesServices
|
||||
await issuesService
|
||||
.patchIssueComment(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
@ -106,13 +115,13 @@ export const IssueActivitySection: React.FC<Props> = ({ issueActivities, mutate
|
||||
comment
|
||||
)
|
||||
.then((res) => {
|
||||
mutate();
|
||||
mutateIssueActivities();
|
||||
});
|
||||
};
|
||||
|
||||
const onCommentDelete = async (commentId: string) => {
|
||||
const handleCommentDelete = async (commentId: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
await issuesServices
|
||||
await issuesService
|
||||
.deleteIssueComment(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
@ -120,7 +129,7 @@ export const IssueActivitySection: React.FC<Props> = ({ issueActivities, mutate
|
||||
commentId
|
||||
)
|
||||
.then((response) => {
|
||||
mutate();
|
||||
mutateIssueActivities();
|
||||
console.log(response);
|
||||
});
|
||||
};
|
||||
@ -234,8 +243,8 @@ export const IssueActivitySection: React.FC<Props> = ({ issueActivities, mutate
|
||||
<CommentCard
|
||||
key={activity.id}
|
||||
comment={activity as any}
|
||||
onSubmit={onCommentUpdate}
|
||||
handleCommentDeletion={onCommentDelete}
|
||||
onSubmit={handleCommentUpdate}
|
||||
handleCommentDeletion={handleCommentDelete}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
@ -3,6 +3,8 @@ import React, { useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
// services
|
||||
@ -12,8 +14,9 @@ import { Loader } from "components/ui";
|
||||
// helpers
|
||||
import { debounce } from "helpers/common.helper";
|
||||
// types
|
||||
import type { IIssueActivity, IIssueComment } from "types";
|
||||
import type { KeyedMutator } from "swr";
|
||||
import type { IIssueComment } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
@ -29,9 +32,7 @@ const defaultValues: Partial<IIssueComment> = {
|
||||
comment_json: "",
|
||||
};
|
||||
|
||||
export const AddComment: React.FC<{
|
||||
mutate: KeyedMutator<IIssueActivity[]>;
|
||||
}> = ({ mutate }) => {
|
||||
export const AddComment: React.FC = () => {
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
@ -57,7 +58,7 @@ export const AddComment: React.FC<{
|
||||
await issuesServices
|
||||
.createIssueComment(workspaceSlug as string, projectId as string, issueId as string, formData)
|
||||
.then(() => {
|
||||
mutate();
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||
reset(defaultValues);
|
||||
})
|
||||
.catch((error) => {
|
||||
|
@ -15,7 +15,7 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { Button } from "components/ui";
|
||||
// types
|
||||
import type { CycleIssueResponse, IIssue, IssueResponse, ModuleIssueResponse } from "types";
|
||||
import type { CycleIssueResponse, IIssue, ModuleIssueResponse } from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_ISSUES, PROJECT_ISSUES_LIST, MODULE_ISSUES, USER_ISSUE } from "constants/fetch-keys";
|
||||
|
||||
@ -77,13 +77,9 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data })
|
||||
false
|
||||
);
|
||||
|
||||
mutate<IssueResponse>(
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId),
|
||||
(prevData) => ({
|
||||
...(prevData as IssueResponse),
|
||||
results: prevData?.results.filter((i) => i.id !== data.id) ?? [],
|
||||
count: (prevData?.count as number) - 1,
|
||||
}),
|
||||
(prevData) => (prevData ?? []).filter((i) => i.id !== data.id),
|
||||
false
|
||||
);
|
||||
|
||||
|
@ -7,7 +7,7 @@ import { useForm } from "react-hook-form";
|
||||
// lodash
|
||||
import debounce from "lodash.debounce";
|
||||
// components
|
||||
import { Loader, Input } from "components/ui";
|
||||
import { Loader, TextArea } from "components/ui";
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
@ -45,7 +45,6 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors },
|
||||
setError,
|
||||
} = useForm<IIssue>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
@ -76,8 +75,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
|
||||
handleFormSubmit({
|
||||
name: formData.name ?? "",
|
||||
description: formData.description,
|
||||
description_html: formData.description_html,
|
||||
description: formData.description ?? "",
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
});
|
||||
},
|
||||
[handleFormSubmit, setToastAlert]
|
||||
@ -106,19 +105,20 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
<TextArea
|
||||
id="name"
|
||||
placeholder="Enter issue name"
|
||||
name="name"
|
||||
value={watch("name")}
|
||||
autoComplete="off"
|
||||
onChange={(e) => {
|
||||
setValue("name", e.target.value);
|
||||
debounceHandler();
|
||||
}}
|
||||
mode="transparent"
|
||||
className="text-xl font-medium"
|
||||
disabled={isNotAllowed}
|
||||
required={true}
|
||||
className="block px-3 py-2 text-xl
|
||||
w-full overflow-hidden resize-none min-h-10
|
||||
rounded border-none bg-transparent ring-0 focus:ring-1 focus:ring-theme outline-none "
|
||||
role="textbox "
|
||||
/>
|
||||
<span>{errors.name ? errors.name.message : null}</span>
|
||||
<RemirrorRichTextEditor
|
||||
|
@ -16,8 +16,9 @@ import {
|
||||
IssueStateSelect,
|
||||
} from "components/issues/select";
|
||||
import { CycleSelect as IssueCycleSelect } from "components/cycles/select";
|
||||
import { CreateUpdateStateModal } from "components/states";
|
||||
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
|
||||
import { CreateStateModal } from "components/states";
|
||||
import { CreateUpdateCycleModal } from "components/cycles";
|
||||
import { CreateLabelModal } from "components/labels";
|
||||
// ui
|
||||
import { Button, CustomDatePicker, CustomMenu, Input, Loader } from "components/ui";
|
||||
// icons
|
||||
@ -48,7 +49,7 @@ const defaultValues: Partial<IIssue> = {
|
||||
};
|
||||
|
||||
export interface IssueFormProps {
|
||||
handleFormSubmit: (values: Partial<IIssue>) => void;
|
||||
handleFormSubmit: (values: Partial<IIssue>) => Promise<void>;
|
||||
initialData?: Partial<IIssue>;
|
||||
issues: IIssue[];
|
||||
projectId: string;
|
||||
@ -74,6 +75,7 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
const [mostSimilarIssue, setMostSimilarIssue] = useState<IIssue | undefined>();
|
||||
const [cycleModal, setCycleModal] = useState(false);
|
||||
const [stateModal, setStateModal] = useState(false);
|
||||
const [labelModal, setLabelModal] = useState(false);
|
||||
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
@ -105,30 +107,32 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
reset({
|
||||
...defaultValues,
|
||||
project: projectId,
|
||||
description: "",
|
||||
description_html: "<p></p>",
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...watch(),
|
||||
project: projectId,
|
||||
...initialData,
|
||||
project: projectId,
|
||||
});
|
||||
}, [initialData, reset, watch, projectId]);
|
||||
}, [initialData, reset, projectId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{projectId && (
|
||||
<>
|
||||
<CreateUpdateStateModal
|
||||
<CreateStateModal
|
||||
isOpen={stateModal}
|
||||
handleClose={() => setStateModal(false)}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<CreateUpdateCycleModal
|
||||
isOpen={cycleModal}
|
||||
setIsOpen={setCycleModal}
|
||||
<CreateUpdateCycleModal isOpen={cycleModal} handleClose={() => setCycleModal(false)} />
|
||||
<CreateLabelModal
|
||||
isOpen={labelModal}
|
||||
handleClose={() => setLabelModal(false)}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</>
|
||||
@ -231,13 +235,11 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
render={({ field: { value } }) => (
|
||||
<RemirrorRichTextEditor
|
||||
value={value}
|
||||
onBlur={(jsonValue, htmlValue) => {
|
||||
setValue("description", jsonValue);
|
||||
setValue("description_html", htmlValue);
|
||||
}}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Enter Your Text..."
|
||||
/>
|
||||
)}
|
||||
@ -272,16 +274,14 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignees_list"
|
||||
name="labels"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueAssigneeSelect projectId={projectId} value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="labels_list"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueLabelSelect value={value} onChange={onChange} projectId={projectId} />
|
||||
<IssueLabelSelect
|
||||
setIsOpen={setLabelModal}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
@ -297,6 +297,13 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignees"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueAssigneeSelect projectId={projectId} value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
<IssueParentSelect
|
||||
control={control}
|
||||
isOpen={parentIssueListModalOpen}
|
||||
|
@ -9,4 +9,3 @@ export * from "./my-issues-list-item";
|
||||
export * from "./parent-issues-list-modal";
|
||||
export * from "./sidebar";
|
||||
export * from "./sub-issues-list";
|
||||
export * from "./sub-issues-list-modal";
|
||||
|
@ -4,8 +4,6 @@ import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
@ -18,7 +16,7 @@ import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { IssueForm } from "components/issues";
|
||||
// types
|
||||
import type { IIssue, IssueResponse } from "types";
|
||||
import type { IIssue } from "types";
|
||||
// fetch keys
|
||||
import {
|
||||
PROJECT_ISSUES_DETAILS,
|
||||
@ -72,11 +70,6 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
workspaceSlug ? () => projectService.getProjects(workspaceSlug as string) : null
|
||||
);
|
||||
|
||||
const { setError } = useForm<IIssue>({
|
||||
mode: "all",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (projects && projects.length > 0)
|
||||
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
|
||||
@ -98,15 +91,13 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
false
|
||||
);
|
||||
} else
|
||||
mutate<IssueResponse>(
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? ""),
|
||||
(prevData) => ({
|
||||
...(prevData as IssueResponse),
|
||||
results: (prevData?.results ?? []).map((issue) => {
|
||||
if (issue.id === res.id) return { ...issue, sprints: cycleId };
|
||||
return issue;
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((i) => {
|
||||
if (i.id === res.id) return { ...i, sprints: cycleId };
|
||||
return i;
|
||||
}),
|
||||
}),
|
||||
false
|
||||
);
|
||||
})
|
||||
@ -133,7 +124,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
await issuesService
|
||||
.createIssues(workspaceSlug as string, activeProject ?? "", payload)
|
||||
.then((res) => {
|
||||
mutate<IssueResponse>(PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? ""));
|
||||
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? ""));
|
||||
|
||||
if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle);
|
||||
if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module);
|
||||
@ -141,30 +132,20 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
if (!createMore) handleClose();
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Issue created successfully",
|
||||
title: "Success!",
|
||||
message: "Issue created successfully.",
|
||||
});
|
||||
|
||||
if (payload.assignees_list?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE);
|
||||
|
||||
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.detail) {
|
||||
setToastAlert({
|
||||
title: "Join the project.",
|
||||
type: "error",
|
||||
message: "Click select to join from projects page to start making changes",
|
||||
});
|
||||
}
|
||||
Object.keys(err).map((key) => {
|
||||
const message = err[key];
|
||||
if (!message) return;
|
||||
|
||||
setError(key as keyof IIssue, {
|
||||
message: Array.isArray(message) ? message.join(", ") : message,
|
||||
});
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be created. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -176,15 +157,13 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
if (isUpdatingSingleIssue) {
|
||||
mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
|
||||
} else {
|
||||
mutate<IssueResponse>(
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? ""),
|
||||
(prevData) => ({
|
||||
...(prevData as IssueResponse),
|
||||
results: (prevData?.results ?? []).map((issue) => {
|
||||
if (issue.id === res.id) return { ...issue, ...res };
|
||||
return issue;
|
||||
}),
|
||||
})
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((i) => {
|
||||
if (i.id === res.id) return { ...i, ...res };
|
||||
return i;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@ -194,14 +173,16 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
if (!createMore) handleClose();
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Issue updated successfully",
|
||||
title: "Success!",
|
||||
message: "Issue updated successfully.",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
Object.keys(err).map((key) => {
|
||||
setError(key as keyof IIssue, { message: err[key].join(", ") });
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be updated. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -211,8 +192,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
|
||||
const payload: Partial<IIssue> = {
|
||||
...formData,
|
||||
description: formData.description ? formData.description : "",
|
||||
description_html: formData.description_html ? formData.description_html : "<p></p>",
|
||||
assignees_list: formData.assignees,
|
||||
labels_list: formData.labels,
|
||||
description: formData.description ?? "",
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
};
|
||||
|
||||
if (!data) await createIssue(payload);
|
||||
@ -221,7 +204,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Dialog as="div" className="relative z-20" onClose={() => {}}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@ -247,7 +230,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-white p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
|
||||
<IssueForm
|
||||
issues={issues?.results ?? []}
|
||||
issues={issues ?? []}
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
initialData={prePopulateData}
|
||||
createMore={createMore}
|
||||
|
@ -82,7 +82,9 @@ export const MyIssuesListItem: React.FC<Props> = ({
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
)}
|
||||
<span>{issue.name}</span>
|
||||
<span className="w-[275px] md:w-[450px] lg:w-[600px] text-ellipsis overflow-hidden whitespace-nowrap">
|
||||
{issue.name}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
@ -1,18 +1,15 @@
|
||||
import { useState, FC, Fragment } from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Transition, Combobox } from "@headlessui/react";
|
||||
// icons
|
||||
import { UserIcon } from "@heroicons/react/24/outline";
|
||||
// service
|
||||
// services
|
||||
import projectServices from "services/project.service";
|
||||
// types
|
||||
import type { IProjectMember } from "types";
|
||||
// ui
|
||||
import { AssigneesList, Avatar } from "components/ui";
|
||||
// fetch keys
|
||||
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
@ -22,35 +19,6 @@ export type IssueAssigneeSelectProps = {
|
||||
onChange: (value: string[]) => void;
|
||||
};
|
||||
|
||||
type AssigneeAvatarProps = {
|
||||
user: IProjectMember | undefined;
|
||||
};
|
||||
|
||||
export const AssigneeAvatar: FC<AssigneeAvatarProps> = ({ user }) => {
|
||||
if (!user) return <></>;
|
||||
|
||||
if (user.member.avatar && user.member.avatar !== "") {
|
||||
return (
|
||||
<div className="relative h-4 w-4">
|
||||
<Image
|
||||
src={user.member.avatar}
|
||||
alt="avatar"
|
||||
className="rounded-full"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 capitalize text-white">
|
||||
{user.member.first_name && user.member.first_name !== ""
|
||||
? user.member.first_name.charAt(0)
|
||||
: user.member.email.charAt(0)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const IssueAssigneeSelect: FC<IssueAssigneeSelectProps> = ({
|
||||
projectId,
|
||||
value = [],
|
||||
@ -93,22 +61,10 @@ export const IssueAssigneeSelect: FC<IssueAssigneeSelectProps> = ({
|
||||
>
|
||||
{({ open }: any) => (
|
||||
<>
|
||||
<Combobox.Label className="sr-only">Assignees</Combobox.Label>
|
||||
<Combobox.Button
|
||||
className={`flex cursor-pointer items-center gap-1 rounded-md border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500`}
|
||||
>
|
||||
<UserIcon className="h-3 w-3 text-gray-500" />
|
||||
<span
|
||||
className={`hidden truncate sm:block ${
|
||||
value === null || value === undefined ? "" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{Array.isArray(value)
|
||||
? value
|
||||
.map((v) => options?.find((option) => option.value === v)?.display)
|
||||
.join(", ") || "Assignees"
|
||||
: options?.find((option) => option.value === value)?.display || "Assignees"}
|
||||
</span>
|
||||
<Combobox.Button className="flex items-center cursor-pointer gap-1 rounded-md">
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{value && Array.isArray(value) ? <AssigneesList userIds={value} length={10} /> : null}
|
||||
</div>
|
||||
</Combobox.Button>
|
||||
|
||||
<Transition
|
||||
@ -136,14 +92,14 @@ export const IssueAssigneeSelect: FC<IssueAssigneeSelectProps> = ({
|
||||
className={({ active, selected }) =>
|
||||
`${active ? "bg-indigo-50" : ""} ${
|
||||
selected ? "bg-indigo-50 font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate px-2 py-1 text-gray-900`
|
||||
}
|
||||
value={option.value}
|
||||
>
|
||||
{people && (
|
||||
<>
|
||||
<AssigneeAvatar
|
||||
user={people?.find((p) => p.member.id === option.value)}
|
||||
<Avatar
|
||||
user={people?.find((p) => p.member.id === option.value)?.member}
|
||||
/>
|
||||
{option.display}
|
||||
</>
|
||||
|
@ -1,15 +1,13 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Combobox, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { RectangleGroupIcon, TagIcon } from "@heroicons/react/24/outline";
|
||||
import { PlusIcon, RectangleGroupIcon, TagIcon } from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import issuesServices from "services/issues.service";
|
||||
// types
|
||||
@ -18,55 +16,26 @@ import type { IIssueLabels } from "types";
|
||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IIssueLabels> = {
|
||||
name: "",
|
||||
};
|
||||
|
||||
export const IssueLabelSelect: React.FC<Props> = ({ value, onChange, projectId }) => {
|
||||
export const IssueLabelSelect: React.FC<Props> = ({ setIsOpen, value, onChange, projectId }) => {
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data: issueLabels, mutate: issueLabelsMutate } = useSWR<IIssueLabels[]>(
|
||||
const { data: issueLabels } = useSWR<IIssueLabels[]>(
|
||||
projectId ? PROJECT_ISSUE_LABELS(projectId) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId)
|
||||
: null
|
||||
);
|
||||
|
||||
const onSubmit = async (data: IIssueLabels) => {
|
||||
if (!projectId || !workspaceSlug || isSubmitting) return;
|
||||
await issuesServices
|
||||
.createIssueLabel(workspaceSlug as string, projectId as string, data)
|
||||
.then((response) => {
|
||||
issueLabelsMutate((prevData) => [...(prevData ?? []), response], false);
|
||||
setIsOpen(false);
|
||||
reset(defaultValues);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
formState: { isSubmitting },
|
||||
setFocus,
|
||||
reset,
|
||||
} = useForm<IIssueLabels>({ defaultValues });
|
||||
|
||||
useEffect(() => {
|
||||
isOpen && setFocus("name");
|
||||
}, [isOpen, setFocus]);
|
||||
|
||||
const filteredOptions =
|
||||
query === ""
|
||||
? issueLabels
|
||||
@ -133,7 +102,8 @@ export const IssueLabelSelect: React.FC<Props> = ({ value, onChange, projectId }
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color ?? "green",
|
||||
backgroundColor:
|
||||
label.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
@ -159,7 +129,7 @@ export const IssueLabelSelect: React.FC<Props> = ({ value, onChange, projectId }
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child?.color ?? "green",
|
||||
backgroundColor: child?.color ?? "black",
|
||||
}}
|
||||
/>
|
||||
{child.name}
|
||||
@ -175,48 +145,14 @@ export const IssueLabelSelect: React.FC<Props> = ({ value, onChange, projectId }
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 px-2">Loading...</p>
|
||||
)}
|
||||
{/* <div className="cursor-default select-none p-2 hover:bg-indigo-50 hover:text-gray-900">
|
||||
{isOpen ? (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
className="w-full"
|
||||
autoComplete="off"
|
||||
register={register}
|
||||
validations={{
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-green-600"
|
||||
disabled={isSubmitting}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-red-600"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 text-gray-400" aria-hidden="true" />
|
||||
<span className="text-xs whitespace-nowrap">Create label</span>
|
||||
</button>
|
||||
)}
|
||||
</div> */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex select-none w-full items-center gap-2 p-2 text-gray-400 outline-none hover:bg-indigo-50 hover:text-gray-900"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-3 w-3 text-gray-400" aria-hidden="true" />
|
||||
<span className="text-xs whitespace-nowrap">Create label</span>
|
||||
</button>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
|
@ -66,17 +66,11 @@ export const SidebarAssigneeSelect: React.FC<Props> = ({ control, submitChanges,
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center gap-1 text-xs`}
|
||||
>
|
||||
<span
|
||||
className={`hidden truncate text-left sm:block ${
|
||||
value ? "" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{value && Array.isArray(value) ? (
|
||||
<AssigneesList userIds={value} length={10} />
|
||||
) : null}
|
||||
</div>
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{value && Array.isArray(value) ? (
|
||||
<AssigneesList userIds={value} length={10} />
|
||||
) : null}
|
||||
</div>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
@ -97,8 +91,8 @@ export const SidebarAssigneeSelect: React.FC<Props> = ({ control, submitChanges,
|
||||
<Listbox.Option
|
||||
key={option.member.id}
|
||||
className={({ active, selected }) =>
|
||||
`${
|
||||
active || selected ? "bg-indigo-50" : ""
|
||||
`${active || selected ? "bg-indigo-50" : ""} ${
|
||||
selected ? "font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
}
|
||||
value={option.member.id}
|
||||
|
@ -127,14 +127,14 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
|
||||
>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${
|
||||
issues?.results.find((i) => i.id === issue)?.id
|
||||
issues?.find((i) => i.id === issue)?.id
|
||||
}`}
|
||||
>
|
||||
<a className="flex items-center gap-1">
|
||||
<BlockedIcon height={10} width={10} />
|
||||
{`${
|
||||
issues?.results.find((i) => i.id === issue)?.project_detail?.identifier
|
||||
}-${issues?.results.find((i) => i.id === issue)?.sequence_id}`}
|
||||
{`${issues?.find((i) => i.id === issue)?.project_detail?.identifier}-${
|
||||
issues?.find((i) => i.id === issue)?.sequence_id
|
||||
}`}
|
||||
</a>
|
||||
</Link>
|
||||
<span className="opacity-0 duration-300 group-hover:opacity-100">
|
||||
@ -243,8 +243,8 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{
|
||||
issues?.results.find((i) => i.id === issue.id)
|
||||
?.project_detail?.identifier
|
||||
issues?.find((i) => i.id === issue.id)?.project_detail
|
||||
?.identifier
|
||||
}
|
||||
-{issue.sequence_id}
|
||||
</span>
|
||||
|
@ -119,14 +119,14 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
|
||||
>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${
|
||||
issues?.results.find((i) => i.id === issue)?.id
|
||||
issues?.find((i) => i.id === issue)?.id
|
||||
}`}
|
||||
>
|
||||
<a className="flex items-center gap-1">
|
||||
<BlockerIcon height={10} width={10} />
|
||||
{`${
|
||||
issues?.results.find((i) => i.id === issue)?.project_detail?.identifier
|
||||
}-${issues?.results.find((i) => i.id === issue)?.sequence_id}`}
|
||||
{`${issues?.find((i) => i.id === issue)?.project_detail?.identifier}-${
|
||||
issues?.find((i) => i.id === issue)?.sequence_id
|
||||
}`}
|
||||
</a>
|
||||
</Link>
|
||||
<span
|
||||
@ -244,8 +244,8 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{
|
||||
issues?.results.find((i) => i.id === issue.id)
|
||||
?.project_detail?.identifier
|
||||
issues?.find((i) => i.id === issue.id)?.project_detail
|
||||
?.identifier
|
||||
}
|
||||
-{issue.sequence_id}
|
||||
</span>
|
||||
|
@ -82,14 +82,14 @@ export const SidebarCycleSelect: React.FC<Props> = ({
|
||||
{cycles ? (
|
||||
cycles.length > 0 ? (
|
||||
<>
|
||||
<CustomSelect.Option value={null} className="capitalize">
|
||||
None
|
||||
</CustomSelect.Option>
|
||||
{cycles.map((option) => (
|
||||
<CustomSelect.Option key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
<CustomSelect.Option value={null} className="capitalize">
|
||||
None
|
||||
</CustomSelect.Option>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">No cycles found</div>
|
||||
|
@ -81,14 +81,14 @@ export const SidebarModuleSelect: React.FC<Props> = ({
|
||||
{modules ? (
|
||||
modules.length > 0 ? (
|
||||
<>
|
||||
<CustomSelect.Option value={null} className="capitalize">
|
||||
None
|
||||
</CustomSelect.Option>
|
||||
{modules.map((option) => (
|
||||
<CustomSelect.Option key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
<CustomSelect.Option value={null} className="capitalize">
|
||||
None
|
||||
</CustomSelect.Option>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">No modules found</div>
|
||||
|
@ -84,9 +84,9 @@ export const SidebarParentSelect: React.FC<Props> = ({
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{watch("parent") && watch("parent") !== ""
|
||||
? `${
|
||||
issues?.results.find((i) => i.id === watch("parent"))?.project_detail?.identifier
|
||||
}-${issues?.results.find((i) => i.id === watch("parent"))?.sequence_id}`
|
||||
? `${issues?.find((i) => i.id === watch("parent"))?.project_detail?.identifier}-${
|
||||
issues?.find((i) => i.id === watch("parent"))?.sequence_id
|
||||
}`
|
||||
: "Select issue"}
|
||||
</button>
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@ -13,9 +13,10 @@ import { Popover, Listbox, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
import issuesServices from "services/issues.service";
|
||||
import issuesService from "services/issues.service";
|
||||
import modulesService from "services/modules.service";
|
||||
// components
|
||||
import { LinkModal, LinksList } from "components/core";
|
||||
import {
|
||||
DeleteIssueModal,
|
||||
SidebarAssigneeSelect,
|
||||
@ -43,7 +44,7 @@ import {
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import type { ICycle, IIssue, IIssueLabels, IModule, UserAuth } from "types";
|
||||
import type { ICycle, IIssue, IIssueLabels, IIssueLink, IModule, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
@ -69,6 +70,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [createLabelForm, setCreateLabelForm] = useState(false);
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
const [linkModal, setLinkModal] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
@ -80,14 +82,14 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
|
||||
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: issueLabels, mutate: issueLabelMutate } = useSWR<IIssueLabels[]>(
|
||||
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId as string)
|
||||
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
@ -104,7 +106,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
|
||||
const handleNewLabel = (formData: any) => {
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
issuesServices
|
||||
issuesService
|
||||
.createIssueLabel(workspaceSlug as string, projectId as string, formData)
|
||||
.then((res) => {
|
||||
reset(defaultValues);
|
||||
@ -118,7 +120,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
(cycleDetail: ICycle) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetail) return;
|
||||
|
||||
issuesServices
|
||||
issuesService
|
||||
.addIssueToCycle(workspaceSlug as string, projectId as string, cycleDetail.id, {
|
||||
issues: [issueDetail.id],
|
||||
})
|
||||
@ -144,10 +146,63 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
[workspaceSlug, projectId, issueId, issueDetail]
|
||||
);
|
||||
|
||||
const handleCreateLink = async (formData: IIssueLink) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetail) return;
|
||||
|
||||
const previousLinks = issueDetail?.issue_link.map((l) => ({ title: l.title, url: l.url }));
|
||||
|
||||
const payload: Partial<IIssue> = {
|
||||
links_list: [...(previousLinks ?? []), formData],
|
||||
};
|
||||
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueDetail.id, payload)
|
||||
.then((res) => {
|
||||
mutate(ISSUE_DETAILS(issueDetail.id as string));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteLink = async (linkId: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetail) return;
|
||||
|
||||
const updatedLinks = issueDetail.issue_link.filter((l) => l.id !== linkId);
|
||||
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueDetail.id as string),
|
||||
(prevData) => ({ ...(prevData as IIssue), issue_link: updatedLinks }),
|
||||
false
|
||||
);
|
||||
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueDetail.id, {
|
||||
links_list: updatedLinks,
|
||||
})
|
||||
.then((res) => {
|
||||
mutate(ISSUE_DETAILS(issueDetail.id as string));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!createLabelForm) return;
|
||||
|
||||
reset();
|
||||
}, [createLabelForm, reset]);
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkModal
|
||||
isOpen={linkModal}
|
||||
handleClose={() => setLinkModal(false)}
|
||||
onFormSubmit={handleCreateLink}
|
||||
/>
|
||||
<DeleteIssueModal
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
isOpen={deleteIssueModal}
|
||||
@ -216,7 +271,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
control={control}
|
||||
submitChanges={submitChanges}
|
||||
issuesList={
|
||||
issues?.results.filter(
|
||||
issues?.filter(
|
||||
(i) =>
|
||||
i.id !== issueDetail?.id &&
|
||||
i.id !== issueDetail?.parent &&
|
||||
@ -244,13 +299,13 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
/>
|
||||
<SidebarBlockerSelect
|
||||
submitChanges={submitChanges}
|
||||
issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
watch={watchIssue}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
<SidebarBlockedSelect
|
||||
submitChanges={submitChanges}
|
||||
issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
watch={watchIssue}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
@ -291,7 +346,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 pt-3">
|
||||
<div className="space-y-3 py-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex basis-1/2 items-center gap-x-2 text-sm">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
@ -318,7 +373,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: label?.color ?? "green" }}
|
||||
style={{ backgroundColor: label?.color ?? "black" }}
|
||||
/>
|
||||
{label.name}
|
||||
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
|
||||
@ -380,7 +435,10 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color ?? "green",
|
||||
backgroundColor:
|
||||
label.color && label.color !== ""
|
||||
? label.color
|
||||
: "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
@ -407,7 +465,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child?.color ?? "green",
|
||||
backgroundColor: child?.color ?? "black",
|
||||
}}
|
||||
/>
|
||||
{child.name}
|
||||
@ -431,24 +489,25 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
</Listbox>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-gray-100"
|
||||
} items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs`}
|
||||
onClick={() => setCreateLabelForm((prevData) => !prevData)}
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{createLabelForm ? (
|
||||
<>
|
||||
<XMarkIcon className="h-3 w-3" /> Cancel
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="h-3 w-3" /> New
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-gray-100"
|
||||
} items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs`}
|
||||
onClick={() => setCreateLabelForm((prevData) => !prevData)}
|
||||
>
|
||||
{createLabelForm ? (
|
||||
<>
|
||||
<XMarkIcon className="h-3 w-3" /> Cancel
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="h-3 w-3" /> New
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -465,7 +524,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
<span
|
||||
className="h-5 w-5 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("color") ?? "green",
|
||||
backgroundColor: watch("color") ?? "black",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -517,6 +576,29 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-1 text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4>Links</h4>
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-100"
|
||||
onClick={() => setLinkModal(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{issueDetail?.issue_link && issueDetail.issue_link.length > 0 ? (
|
||||
<LinksList
|
||||
links={issueDetail.issue_link}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -1,205 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { RectangleStackIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import issuesServices from "services/issues.service";
|
||||
// helpers
|
||||
import { orderArrayBy } from "helpers/array.helper";
|
||||
// types
|
||||
import { IIssue, IssueResponse } from "types";
|
||||
// constants
|
||||
import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
parent: IIssue | undefined;
|
||||
};
|
||||
|
||||
export const SubIssuesListModal: React.FC<Props> = ({ isOpen, handleClose, parent }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const filteredIssues: IIssue[] =
|
||||
query === ""
|
||||
? issues?.results ?? []
|
||||
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
|
||||
[];
|
||||
|
||||
const handleModalClose = () => {
|
||||
handleClose();
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const addAsSubIssue = (issue: IIssue) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutate<IIssue[]>(
|
||||
SUB_ISSUES(parent?.id ?? ""),
|
||||
(prevData) => {
|
||||
let newSubIssues = [...(prevData as IIssue[])];
|
||||
newSubIssues.push(issue);
|
||||
|
||||
newSubIssues = orderArrayBy(newSubIssues, "created_at", "descending");
|
||||
|
||||
return newSubIssues;
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
issuesServices
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issue.id, { parent: parent?.id })
|
||||
.then((res) => {
|
||||
mutate(SUB_ISSUES(parent?.id ?? ""));
|
||||
mutate<IssueResponse>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) => ({
|
||||
...(prevData as IssueResponse),
|
||||
results: (prevData?.results ?? []).map((p) => {
|
||||
if (p.id === res.id)
|
||||
return {
|
||||
...p,
|
||||
...res,
|
||||
};
|
||||
|
||||
return p;
|
||||
}),
|
||||
}),
|
||||
false
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleModalClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
|
||||
<Combobox>
|
||||
<div className="relative m-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Combobox.Options
|
||||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
|
||||
>
|
||||
{filteredIssues.length > 0 && (
|
||||
<>
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
Issues
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
{filteredIssues.map((issue) => {
|
||||
if (
|
||||
(issue.parent === "" || issue.parent === null) && // issue does not have any other parent
|
||||
issue.id !== parent?.id && // issue is not itself
|
||||
issue.id !== parent?.parent // issue is not it's parent
|
||||
)
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
value={{
|
||||
name: issue.name,
|
||||
}}
|
||||
className={({ active }) =>
|
||||
`flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 ${
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
}`
|
||||
}
|
||||
onClick={() => {
|
||||
addAsSubIssue(issue);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="block flex-shrink-0 h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
{issue.name}
|
||||
</Combobox.Option>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
|
||||
{query !== "" && filteredIssues.length === 0 && (
|
||||
<div className="py-14 px-6 text-center sm:px-14">
|
||||
<RectangleStackIcon
|
||||
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="mt-4 text-sm text-gray-900">
|
||||
We couldn{"'"}t find any issue with that term. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Combobox>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
@ -1,49 +1,146 @@
|
||||
import { FC, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import { ChevronRightIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "components/core";
|
||||
import { CreateUpdateIssueModal } from "components/issues";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
import { CreateUpdateIssueModal, SubIssuesListModal } from "components/issues";
|
||||
// icons
|
||||
import { ChevronRightIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { orderArrayBy } from "helpers/array.helper";
|
||||
// types
|
||||
import { IIssue, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
export interface SubIssueListProps {
|
||||
issues: IIssue[];
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
type Props = {
|
||||
parentIssue: IIssue;
|
||||
handleSubIssueRemove: (subIssueId: string) => void;
|
||||
userAuth: UserAuth;
|
||||
}
|
||||
};
|
||||
|
||||
export const SubIssuesList: FC<SubIssueListProps> = ({
|
||||
issues = [],
|
||||
handleSubIssueRemove,
|
||||
parentIssue,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
userAuth,
|
||||
}) => {
|
||||
export const SubIssuesList: FC<Props> = ({ parentIssue, userAuth }) => {
|
||||
// states
|
||||
const [isIssueModalActive, setIssueModalActive] = useState(false);
|
||||
const [createIssueModal, setCreateIssueModal] = useState(false);
|
||||
const [subIssuesListModal, setSubIssuesListModal] = useState(false);
|
||||
const [preloadedData, setPreloadedData] = useState<Partial<IIssue> | null>(null);
|
||||
|
||||
const openIssueModal = () => {
|
||||
setIssueModalActive(true);
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { data: subIssues } = useSWR<IIssue[] | undefined>(
|
||||
workspaceSlug && projectId && issueId ? SUB_ISSUES(issueId as string) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () =>
|
||||
issuesService.subIssues(workspaceSlug as string, projectId as string, issueId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const addAsSubIssue = async (data: { issues: string[] }) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
await issuesService
|
||||
.addSubIssues(workspaceSlug as string, projectId as string, parentIssue?.id ?? "", {
|
||||
sub_issue_ids: data.issues,
|
||||
})
|
||||
.then((res) => {
|
||||
mutate<IIssue[]>(
|
||||
SUB_ISSUES(parentIssue?.id ?? ""),
|
||||
(prevData) => {
|
||||
let newSubIssues = [...(prevData as IIssue[])];
|
||||
|
||||
data.issues.forEach((issueId: string) => {
|
||||
const issue = issues?.find((i) => i.id === issueId);
|
||||
|
||||
if (issue) newSubIssues.push(issue);
|
||||
});
|
||||
|
||||
newSubIssues = orderArrayBy(newSubIssues, "created_at", "descending");
|
||||
|
||||
return newSubIssues;
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (data.issues.includes(p.id))
|
||||
return {
|
||||
...p,
|
||||
parent: parentIssue.id,
|
||||
};
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
const closeIssueModal = () => {
|
||||
setIssueModalActive(false);
|
||||
const handleSubIssueRemove = (issueId: string) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutate<IIssue[]>(
|
||||
SUB_ISSUES(parentIssue.id ?? ""),
|
||||
(prevData) => prevData?.filter((i) => i.id !== issueId),
|
||||
false
|
||||
);
|
||||
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId, { parent: null })
|
||||
.then((res) => {
|
||||
mutate(SUB_ISSUES(parentIssue.id ?? ""));
|
||||
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === res.id)
|
||||
return {
|
||||
...p,
|
||||
...res,
|
||||
};
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const openSubIssueModal = () => {
|
||||
setSubIssuesListModal(true);
|
||||
};
|
||||
|
||||
const closeSubIssueModal = () => {
|
||||
setSubIssuesListModal(false);
|
||||
const handleCreateIssueModal = () => {
|
||||
setCreateIssueModal(true);
|
||||
setPreloadedData({
|
||||
parent: parentIssue.id,
|
||||
});
|
||||
};
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
@ -51,95 +148,114 @@ export const SubIssuesList: FC<SubIssueListProps> = ({
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isIssueModalActive}
|
||||
isOpen={createIssueModal}
|
||||
prePopulateData={{ ...preloadedData }}
|
||||
handleClose={closeIssueModal}
|
||||
handleClose={() => setCreateIssueModal(false)}
|
||||
/>
|
||||
<SubIssuesListModal
|
||||
<ExistingIssuesListModal
|
||||
isOpen={subIssuesListModal}
|
||||
handleClose={() => setSubIssuesListModal(false)}
|
||||
parent={parentIssue}
|
||||
issues={
|
||||
issues?.filter(
|
||||
(i) =>
|
||||
(i.parent === "" || i.parent === null) &&
|
||||
i.id !== parentIssue?.id &&
|
||||
i.id !== parentIssue?.parent
|
||||
) ?? []
|
||||
}
|
||||
handleOnSubmit={addAsSubIssue}
|
||||
/>
|
||||
<Disclosure defaultOpen={true}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<Disclosure.Button className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100">
|
||||
<ChevronRightIcon className={`h-3 w-3 ${open ? "rotate-90" : ""}`} />
|
||||
Sub-issues <span className="ml-1 text-gray-600">{issues.length}</span>
|
||||
</Disclosure.Button>
|
||||
{open && !isNotAllowed ? (
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
openIssueModal();
|
||||
setPreloadedData({
|
||||
parent: parentIssue.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Create new
|
||||
</button>
|
||||
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setSubIssuesListModal(true);
|
||||
}}
|
||||
{subIssues && subIssues.length > 0 ? (
|
||||
<Disclosure defaultOpen={true}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<Disclosure.Button className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100">
|
||||
<ChevronRightIcon className={`h-3 w-3 ${open ? "rotate-90" : ""}`} />
|
||||
Sub-issues <span className="ml-1 text-gray-600">{subIssues.length}</span>
|
||||
</Disclosure.Button>
|
||||
{open && !isNotAllowed ? (
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100"
|
||||
onClick={handleCreateIssueModal}
|
||||
>
|
||||
Add an existing issue
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
) : null}
|
||||
</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="mt-3 flex flex-col gap-y-1">
|
||||
{issues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="group flex items-center justify-between gap-2 rounded p-2 hover:bg-gray-100"
|
||||
>
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}>
|
||||
<a className="flex items-center gap-2 rounded text-xs">
|
||||
<span
|
||||
className="block flex-shrink-0 h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-gray-600">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span className="max-w-sm break-all font-medium">{issue.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
{!isNotAllowed && (
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => handleSubIssueRemove(issue.id)}>
|
||||
Remove as sub-issue
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
)}
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Create new
|
||||
</button>
|
||||
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => setSubIssuesListModal(true)}>
|
||||
Add an existing issue
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
) : null}
|
||||
</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="mt-3 flex flex-col gap-y-1">
|
||||
{subIssues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="group flex items-center justify-between gap-2 rounded p-2 hover:bg-gray-100"
|
||||
>
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}>
|
||||
<a className="flex items-center gap-2 rounded text-xs">
|
||||
<span
|
||||
className="block flex-shrink-0 h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-gray-600">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span className="max-w-sm break-all font-medium">{issue.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-0 group-hover:opacity-100 cursor-pointer"
|
||||
onClick={() => handleSubIssueRemove(issue.id)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4 text-gray-500 hover:text-gray-900" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
) : (
|
||||
!isNotAllowed && (
|
||||
<CustomMenu
|
||||
label={
|
||||
<>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add sub-issue
|
||||
</>
|
||||
}
|
||||
optionsPosition="left"
|
||||
noBorder
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={handleCreateIssueModal}>Create new</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => setSubIssuesListModal(true)}>
|
||||
Add an existing issue
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -9,7 +9,7 @@ import { Listbox, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
// ui
|
||||
import { AssigneesList, Avatar } from "components/ui";
|
||||
import { AssigneesList, Avatar, Tooltip } from "components/ui";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// fetch-keys
|
||||
@ -56,13 +56,26 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
|
||||
{({ open }) => (
|
||||
<div>
|
||||
<Listbox.Button>
|
||||
<div
|
||||
className={`flex ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center gap-1 text-xs`}
|
||||
<Tooltip
|
||||
tooltipHeading="Assignee"
|
||||
tooltipContent={
|
||||
issue.assignee_details.length > 0
|
||||
? issue.assignee_details
|
||||
.map((assingee) =>
|
||||
assingee.first_name !== "" ? assingee.first_name : assingee.email
|
||||
)
|
||||
.toString()
|
||||
: "No Assignee"
|
||||
}
|
||||
>
|
||||
<AssigneesList userIds={issue.assignees ?? []} />
|
||||
</div>
|
||||
<div
|
||||
className={`flex ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center gap-1 text-xs`}
|
||||
>
|
||||
<AssigneesList userIds={issue.assignees ?? []} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
|
@ -1,5 +1,5 @@
|
||||
// ui
|
||||
import { CustomDatePicker } from "components/ui";
|
||||
import { CustomDatePicker, Tooltip } from "components/ui";
|
||||
// helpers
|
||||
import { findHowManyDaysLeft } from "helpers/date-time.helper";
|
||||
// types
|
||||
@ -12,25 +12,27 @@ type Props = {
|
||||
};
|
||||
|
||||
export const ViewDueDateSelect: React.FC<Props> = ({ issue, partialUpdateIssue, isNotAllowed }) => (
|
||||
<div
|
||||
className={`group relative ${
|
||||
issue.target_date === null
|
||||
? ""
|
||||
: issue.target_date < new Date().toISOString()
|
||||
? "text-red-600"
|
||||
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
|
||||
}`}
|
||||
>
|
||||
<CustomDatePicker
|
||||
placeholder="N/A"
|
||||
value={issue?.target_date}
|
||||
onChange={(val) =>
|
||||
partialUpdateIssue({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"}
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip tooltipHeading="Due Date" tooltipContent={issue.target_date ?? "N/A"}>
|
||||
<div
|
||||
className={`group relative ${
|
||||
issue.target_date === null
|
||||
? ""
|
||||
: issue.target_date < new Date().toISOString()
|
||||
? "text-red-600"
|
||||
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
|
||||
}`}
|
||||
>
|
||||
<CustomDatePicker
|
||||
placeholder="N/A"
|
||||
value={issue?.target_date}
|
||||
onChange={(val) =>
|
||||
partialUpdateIssue({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"}
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
// ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { CustomSelect, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { getPriorityIcon } from "components/icons/priority-icon";
|
||||
// types
|
||||
@ -22,67 +22,45 @@ export const ViewPrioritySelect: React.FC<Props> = ({
|
||||
position = "right",
|
||||
isNotAllowed,
|
||||
}) => (
|
||||
<Listbox
|
||||
as="div"
|
||||
value={issue.priority}
|
||||
onChange={(data: string) => {
|
||||
partialUpdateIssue({ priority: data });
|
||||
}}
|
||||
className="group relative flex-shrink-0"
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{({ open }) => (
|
||||
<div>
|
||||
<Listbox.Button
|
||||
className={`flex ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center gap-x-2 rounded px-2 py-0.5 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
|
||||
issue.priority === "urgent"
|
||||
? "bg-red-100 text-red-600"
|
||||
: issue.priority === "high"
|
||||
? "bg-orange-100 text-orange-500"
|
||||
: issue.priority === "medium"
|
||||
? "bg-yellow-100 text-yellow-500"
|
||||
: issue.priority === "low"
|
||||
? "bg-green-100 text-green-500"
|
||||
: "bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<CustomSelect
|
||||
label={
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={issue.priority ?? "None"}>
|
||||
<span>
|
||||
{getPriorityIcon(
|
||||
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
|
||||
"text-sm"
|
||||
)}
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options
|
||||
className={`absolute z-10 mt-1 max-h-48 w-36 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none ${
|
||||
position === "left" ? "left-0" : "right-0"
|
||||
}`}
|
||||
>
|
||||
{PRIORITIES?.map((priority) => (
|
||||
<Listbox.Option
|
||||
key={priority}
|
||||
className={({ active, selected }) =>
|
||||
`${active || selected ? "bg-indigo-50" : ""} ${
|
||||
selected ? "font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center gap-x-2 px-3 py-2 capitalize`
|
||||
}
|
||||
value={priority}
|
||||
>
|
||||
{getPriorityIcon(priority, "text-sm")}
|
||||
{priority ?? "None"}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
</span>
|
||||
</Tooltip>
|
||||
}
|
||||
value={issue.state}
|
||||
onChange={(data: string) => {
|
||||
partialUpdateIssue({ priority: data });
|
||||
}}
|
||||
maxHeight="md"
|
||||
buttonClassName={`flex ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center gap-x-2 rounded px-2 py-0.5 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
|
||||
issue.priority === "urgent"
|
||||
? "bg-red-100 text-red-600 hover:bg-red-100"
|
||||
: issue.priority === "high"
|
||||
? "bg-orange-100 text-orange-500 hover:bg-orange-100"
|
||||
: issue.priority === "medium"
|
||||
? "bg-yellow-100 text-yellow-500 hover:bg-yellow-100"
|
||||
: issue.priority === "low"
|
||||
? "bg-green-100 text-green-500 hover:bg-green-100"
|
||||
: "bg-gray-100"
|
||||
} border-none`}
|
||||
noChevron
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{PRIORITIES?.map((priority) => (
|
||||
<CustomSelect.Option key={priority} value={priority} className="capitalize">
|
||||
<>
|
||||
{getPriorityIcon(priority, "text-sm")}
|
||||
{priority ?? "None"}
|
||||
</>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
);
|
||||
|
@ -5,7 +5,7 @@ import useSWR from "swr";
|
||||
// services
|
||||
import stateService from "services/state.service";
|
||||
// ui
|
||||
import { CustomSelect } from "components/ui";
|
||||
import { CustomSelect, Tooltip } from "components/ui";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
@ -24,7 +24,7 @@ type Props = {
|
||||
export const ViewStateSelect: React.FC<Props> = ({
|
||||
issue,
|
||||
partialUpdateIssue,
|
||||
position,
|
||||
position = "right",
|
||||
isNotAllowed,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
@ -48,7 +48,16 @@ export const ViewStateSelect: React.FC<Props> = ({
|
||||
backgroundColor: states?.find((s) => s.id === issue.state)?.color,
|
||||
}}
|
||||
/>
|
||||
{addSpaceIfCamelCase(states?.find((s) => s.id === issue.state)?.name ?? "")}
|
||||
<Tooltip
|
||||
tooltipHeading="State"
|
||||
tooltipContent={addSpaceIfCamelCase(
|
||||
states?.find((s) => s.id === issue.state)?.name ?? ""
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{addSpaceIfCamelCase(states?.find((s) => s.id === issue.state)?.name ?? "")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
value={issue.state}
|
||||
|
189
apps/app/components/labels/create-label-modal.tsx
Normal file
189
apps/app/components/labels/create-label-modal.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// react-color
|
||||
import { TwitterPicker } from "react-color";
|
||||
// headless ui
|
||||
import { Dialog, Popover, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// ui
|
||||
import { Button, Input } from "components/ui";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IIssueLabels, IState } from "types";
|
||||
// constants
|
||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
|
||||
// types
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
projectId: string;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IState> = {
|
||||
name: "",
|
||||
color: "#000000",
|
||||
};
|
||||
|
||||
export const CreateLabelModal: React.FC<Props> = ({ isOpen, projectId, handleClose }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
watch,
|
||||
control,
|
||||
reset,
|
||||
setError,
|
||||
} = useForm<IIssueLabels>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
reset(defaultValues);
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: IIssueLabels) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
await issuesService
|
||||
.createIssueLabel(workspaceSlug as string, projectId as string, formData)
|
||||
.then((res) => {
|
||||
mutate<IIssueLabels[]>(
|
||||
PROJECT_ISSUE_LABELS(projectId),
|
||||
(prevData) => [res, ...(prevData ?? [])],
|
||||
false
|
||||
);
|
||||
onClose();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-30" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||
Create Label
|
||||
</Dialog.Title>
|
||||
<div className="mt-8 flex items-center gap-2">
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group inline-flex items-center rounded-sm bg-white text-base font-medium hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
|
||||
open ? "text-gray-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
<span>Color</span>
|
||||
{watch("color") && watch("color") !== "" && (
|
||||
<span
|
||||
className="ml-2 h-4 w-4 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("color") ?? "black",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={`ml-2 h-5 w-5 group-hover:text-gray-500 ${
|
||||
open ? "text-gray-600" : "text-gray-400"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="fixed left-5 z-50 mt-3 w-screen max-w-xs transform px-2 sm:px-0">
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TwitterPicker
|
||||
color={value}
|
||||
onChange={(value) => onChange(value.hex)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Enter name"
|
||||
autoComplete="off"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
width="full"
|
||||
validations={{
|
||||
required: "Name is required",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button theme="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating Label..." : "Create Label"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
@ -100,7 +100,10 @@ export const CreateUpdateLabelInline: React.FC<Props> = ({
|
||||
useEffect(() => {
|
||||
if (!labelToUpdate) return;
|
||||
|
||||
setValue("color", labelToUpdate.color);
|
||||
setValue(
|
||||
"color",
|
||||
labelToUpdate.color && labelToUpdate.color !== "" ? labelToUpdate.color : "#000"
|
||||
);
|
||||
setValue("name", labelToUpdate.name);
|
||||
}, [labelToUpdate, setValue]);
|
||||
|
||||
@ -123,7 +126,7 @@ export const CreateUpdateLabelInline: React.FC<Props> = ({
|
||||
<span
|
||||
className="h-4 w-4 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("color") ?? "green",
|
||||
backgroundColor: watch("color") ?? "#000",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,3 +1,4 @@
|
||||
export * from "./create-label-modal";
|
||||
export * from "./create-update-label-inline";
|
||||
export * from "./labels-list-modal";
|
||||
export * from "./single-label-group";
|
||||
|
@ -24,7 +24,7 @@ export const SingleLabel: React.FC<Props> = ({
|
||||
<span
|
||||
className="h-3 w-3 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color,
|
||||
backgroundColor: label.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
<h6 className="text-sm">{label.name}</h6>
|
||||
|
@ -65,10 +65,6 @@ export const DeleteModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data })
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
data && setIsOpen(true);
|
||||
}, [data, setIsOpen]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// components
|
||||
@ -8,9 +10,10 @@ import { Button, CustomDatePicker, Input, TextArea } from "components/ui";
|
||||
import { IModule } from "types";
|
||||
|
||||
type Props = {
|
||||
handleFormSubmit: (values: Partial<IModule>) => void;
|
||||
handleFormSubmit: (values: Partial<IModule>) => Promise<void>;
|
||||
handleClose: () => void;
|
||||
status: boolean;
|
||||
data?: IModule;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IModule> = {
|
||||
@ -21,7 +24,7 @@ const defaultValues: Partial<IModule> = {
|
||||
members_list: [],
|
||||
};
|
||||
|
||||
export const ModuleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status }) => {
|
||||
export const ModuleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status, data }) => {
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
@ -40,6 +43,13 @@ export const ModuleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, sta
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...data,
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleCreateUpdateModule)}>
|
||||
<div className="space-y-5">
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user