diff --git a/.eslintrc.js b/.eslintrc.js index be1ad0f9d..463c86901 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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/*"], }, }, }; diff --git a/.gitignore b/.gitignore index 4933d309e..3562ab0b3 100644 --- a/.gitignore +++ b/.gitignore @@ -64,4 +64,9 @@ package-lock.json .vscode # Sentry -.sentryclirc \ No newline at end of file +.sentryclirc + +# lock files +package-lock.json +pnpm-lock.yaml +pnpm-workspace.yaml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..094d628e3 --- /dev/null +++ b/Dockerfile @@ -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"] + + + + diff --git a/apiserver/.env.example b/apiserver/.env.example index 0595770fa..9a6904b55 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -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 diff --git a/apiserver/Dockerfile.api b/apiserver/Dockerfile.api index 6343c740e..123544571 100644 --- a/apiserver/Dockerfile.api +++ b/apiserver/Dockerfile.api @@ -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" ] diff --git a/apiserver/back_migration.py b/apiserver/back_migration.py index 9613412a3..f716ea29f 100644 --- a/apiserver/back_migration.py +++ b/apiserver/back_migration.py @@ -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") diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index 8340f16c7..d22eceb6e 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -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 - diff --git a/apiserver/bin/user_script.py b/apiserver/bin/user_script.py new file mode 100644 index 000000000..b554d2c40 --- /dev/null +++ b/apiserver/bin/user_script.py @@ -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() diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 8d43d90ff..183129939 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -40,4 +40,13 @@ from .issue import ( from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer -from .api_token import APITokenSerializer \ No newline at end of file +from .api_token import APITokenSerializer + +from .integration import ( + IntegrationSerializer, + WorkspaceIntegrationSerializer, + GithubIssueSyncSerializer, + GithubRepositorySerializer, + GithubRepositorySyncSerializer, + GithubCommentSyncSerializer, +) diff --git a/apiserver/plane/api/serializers/integration/__init__.py b/apiserver/plane/api/serializers/integration/__init__.py new file mode 100644 index 000000000..8aea68bd6 --- /dev/null +++ b/apiserver/plane/api/serializers/integration/__init__.py @@ -0,0 +1,7 @@ +from .base import IntegrationSerializer, WorkspaceIntegrationSerializer +from .github import ( + GithubRepositorySerializer, + GithubRepositorySyncSerializer, + GithubIssueSyncSerializer, + GithubCommentSyncSerializer, +) diff --git a/apiserver/plane/api/serializers/integration/base.py b/apiserver/plane/api/serializers/integration/base.py new file mode 100644 index 000000000..10ebd4620 --- /dev/null +++ b/apiserver/plane/api/serializers/integration/base.py @@ -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__" diff --git a/apiserver/plane/api/serializers/integration/github.py b/apiserver/plane/api/serializers/integration/github.py new file mode 100644 index 000000000..8352dcee1 --- /dev/null +++ b/apiserver/plane/api/serializers/integration/github.py @@ -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", + ] diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 3add8f965..6a3c06e22 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -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: diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index 808991ddc..14a33d9c3 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -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", ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 4af139bf5..e44579cb7 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -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//", ApiTokenEndpoint.as_view(), name="api-token"), + path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"), + path("api-tokens//", ApiTokenEndpoint.as_view(), name="api-tokens"), ## End API Tokens + # Integrations + path( + "integrations/", + IntegrationViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="integrations", + ), + path( + "integrations//", + IntegrationViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="integrations", + ), + path( + "workspaces//workspace-integrations/", + WorkspaceIntegrationViewSet.as_view( + { + "get": "list", + } + ), + name="workspace-integrations", + ), + path( + "workspaces//workspace-integrations//", + WorkspaceIntegrationViewSet.as_view( + { + "post": "create", + } + ), + name="workspace-integrations", + ), + path( + "workspaces//workspace-integrations//", + WorkspaceIntegrationViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + name="workspace-integrations", + ), + # Github Integrations + path( + "workspaces//workspace-integrations//github-repositories/", + GithubRepositoriesEndpoint.as_view(), + ), + path( + "workspaces//projects//workspace-integrations//github-repository-sync/", + GithubRepositorySyncViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + ), + path( + "workspaces//projects//workspace-integrations//github-repository-sync//", + GithubRepositorySyncViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync/", + GithubIssueSyncViewSet.as_view( + { + "post": "create", + "get": "list", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync//", + GithubIssueSyncViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync//github-comment-sync/", + GithubCommentSyncViewSet.as_view( + { + "post": "create", + "get": "list", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync//github-comment-sync//", + GithubCommentSyncViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + ), + ## End Github Integrations + ## End Integrations ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 4fb565e8d..275642c50 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -72,4 +72,13 @@ from .authentication import ( from .module import ModuleViewSet, ModuleIssueViewSet -from .api_token import ApiTokenEndpoint \ No newline at end of file +from .api_token import ApiTokenEndpoint + +from .integration import ( + WorkspaceIntegrationViewSet, + IntegrationViewSet, + GithubIssueSyncViewSet, + GithubRepositorySyncViewSet, + GithubCommentSyncViewSet, + GithubRepositoriesEndpoint, +) diff --git a/apiserver/plane/api/views/integration/__init__.py b/apiserver/plane/api/views/integration/__init__.py new file mode 100644 index 000000000..693202573 --- /dev/null +++ b/apiserver/plane/api/views/integration/__init__.py @@ -0,0 +1,7 @@ +from .base import IntegrationViewSet, WorkspaceIntegrationViewSet +from .github import ( + GithubRepositorySyncViewSet, + GithubIssueSyncViewSet, + GithubCommentSyncViewSet, + GithubRepositoriesEndpoint, +) diff --git a/apiserver/plane/api/views/integration/base.py b/apiserver/plane/api/views/integration/base.py new file mode 100644 index 000000000..bded732ec --- /dev/null +++ b/apiserver/plane/api/views/integration/base.py @@ -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, + ) diff --git a/apiserver/plane/api/views/integration/github.py b/apiserver/plane/api/views/integration/github.py new file mode 100644 index 000000000..7486ce7b9 --- /dev/null +++ b/apiserver/plane/api/views/integration/github.py @@ -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"), + ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 37082e0ec..68797c296 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -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, + ) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 7e0e3f6ff..a9bf30712 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -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) diff --git a/apiserver/plane/db/mixins.py b/apiserver/plane/db/mixins.py index b48e5c965..728cb9933 100644 --- a/apiserver/plane/db/mixins.py +++ b/apiserver/plane/db/mixins.py @@ -1,3 +1,7 @@ +# Python imports +import uuid + +# Django imports from django.db import models diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index ef7ad5b8d..ce8cf950b 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -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 \ No newline at end of file +from .api_token import APIToken + +from .integration import ( + WorkspaceIntegration, + Integration, + GithubRepository, + GithubRepositorySync, + GithubIssueSync, + GithubCommentSync, +) diff --git a/apiserver/plane/db/models/integration/__init__.py b/apiserver/plane/db/models/integration/__init__.py new file mode 100644 index 000000000..4742a2529 --- /dev/null +++ b/apiserver/plane/db/models/integration/__init__.py @@ -0,0 +1,2 @@ +from .base import Integration, WorkspaceIntegration +from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync diff --git a/apiserver/plane/db/models/integration/base.py b/apiserver/plane/db/models/integration/base.py new file mode 100644 index 000000000..47db0483c --- /dev/null +++ b/apiserver/plane/db/models/integration/base.py @@ -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",) diff --git a/apiserver/plane/db/models/integration/github.py b/apiserver/plane/db/models/integration/github.py new file mode 100644 index 000000000..130925c21 --- /dev/null +++ b/apiserver/plane/db/models/integration/github.py @@ -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",) diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 82e8343bb..aea41677e 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -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( diff --git a/apiserver/plane/db/models/state.py b/apiserver/plane/db/models/state.py index d66ecfa72..2fa1ebe38 100644 --- a/apiserver/plane/db/models/state.py +++ b/apiserver/plane/db/models/state.py @@ -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) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index e14c250b4..9d270662e 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -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 diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 4d4af9b77..ccb388012 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -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) diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index c83901484..1b6ac2cf7 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -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) diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index 725f2cd85..0e58ab224 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -185,3 +185,5 @@ RQ_QUEUES = { WEB_URL = os.environ.get("WEB_URL") + +PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py new file mode 100644 index 000000000..51c1f61c2 --- /dev/null +++ b/apiserver/plane/utils/grouper.py @@ -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 \ No newline at end of file diff --git a/apiserver/plane/utils/integrations/__init__.py b/apiserver/plane/utils/integrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/utils/integrations/github.py b/apiserver/plane/utils/integrations/github.py new file mode 100644 index 000000000..ba9cb0ae0 --- /dev/null +++ b/apiserver/plane/utils/integrations/github.py @@ -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 diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 97ef2901f..ffe11a234 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -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 diff --git a/apiserver/templates/admin/base_site.html b/apiserver/templates/admin/base_site.html index 4fdb5e19b..fd1d89067 100644 --- a/apiserver/templates/admin/base_site.html +++ b/apiserver/templates/admin/base_site.html @@ -17,7 +17,7 @@ color: #FFFFFF; } -

{% trans 'plane Admin' %}

+

{% trans 'Plane Django Admin' %}

{% endblock %}{% block nav-global %}{% endblock %} diff --git a/app.json b/app.json index 017911920..7f6b27427 100644 --- a/app.json +++ b/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": "" } } -} +} \ No newline at end of file diff --git a/apps/app/.env.example b/apps/app/.env.example new file mode 100644 index 000000000..5d787806d --- /dev/null +++ b/apps/app/.env.example @@ -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 \ No newline at end of file diff --git a/apps/app/.eslintrc.js b/apps/app/.eslintrc.js index 64b6ff36b..c8df60750 100644 --- a/apps/app/.eslintrc.js +++ b/apps/app/.eslintrc.js @@ -1 +1,4 @@ -module.exports = require("config/.eslintrc"); +module.exports = { + root: true, + extends: ["custom"], +}; diff --git a/apps/app/Dockerfile.dev b/apps/app/Dockerfile.dev new file mode 100644 index 000000000..7b802634c --- /dev/null +++ b/apps/app/Dockerfile.dev @@ -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"] diff --git a/apps/app/Dockerfile.web b/apps/app/Dockerfile.web index be8abf5fa..11bf98bd4 100644 --- a/apps/app/Dockerfile.web +++ b/apps/app/Dockerfile.web @@ -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 \ No newline at end of file +EXPOSE 3000 diff --git a/apps/app/components/account/email-code-form.tsx b/apps/app/components/account/email-code-form.tsx index 98ab10cb7..db201041c 100644 --- a/apps/app/components/account/email-code-form.tsx +++ b/apps/app/components/account/email-code-form.tsx @@ -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({ 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 ( <>
- {codeSent && ( + {(codeSent || codeResent) && (
@@ -77,7 +99,9 @@ export const EmailCodeForm = ({ onSuccess }: any) => {

- Please check your mail for code. + {codeResent + ? "Please check your mail for new code." + : "Please check your mail for code."}

@@ -114,15 +138,33 @@ export const EmailCodeForm = ({ onSuccess }: any) => { error={errors.token} placeholder="Enter code" /> - {/* { - console.log("Triggered"); - handleSubmit(onSubmit); + setIsCodeResending(true); + onSubmit({ email: getValues("email") }).then(() => { + setCodeResent(true); + setIsCodeResending(false); + setResendCodeTimer(30); + }); }} + disabled={isResendDisabled} > - Resend code - */} + {resendCodeTimer > 0 ? ( +

+ Didn{"'"}t receive code? Get new code in {resendCodeTimer} seconds. +

+ ) : isCodeResending ? ( + "Sending code..." + ) : errorResendingCode ? ( + "Please try again later" + ) : ( + "Resend code" + )} +
)}
@@ -139,7 +181,11 @@ export const EmailCodeForm = ({ onSuccess }: any) => { +
- -
- - -
- -); + ); +}; diff --git a/apps/app/components/core/board-view/single-board.tsx b/apps/app/components/core/board-view/single-board.tsx index e7cb49798..5fee43c88 100644 --- a/apps/app/components/core/board-view/single-board.tsx +++ b/apps/app/components/core/board-view/single-board.tsx @@ -25,11 +25,13 @@ type Props = { }; selectedGroup: NestedKeyOf | null; members: IProjectMember[] | undefined; + handleEditIssue: (issue: IIssue) => void; addIssueToState: () => void; handleDeleteIssue: (issue: IIssue) => void; openIssuesListModal?: (() => void) | null; - orderBy: NestedKeyOf | "manual" | null; + orderBy: NestedKeyOf | null; handleTrashBox: (isDragging: boolean) => void; + removeIssue: ((bridgeId: string) => void) | null; userAuth: UserAuth; }; @@ -40,11 +42,13 @@ export const SingleBoard: React.FC = ({ groupedByIssues, selectedGroup, members, + handleEditIssue, addIssueToState, handleDeleteIssue, openIssuesListModal, orderBy, handleTrashBox, + removeIssue, userAuth, }) => { // collapse/expand @@ -55,11 +59,6 @@ export const SingleBoard: React.FC = ({ 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 = ({ {(provided, snapshot) => (
+ {orderBy !== "sort_order" && ( + <> +
+
+ This board is ordered by {orderBy} +
+ + )} {groupedByIssues[groupTitle].map((issue, index: number) => ( {(provided, snapshot) => ( = ({ 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 = ({ ))} {provided.placeholder} diff --git a/apps/app/components/core/board-view/single-issue.tsx b/apps/app/components/core/board-view/single-issue.tsx index 945592788..ea1a37a7a 100644 --- a/apps/app/components/core/board-view/single-issue.tsx +++ b/apps/app/components/core/board-view/single-issue.tsx @@ -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 | null; properties: Properties; + editIssue: () => void; + removeIssue?: (() => void) | null; handleDeleteIssue: (issue: IIssue) => void; - orderBy: NestedKeyOf | "manual" | null; + orderBy: NestedKeyOf | null; handleTrashBox: (isDragging: boolean) => void; userAuth: UserAuth; }; @@ -53,7 +59,10 @@ export const SingleBoardIssue: React.FC = ({ provided, snapshot, issue, + selectedGroup, properties, + editIssue, + removeIssue, handleDeleteIssue, orderBy, handleTrashBox, @@ -62,6 +71,8 @@ export const SingleBoardIssue: React.FC = ({ const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const { setToastAlert } = useToast(); + const partialUpdateIssue = useCallback( (formData: Partial) => { if (!workspaceSlug || !projectId) return; @@ -108,15 +119,15 @@ export const SingleBoardIssue: React.FC = ({ false ); - mutate( + mutate( 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 = ({ 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 = ({ }; } - 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 (
= ({
{!isNotAllowed && (
- + {type && !isNotAllowed && ( + + Edit + {type !== "issue" && removeIssue && ( + + <>Remove from {type} + + )} + handleDeleteIssue(issue)}> + Delete permanently + + Copy issue link + + )}
)} @@ -195,7 +231,7 @@ export const SingleBoardIssue: React.FC = ({
- {properties.priority && ( + {properties.priority && selectedGroup !== "priority" && ( = ({ position="left" /> )} - {properties.state && ( + {properties.state && selectedGroup !== "state_detail.name" && ( = ({ 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 = ({ 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 = ({ isOpen, setIsOpen }) => message: res.message, }); - mutate( + mutate( 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(); diff --git a/apps/app/components/core/existing-issues-list-modal.tsx b/apps/app/components/core/existing-issues-list-modal.tsx index 15a313cb0..59faff3f6 100644 --- a/apps/app/components/core/existing-issues-list-modal.tsx +++ b/apps/app/components/core/existing-issues-list-modal.tsx @@ -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 = ({ handleClose: onClose, issues, handleOnSubmit, - type, }) => { const [query, setQuery] = useState(""); @@ -105,7 +103,7 @@ export const ExistingIssuesListModal: React.FC = ({ leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - + = ({
  • {query === "" && (

    - Select issues to add to {type} + Select issues to add

    )}
      @@ -203,7 +201,7 @@ export const ExistingIssuesListModal: React.FC = ({ onClick={handleSubmit(onSubmit)} disabled={isSubmitting} > - {isSubmitting ? "Adding..." : `Add to ${type}`} + {isSubmitting ? "Adding..." : "Add selected issues"}
  • )} diff --git a/apps/app/components/core/index.ts b/apps/app/components/core/index.ts index 482258b4a..8146660a1 100644 --- a/apps/app/components/core/index.ts +++ b/apps/app/components/core/index.ts @@ -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"; diff --git a/apps/app/components/core/issues-view-filter.tsx b/apps/app/components/core/issues-view-filter.tsx index 7225f5148..9962ba96d 100644 --- a/apps/app/components/core/issues-view-filter.tsx +++ b/apps/app/components/core/issues-view-filter.tsx @@ -130,7 +130,9 @@ export const IssuesFilterView: React.FC = ({ issues }) => { option.key === "priority" ? null : ( setOrderBy(option.key)} + onClick={() => { + setOrderBy(option.key); + }} > {option.name} @@ -178,20 +180,29 @@ export const IssuesFilterView: React.FC = ({ issues }) => {

    Display Properties

    - {Object.keys(properties).map((key) => ( - - ))} + {Object.keys(properties).map((key) => { + if ( + issueView === "kanban" && + ((groupByProperty === "state_detail.name" && key === "state") || + (groupByProperty === "priority" && key === "priority")) + ) + return; + + return ( + + ); + })}
    diff --git a/apps/app/components/core/issues-view.tsx b/apps/app/components/core/issues-view.tsx index 2edb7f804..98526a2b0 100644 --- a/apps/app/components/core/issues-view.tsx +++ b/apps/app/components/core/issues-view.tsx @@ -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 = ({ 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 = ({ 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( - 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( - 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( - 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( - 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( - 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( - PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), + if (cycleId) + mutate( + 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( + 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( + 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 = ({ groupedByIssues, projectId, selectedGroup, + orderBy, states, handleDeleteIssue, ] @@ -452,9 +408,17 @@ export const IssuesView: React.FC = ({ 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} /> )} diff --git a/apps/app/components/modules/module-link-modal.tsx b/apps/app/components/core/link-modal.tsx similarity index 79% rename from apps/app/components/modules/module-link-modal.tsx rename to apps/app/components/core/link-modal.tsx index 2917717ad..2b1004e29 100644 --- a/apps/app/components/modules/module-link-modal.tsx +++ b/apps/app/components/core/link-modal.tsx @@ -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 = ({ isOpen, module, handleClose }) => { - const router = useRouter(); - const { workspaceSlug, projectId, moduleId } = router.query; - +export const LinkModal: React.FC = ({ isOpen, handleClose, onFormSubmit }) => { const { register, formState: { errors, isSubmitting }, handleSubmit, reset, - setError, } = useForm({ 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 = { - 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 = () => { diff --git a/apps/app/components/core/list-view/single-issue.tsx b/apps/app/components/core/list-view/single-issue.tsx index b779db594..69745f6e2 100644 --- a/apps/app/components/core/list-view/single-issue.tsx +++ b/apps/app/components/core/list-view/single-issue.tsx @@ -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 = ({ }) => { const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; - + const { setToastAlert } = useToast(); const partialUpdateIssue = useCallback( (formData: Partial) => { if (!workspaceSlug || !projectId) return; @@ -96,15 +94,15 @@ export const SingleListIssue: React.FC = ({ false ); - mutate( + mutate( 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 = ({ [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 = ({ {properties.key && ( - - {issue.project_detail?.identifier}-{issue.sequence_id} - + + + {issue.project_detail?.identifier}-{issue.sequence_id} + + )} - {issue.name} + + + {issue.name} + +
    @@ -190,6 +214,7 @@ export const SingleListIssue: React.FC = ({ handleDeleteIssue(issue)}> Delete permanently + Copy issue link )}
    diff --git a/apps/app/components/core/list-view/single-list.tsx b/apps/app/components/core/list-view/single-list.tsx index 4309b2d33..9c3a7ac0f 100644 --- a/apps/app/components/core/list-view/single-list.tsx +++ b/apps/app/components/core/list-view/single-list.tsx @@ -50,9 +50,20 @@ export const SingleList: React.FC = ({ 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 ( {({ open }) => ( @@ -67,10 +78,10 @@ export const SingleList: React.FC = ({ {selectedGroup !== null ? (

    - {groupTitle === null || groupTitle === "null" - ? "None" - : createdBy + {selectedGroup === "created_by" ? createdBy + : selectedGroup === "assignees" + ? assignees : addSpaceIfCamelCase(groupTitle)}

    ) : ( diff --git a/apps/app/components/core/sidebar/index.ts b/apps/app/components/core/sidebar/index.ts index 20d186d1e..c5357f576 100644 --- a/apps/app/components/core/sidebar/index.ts +++ b/apps/app/components/core/sidebar/index.ts @@ -1,2 +1,3 @@ +export * from "./links-list"; export * from "./sidebar-progress-stats"; export * from "./single-progress-stats"; diff --git a/apps/app/components/core/sidebar/links-list.tsx b/apps/app/components/core/sidebar/links-list.tsx new file mode 100644 index 000000000..2a30510eb --- /dev/null +++ b/apps/app/components/core/sidebar/links-list.tsx @@ -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 = ({ links, handleDeleteLink, userAuth }) => { + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + + return ( + <> + {links.map((link) => ( +
    + {!isNotAllowed && ( +
    + +
    + )} + + +
    + +
    +
    +
    {link.title}
    + {/*

    + Added {timeAgo(link.created_at)} ago by {link.created_by_detail.email} +

    */} +
    +
    + +
    + ))} + + ); +}; diff --git a/apps/app/components/core/sidebar/progress-chart.tsx b/apps/app/components/core/sidebar/progress-chart.tsx index d787e7077..b0d5bb394 100644 --- a/apps/app/components/core/sidebar/progress-chart.tsx +++ b/apps/app/components/core/sidebar/progress-chart.tsx @@ -46,6 +46,16 @@ const ProgressChart: React.FC = ({ issues, start, end }) => { const ChartData = getChartData(); return (
    +
    +
    + + Ideal +
    +
    + + Current +
    +
    = ({ issues, start, end }) => {
    -
    -
    - - Ideal -
    -
    - - Current -
    -
    ); }; diff --git a/apps/app/components/core/sidebar/sidebar-progress-stats.tsx b/apps/app/components/core/sidebar/sidebar-progress-stats.tsx index ea8e1e401..01417b625 100644 --- a/apps/app/components/core/sidebar/sidebar-progress-stats.tsx +++ b/apps/app/components/core/sidebar/sidebar-progress-stats.tsx @@ -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 = ({ 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( workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, workspaceSlug && projectId @@ -55,7 +58,7 @@ export const SidebarProgressStats: React.FC = ({ 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 = ({ groupedIssues, issues }) return 1; case "States": return 2; + default: + return 0; } }; return ( diff --git a/apps/app/components/core/sidebar/single-progress-stats.tsx b/apps/app/components/core/sidebar/single-progress-stats.tsx index 4b3de9c9f..bb56e1545 100644 --- a/apps/app/components/core/sidebar/single-progress-stats.tsx +++ b/apps/app/components/core/sidebar/single-progress-stats.tsx @@ -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 = ({
    - + {Math.floor((completed / total) * 100)}%
    diff --git a/apps/app/components/project/cycles/stats-view/index.tsx b/apps/app/components/cycles/cycles-list-view.tsx similarity index 87% rename from apps/app/components/project/cycles/stats-view/index.tsx rename to apps/app/components/cycles/cycles-list-view.tsx index 354bab4a1..8491190e8 100644 --- a/apps/app/components/project/cycles/stats-view/index.tsx +++ b/apps/app/components/cycles/cycles-list-view.tsx @@ -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 = ({ +export const CyclesListView: React.FC = ({ cycles, setCreateUpdateCycleModal, setSelectedCycle, @@ -35,7 +34,7 @@ const CycleStatsView: React.FC = ({ return ( <> - = ({ /> {cycles.length > 0 ? ( cycles.map((cycle) => ( - handleDeleteCycle(cycle)} @@ -71,5 +70,3 @@ const CycleStatsView: React.FC = ({ ); }; - -export default CycleStatsView; diff --git a/apps/app/components/project/cycles/confirm-cycle-deletion.tsx b/apps/app/components/cycles/delete-cycle-modal.tsx similarity index 97% rename from apps/app/components/project/cycles/confirm-cycle-deletion.tsx rename to apps/app/components/cycles/delete-cycle-modal.tsx index c1a5f38ed..a962533a4 100644 --- a/apps/app/components/project/cycles/confirm-cycle-deletion.tsx +++ b/apps/app/components/cycles/delete-cycle-modal.tsx @@ -23,7 +23,7 @@ type TConfirmCycleDeletionProps = { // fetch-keys import { CYCLE_LIST } from "constants/fetch-keys"; -const ConfirmCycleDeletion: React.FC = ({ +export const DeleteCycleModal: React.FC = ({ isOpen, setIsOpen, data, @@ -149,5 +149,3 @@ const ConfirmCycleDeletion: React.FC = ({ ); }; - -export default ConfirmCycleDeletion; diff --git a/apps/app/components/cycles/form.tsx b/apps/app/components/cycles/form.tsx index 262db1abc..58f57ba14 100644 --- a/apps/app/components/cycles/form.tsx +++ b/apps/app/components/cycles/form.tsx @@ -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) => Promise; + handleClose: () => void; + status: boolean; + data?: ICycle; +}; const defaultValues: Partial = { name: "", description: "", status: "draft", - start_date: new Date().toString(), - end_date: new Date().toString(), + start_date: "", + end_date: "", }; -export interface CycleFormProps { - handleFormSubmit: (values: Partial) => void; - handleFormCancel?: () => void; - initialData?: Partial; -} - -export const CycleForm: FC = (props) => { - const { handleFormSubmit, handleFormCancel = () => {}, initialData = null } = props; - // form handler +export const CycleForm: React.FC = ({ handleFormSubmit, handleClose, status, data }) => { const { register, formState: { errors, isSubmitting }, handleSubmit, control, + reset, } = useForm({ - defaultValues: initialData || defaultValues, + defaultValues, }); + const handleCreateUpdateCycle = async (formData: Partial) => { + await handleFormSubmit(formData); + + reset({ + ...defaultValues, + }); + }; + + useEffect(() => { + reset({ + ...defaultValues, + ...data, + }); + }, [data, reset]); + return ( - +
    +

    + {status ? "Update" : "Create"} Cycle +

    = (props) => { register={register} validations={{ required: "Name is required", + maxLength: { + value: 255, + message: "Name should be less than 255 characters", + }, }} />
    @@ -86,42 +110,56 @@ export const CycleForm: FC = (props) => {
    - +
    Start Date
    +
    + ( + + )} + /> + {errors.start_date && ( +
    {errors.start_date.message}
    + )} +
    - +
    End Date
    +
    + ( + + )} + /> + {errors.end_date && ( +
    {errors.end_date.message}
    + )} +
    -
    diff --git a/apps/app/components/cycles/select.tsx b/apps/app/components/cycles/select.tsx index 26de56bb8..c48219276 100644 --- a/apps/app/components/cycles/select.tsx +++ b/apps/app/components/cycles/select.tsx @@ -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 = ({ return ( <> - + {({ open }) => ( <> diff --git a/apps/app/components/project/cycles/cycle-detail-sidebar/index.tsx b/apps/app/components/cycles/sidebar.tsx similarity index 67% rename from apps/app/components/project/cycles/cycle-detail-sidebar/index.tsx rename to apps/app/components/cycles/sidebar.tsx index f552bef88..c61102fb1 100644 --- a/apps/app/components/project/cycles/cycle-detail-sidebar/index.tsx +++ b/apps/app/components/cycles/sidebar.tsx @@ -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 = ({ issues, cycle, isOpen, cycleIssues }) => { +export const CycleDetailsSidebar: React.FC = ({ issues, cycle, isOpen, cycleIssues }) => { const [cycleDeleteModal, setCycleDeleteModal] = useState(false); const router = useRouter(); @@ -111,11 +108,7 @@ const CycleDetailSidebar: React.FC = ({ issues, cycle, isOpen, cycleIssue return ( <> - +
    = ({ issues, cycle, isOpen, cycleIssue )} />
    - - {({ open }) => ( - <> - - - - {renderShortNumericDateFormat(`${cycle.start_date}`) - ? renderShortNumericDateFormat(`${cycle.start_date}`) - : "N/A"}{" "} - -{" "} - {renderShortNumericDateFormat(`${cycle.end_date}`) - ? renderShortNumericDateFormat(`${cycle.end_date}`) - : "N/A"} - - +
    + + {({ open }) => ( + <> + + + + {renderShortNumericDateFormat(`${cycle.start_date}`) + ? renderShortNumericDateFormat(`${cycle.start_date}`) + : "N/A"} + + - - - { - 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 - /> - - - - )} - + + + { + submitChanges({ + start_date: renderDateFormat(date), + }); + setStartDateRange(date); + }} + selectsStart + startDate={startDateRange} + endDate={endDateRange} + inline + /> + + + + )} + + + {({ open }) => ( + <> + + + -{" "} + {renderShortNumericDateFormat(`${cycle.end_date}`) + ? renderShortNumericDateFormat(`${cycle.end_date}`) + : "N/A"} + + + + + + { + submitChanges({ + end_date: renderDateFormat(date), + }); + setEndDateRange(date); + }} + selectsEnd + startDate={startDateRange} + endDate={endDateRange} + minDate={startDateRange} + inline + /> + + + + )} + +

    {cycle.name}

    @@ -282,10 +307,9 @@ const CycleDetailSidebar: React.FC = ({ issues, cycle, isOpen, cycleIssue
    -
    @@ -331,5 +355,3 @@ const CycleDetailSidebar: React.FC = ({ issues, cycle, isOpen, cycleIssue ); }; - -export default CycleDetailSidebar; diff --git a/apps/app/components/project/cycles/stats-view/single-stat.tsx b/apps/app/components/cycles/single-cycle-card.tsx similarity index 87% rename from apps/app/components/project/cycles/stats-view/single-stat.tsx rename to apps/app/components/cycles/single-cycle-card.tsx index 33bc18b05..86589995f 100644 --- a/apps/app/components/project/cycles/stats-view/single-stat.tsx +++ b/apps/app/components/cycles/single-cycle-card.tsx @@ -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 = (props) => { +export const SingleCycleCard: React.FC = (props) => { const { cycle, handleEditCycle, handleDeleteCycle } = props; const router = useRouter(); const { workspaceSlug, projectId } = router.query; + const { setToastAlert } = useToast(); const { data: cycleIssues } = useSWR( workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null, @@ -63,6 +67,24 @@ const SingleStat: React.FC = (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 ( <>
    @@ -77,6 +99,7 @@ const SingleStat: React.FC = (props) => { + Copy cycle link Edit cycle Delete cycle permanently @@ -161,5 +184,3 @@ const SingleStat: React.FC = (props) => { ); }; - -export default SingleStat; diff --git a/apps/app/components/issues/activity.tsx b/apps/app/components/issues/activity.tsx index 2bcc3853d..37678b2a2 100644 --- a/apps/app/components/issues/activity.tsx +++ b/apps/app/components/issues/activity.tsx @@ -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; -}; +type Props = {}; -export const IssueActivitySection: React.FC = ({ issueActivities, mutate }) => { +export const IssueActivitySection: React.FC = () => { 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 = ({ 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 = ({ issueActivities, mutate commentId ) .then((response) => { - mutate(); + mutateIssueActivities(); console.log(response); }); }; @@ -234,8 +243,8 @@ export const IssueActivitySection: React.FC = ({ issueActivities, mutate ); })} diff --git a/apps/app/components/issues/comment/add-comment.tsx b/apps/app/components/issues/comment/add-comment.tsx index 9b31a2423..b379b5ce1 100644 --- a/apps/app/components/issues/comment/add-comment.tsx +++ b/apps/app/components/issues/comment/add-comment.tsx @@ -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 = { comment_json: "", }; -export const AddComment: React.FC<{ - mutate: KeyedMutator; -}> = ({ 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) => { diff --git a/apps/app/components/issues/delete-issue-modal.tsx b/apps/app/components/issues/delete-issue-modal.tsx index bbc6552ba..58df96f0d 100644 --- a/apps/app/components/issues/delete-issue-modal.tsx +++ b/apps/app/components/issues/delete-issue-modal.tsx @@ -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 = ({ isOpen, handleClose, data }) false ); - mutate( + mutate( 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 ); diff --git a/apps/app/components/issues/description-form.tsx b/apps/app/components/issues/description-form.tsx index 84ae61643..2caf5b635 100644 --- a/apps/app/components/issues/description-form.tsx +++ b/apps/app/components/issues/description-form.tsx @@ -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 = ({ setValue, reset, formState: { errors }, - setError, } = useForm({ defaultValues: { name: "", @@ -76,8 +75,8 @@ export const IssueDescriptionForm: FC = ({ handleFormSubmit({ name: formData.name ?? "", - description: formData.description, - description_html: formData.description_html, + description: formData.description ?? "", + description_html: formData.description_html ?? "

    ", }); }, [handleFormSubmit, setToastAlert] @@ -106,19 +105,20 @@ export const IssueDescriptionForm: FC = ({ return (
    - { 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 " /> {errors.name ? errors.name.message : null} = { }; export interface IssueFormProps { - handleFormSubmit: (values: Partial) => void; + handleFormSubmit: (values: Partial) => Promise; initialData?: Partial; issues: IIssue[]; projectId: string; @@ -74,6 +75,7 @@ export const IssueForm: FC = ({ const [mostSimilarIssue, setMostSimilarIssue] = useState(); 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 = ({ reset({ ...defaultValues, project: projectId, + description: "", + description_html: "

    ", }); }; useEffect(() => { reset({ ...defaultValues, - ...watch(), - project: projectId, ...initialData, + project: projectId, }); - }, [initialData, reset, watch, projectId]); + }, [initialData, reset, projectId]); return ( <> {projectId && ( <> - setStateModal(false)} projectId={projectId} /> - setCycleModal(false)} /> + setLabelModal(false)} projectId={projectId} /> @@ -231,13 +235,11 @@ export const IssueForm: FC = ({ ( + render={({ field: { value } }) => ( { - 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 = ({ /> ( - - )} - /> - ( - + )} />
    @@ -297,6 +297,13 @@ export const IssueForm: FC = ({ )} />
    + ( + + )} + /> = ({ workspaceSlug ? () => projectService.getProjects(workspaceSlug as string) : null ); - const { setError } = useForm({ - 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 = ({ false ); } else - mutate( + mutate( 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 = ({ await issuesService .createIssues(workspaceSlug as string, activeProject ?? "", payload) .then((res) => { - mutate(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 = ({ 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 = ({ if (isUpdatingSingleIssue) { mutate(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false); } else { - mutate( + mutate( 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 = ({ 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 = ({ const payload: Partial = { ...formData, - description: formData.description ? formData.description : "", - description_html: formData.description_html ? formData.description_html : "

    ", + assignees_list: formData.assignees, + labels_list: formData.labels, + description: formData.description ?? "", + description_html: formData.description_html ?? "

    ", }; if (!data) await createIssue(payload); @@ -221,7 +204,7 @@ export const CreateUpdateIssueModal: React.FC = ({ return ( - + {}}> = ({ > = ({ {issue.project_detail?.identifier}-{issue.sequence_id} )} - {issue.name} + + {issue.name} +
    diff --git a/apps/app/components/issues/select/assignee.tsx b/apps/app/components/issues/select/assignee.tsx index 8aaf4cb40..414ec2e9a 100644 --- a/apps/app/components/issues/select/assignee.tsx +++ b/apps/app/components/issues/select/assignee.tsx @@ -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 = ({ user }) => { - if (!user) return <>; - - if (user.member.avatar && user.member.avatar !== "") { - return ( -
    - avatar -
    - ); - } else - return ( -
    - {user.member.first_name && user.member.first_name !== "" - ? user.member.first_name.charAt(0) - : user.member.email.charAt(0)} -
    - ); -}; - export const IssueAssigneeSelect: FC = ({ projectId, value = [], @@ -93,22 +61,10 @@ export const IssueAssigneeSelect: FC = ({ > {({ open }: any) => ( <> - Assignees - - - - {Array.isArray(value) - ? value - .map((v) => options?.find((option) => option.value === v)?.display) - .join(", ") || "Assignees" - : options?.find((option) => option.value === value)?.display || "Assignees"} - + +
    + {value && Array.isArray(value) ? : null} +
    = ({ 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 && ( <> - p.member.id === option.value)} + p.member.id === option.value)?.member} /> {option.display} diff --git a/apps/app/components/issues/select/label.tsx b/apps/app/components/issues/select/label.tsx index 2d4e5a179..2b810b30a 100644 --- a/apps/app/components/issues/select/label.tsx +++ b/apps/app/components/issues/select/label.tsx @@ -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>; value: string[]; onChange: (value: string[]) => void; projectId: string; }; -const defaultValues: Partial = { - name: "", -}; - -export const IssueLabelSelect: React.FC = ({ value, onChange, projectId }) => { +export const IssueLabelSelect: React.FC = ({ 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( + const { data: issueLabels } = useSWR( 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({ defaultValues }); - - useEffect(() => { - isOpen && setFocus("name"); - }, [isOpen, setFocus]); - const filteredOptions = query === "" ? issueLabels @@ -133,7 +102,8 @@ export const IssueLabelSelect: React.FC = ({ value, onChange, projectId } {label.name} @@ -159,7 +129,7 @@ export const IssueLabelSelect: React.FC = ({ value, onChange, projectId } {child.name} @@ -175,48 +145,14 @@ export const IssueLabelSelect: React.FC = ({ value, onChange, projectId } ) : (

    Loading...

    )} - {/*
    - {isOpen ? ( -
    - - - -
    - ) : ( - - )} -
    */} +
    diff --git a/apps/app/components/issues/sidebar-select/assignee.tsx b/apps/app/components/issues/sidebar-select/assignee.tsx index 369d03368..2d8088059 100644 --- a/apps/app/components/issues/sidebar-select/assignee.tsx +++ b/apps/app/components/issues/sidebar-select/assignee.tsx @@ -66,17 +66,11 @@ export const SidebarAssigneeSelect: React.FC = ({ control, submitChanges, isNotAllowed ? "cursor-not-allowed" : "cursor-pointer" } items-center gap-1 text-xs`} > - -
    - {value && Array.isArray(value) ? ( - - ) : null} -
    -
    +
    + {value && Array.isArray(value) ? ( + + ) : null} +
    = ({ control, submitChanges, - `${ - 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} diff --git a/apps/app/components/issues/sidebar-select/blocked.tsx b/apps/app/components/issues/sidebar-select/blocked.tsx index 393d90dcd..8b802bdd3 100644 --- a/apps/app/components/issues/sidebar-select/blocked.tsx +++ b/apps/app/components/issues/sidebar-select/blocked.tsx @@ -127,14 +127,14 @@ export const SidebarBlockedSelect: React.FC = ({ > i.id === issue)?.id + issues?.find((i) => i.id === issue)?.id }`} > - {`${ - 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 + }`} @@ -243,8 +243,8 @@ export const SidebarBlockedSelect: React.FC = ({ /> { - issues?.results.find((i) => i.id === issue.id) - ?.project_detail?.identifier + issues?.find((i) => i.id === issue.id)?.project_detail + ?.identifier } -{issue.sequence_id} diff --git a/apps/app/components/issues/sidebar-select/blocker.tsx b/apps/app/components/issues/sidebar-select/blocker.tsx index c67e0a8bf..3d343260c 100644 --- a/apps/app/components/issues/sidebar-select/blocker.tsx +++ b/apps/app/components/issues/sidebar-select/blocker.tsx @@ -119,14 +119,14 @@ export const SidebarBlockerSelect: React.FC = ({ > i.id === issue)?.id + issues?.find((i) => i.id === issue)?.id }`} > - {`${ - 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 + }`} = ({ /> { - issues?.results.find((i) => i.id === issue.id) - ?.project_detail?.identifier + issues?.find((i) => i.id === issue.id)?.project_detail + ?.identifier } -{issue.sequence_id} diff --git a/apps/app/components/issues/sidebar-select/cycle.tsx b/apps/app/components/issues/sidebar-select/cycle.tsx index 353bc5121..b1243fe98 100644 --- a/apps/app/components/issues/sidebar-select/cycle.tsx +++ b/apps/app/components/issues/sidebar-select/cycle.tsx @@ -82,14 +82,14 @@ export const SidebarCycleSelect: React.FC = ({ {cycles ? ( cycles.length > 0 ? ( <> - - None - {cycles.map((option) => ( {option.name} ))} + + None + ) : (
    No cycles found
    diff --git a/apps/app/components/issues/sidebar-select/module.tsx b/apps/app/components/issues/sidebar-select/module.tsx index e57688887..44bef4d62 100644 --- a/apps/app/components/issues/sidebar-select/module.tsx +++ b/apps/app/components/issues/sidebar-select/module.tsx @@ -81,14 +81,14 @@ export const SidebarModuleSelect: React.FC = ({ {modules ? ( modules.length > 0 ? ( <> - - None - {modules.map((option) => ( {option.name} ))} + + None + ) : (
    No modules found
    diff --git a/apps/app/components/issues/sidebar-select/parent.tsx b/apps/app/components/issues/sidebar-select/parent.tsx index 1af86c359..f2458a53e 100644 --- a/apps/app/components/issues/sidebar-select/parent.tsx +++ b/apps/app/components/issues/sidebar-select/parent.tsx @@ -84,9 +84,9 @@ export const SidebarParentSelect: React.FC = ({ 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"}
    diff --git a/apps/app/components/issues/sidebar.tsx b/apps/app/components/issues/sidebar.tsx index c321f0a31..24a180bb6 100644 --- a/apps/app/components/issues/sidebar.tsx +++ b/apps/app/components/issues/sidebar.tsx @@ -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 = ({ }) => { 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 = ({ ? 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( 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 = ({ 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 = ({ (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 = ({ [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 = { + 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( + 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 ( <> + setLinkModal(false)} + onFormSubmit={handleCreateLink} + /> setDeleteIssueModal(false)} isOpen={deleteIssueModal} @@ -216,7 +271,7 @@ export const IssueDetailsSidebar: React.FC = ({ 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 = ({ /> i.id !== issueDetail?.id) ?? []} + issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []} watch={watchIssue} userAuth={userAuth} /> 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 = ({ />
    -
    +
    @@ -318,7 +373,7 @@ export const IssueDetailsSidebar: React.FC = ({ > {label.name} @@ -380,7 +435,10 @@ export const IssueDetailsSidebar: React.FC = ({ {label.name} @@ -407,7 +465,7 @@ export const IssueDetailsSidebar: React.FC = ({ {child.name} @@ -431,24 +489,25 @@ export const IssueDetailsSidebar: React.FC = ({ )} /> - + {!isNotAllowed && ( + + )}
    @@ -465,7 +524,7 @@ export const IssueDetailsSidebar: React.FC = ({ )} @@ -517,6 +576,29 @@ export const IssueDetailsSidebar: React.FC = ({ )}
    +
    +
    +

    Links

    + {!isNotAllowed && ( + + )} +
    +
    + {issueDetail?.issue_link && issueDetail.issue_link.length > 0 ? ( + + ) : null} +
    +
    ); diff --git a/apps/app/components/issues/sub-issues-list-modal.tsx b/apps/app/components/issues/sub-issues-list-modal.tsx deleted file mode 100644 index ac3bc7597..000000000 --- a/apps/app/components/issues/sub-issues-list-modal.tsx +++ /dev/null @@ -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 = ({ 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( - 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( - 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 ( - setQuery("")} appear> - - -
    - - -
    - - - -
    -
    - - - {filteredIssues.length > 0 && ( - <> -
  • - {query === "" && ( -

    - Issues -

    - )} -
      - {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 ( - - `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(); - }} - > - - - {issue.project_detail.identifier}-{issue.sequence_id} - - {issue.name} - - ); - })} -
    -
  • - - )} -
    - - {query !== "" && filteredIssues.length === 0 && ( -
    -
    - )} -
    -
    -
    -
    -
    -
    - ); -}; diff --git a/apps/app/components/issues/sub-issues-list.tsx b/apps/app/components/issues/sub-issues-list.tsx index a903f3b62..4f9b1f3fc 100644 --- a/apps/app/components/issues/sub-issues-list.tsx +++ b/apps/app/components/issues/sub-issues-list.tsx @@ -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 = ({ - issues = [], - handleSubIssueRemove, - parentIssue, - workspaceSlug, - projectId, - userAuth, -}) => { +export const SubIssuesList: FC = ({ parentIssue, userAuth }) => { // states - const [isIssueModalActive, setIssueModalActive] = useState(false); + const [createIssueModal, setCreateIssueModal] = useState(false); const [subIssuesListModal, setSubIssuesListModal] = useState(false); const [preloadedData, setPreloadedData] = useState | null>(null); - const openIssueModal = () => { - setIssueModalActive(true); + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const { data: subIssues } = useSWR( + 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( + 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( + 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( + 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( + 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 = ({ return ( <> setCreateIssueModal(false)} /> - setSubIssuesListModal(false)} - parent={parentIssue} + issues={ + issues?.filter( + (i) => + (i.parent === "" || i.parent === null) && + i.id !== parentIssue?.id && + i.id !== parentIssue?.parent + ) ?? [] + } + handleOnSubmit={addAsSubIssue} /> - - {({ open }) => ( - <> -
    - - - Sub-issues {issues.length} - - {open && !isNotAllowed ? ( -
    - - - - { - setSubIssuesListModal(true); - }} + {subIssues && subIssues.length > 0 ? ( + + {({ open }) => ( + <> +
    + + + Sub-issues {subIssues.length} + + {open && !isNotAllowed ? ( +
    +
    - ) : null} -
    - - - {issues.map((issue) => ( -
    - - - - - {issue.project_detail.identifier}-{issue.sequence_id} - - {issue.name} - - - {!isNotAllowed && ( -
    - - handleSubIssueRemove(issue.id)}> - Remove as sub-issue - - -
    - )} + + Create new + + + + setSubIssuesListModal(true)}> + Add an existing issue + +
    - ))} -
    -
    - - )} -
    + ) : null} +
    + + + {subIssues.map((issue) => ( +
    + + + + + {issue.project_detail.identifier}-{issue.sequence_id} + + {issue.name} + + + {!isNotAllowed && ( + + )} +
    + ))} +
    +
    + + )} + + ) : ( + !isNotAllowed && ( + + + Add sub-issue + + } + optionsPosition="left" + noBorder + > + Create new + setSubIssuesListModal(true)}> + Add an existing issue + + + ) + )} ); }; diff --git a/apps/app/components/issues/view-select/assignee.tsx b/apps/app/components/issues/view-select/assignee.tsx index 54d667841..daae990b8 100644 --- a/apps/app/components/issues/view-select/assignee.tsx +++ b/apps/app/components/issues/view-select/assignee.tsx @@ -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 = ({ {({ open }) => (
    -
    0 + ? issue.assignee_details + .map((assingee) => + assingee.first_name !== "" ? assingee.first_name : assingee.email + ) + .toString() + : "No Assignee" + } > - -
    +
    + +
    +
    = ({ issue, partialUpdateIssue, isNotAllowed }) => ( -
    - - partialUpdateIssue({ - target_date: val, - }) - } - className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"} - disabled={isNotAllowed} - /> -
    + +
    + + partialUpdateIssue({ + target_date: val, + }) + } + className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"} + disabled={isNotAllowed} + /> +
    +
    ); diff --git a/apps/app/components/issues/view-select/priority.tsx b/apps/app/components/issues/view-select/priority.tsx index 9f55937a2..5e4dae007 100644 --- a/apps/app/components/issues/view-select/priority.tsx +++ b/apps/app/components/issues/view-select/priority.tsx @@ -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 = ({ position = "right", isNotAllowed, }) => ( - { - partialUpdateIssue({ priority: data }); - }} - className="group relative flex-shrink-0" - disabled={isNotAllowed} - > - {({ open }) => ( -
    - + + {getPriorityIcon( issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None", "text-sm" )} - - - - - {PRIORITIES?.map((priority) => ( - - `${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"} - - ))} - - -
    - )} -
    + + + } + 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) => ( + + <> + {getPriorityIcon(priority, "text-sm")} + {priority ?? "None"} + + + ))} + ); diff --git a/apps/app/components/issues/view-select/state.tsx b/apps/app/components/issues/view-select/state.tsx index 67b5cd9b9..0f516f2c9 100644 --- a/apps/app/components/issues/view-select/state.tsx +++ b/apps/app/components/issues/view-select/state.tsx @@ -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 = ({ issue, partialUpdateIssue, - position, + position = "right", isNotAllowed, }) => { const router = useRouter(); @@ -48,7 +48,16 @@ export const ViewStateSelect: React.FC = ({ backgroundColor: states?.find((s) => s.id === issue.state)?.color, }} /> - {addSpaceIfCamelCase(states?.find((s) => s.id === issue.state)?.name ?? "")} + s.id === issue.state)?.name ?? "" + )} + > + + {addSpaceIfCamelCase(states?.find((s) => s.id === issue.state)?.name ?? "")} + + } value={issue.state} diff --git a/apps/app/components/labels/create-label-modal.tsx b/apps/app/components/labels/create-label-modal.tsx new file mode 100644 index 000000000..58967d6d4 --- /dev/null +++ b/apps/app/components/labels/create-label-modal.tsx @@ -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 = { + name: "", + color: "#000000", +}; + +export const CreateLabelModal: React.FC = ({ isOpen, projectId, handleClose }) => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { + register, + formState: { errors, isSubmitting }, + handleSubmit, + watch, + control, + reset, + setError, + } = useForm({ + 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( + PROJECT_ISSUE_LABELS(projectId), + (prevData) => [res, ...(prevData ?? [])], + false + ); + onClose(); + }) + .catch((error) => { + console.log(error); + }); + }; + + return ( + + + +
    + + +
    +
    + + +
    +
    + + Create Label + +
    + + {({ open }) => ( + <> + + Color + {watch("color") && watch("color") !== "" && ( + + )} + + + + + ( + onChange(value.hex)} + /> + )} + /> + + + + )} + + +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/app/components/labels/create-update-label-inline.tsx b/apps/app/components/labels/create-update-label-inline.tsx index 3b7fe614c..6c61d6d5a 100644 --- a/apps/app/components/labels/create-update-label-inline.tsx +++ b/apps/app/components/labels/create-update-label-inline.tsx @@ -100,7 +100,10 @@ export const CreateUpdateLabelInline: React.FC = ({ 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 = ({ )} diff --git a/apps/app/components/labels/index.ts b/apps/app/components/labels/index.ts index d407cd074..db02d29f0 100644 --- a/apps/app/components/labels/index.ts +++ b/apps/app/components/labels/index.ts @@ -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"; diff --git a/apps/app/components/labels/single-label.tsx b/apps/app/components/labels/single-label.tsx index 927a30d5a..9c311518e 100644 --- a/apps/app/components/labels/single-label.tsx +++ b/apps/app/components/labels/single-label.tsx @@ -24,7 +24,7 @@ export const SingleLabel: React.FC = ({
    {label.name}
    diff --git a/apps/app/components/modules/delete-module-modal.tsx b/apps/app/components/modules/delete-module-modal.tsx index 317f49a68..05ed67089 100644 --- a/apps/app/components/modules/delete-module-modal.tsx +++ b/apps/app/components/modules/delete-module-modal.tsx @@ -65,10 +65,6 @@ export const DeleteModuleModal: React.FC = ({ isOpen, setIsOpen, data }) }); }; - useEffect(() => { - data && setIsOpen(true); - }, [data, setIsOpen]); - return ( ) => void; + handleFormSubmit: (values: Partial) => Promise; handleClose: () => void; status: boolean; + data?: IModule; }; const defaultValues: Partial = { @@ -21,7 +24,7 @@ const defaultValues: Partial = { members_list: [], }; -export const ModuleForm: React.FC = ({ handleFormSubmit, handleClose, status }) => { +export const ModuleForm: React.FC = ({ handleFormSubmit, handleClose, status, data }) => { const { register, formState: { errors, isSubmitting }, @@ -40,6 +43,13 @@ export const ModuleForm: React.FC = ({ handleFormSubmit, handleClose, sta }); }; + useEffect(() => { + reset({ + ...defaultValues, + ...data, + }); + }, [data, reset]); + return (
    diff --git a/apps/app/components/modules/index.ts b/apps/app/components/modules/index.ts index 6f3121292..5ba7ea47e 100644 --- a/apps/app/components/modules/index.ts +++ b/apps/app/components/modules/index.ts @@ -3,6 +3,5 @@ export * from "./sidebar-select"; export * from "./delete-module-modal"; export * from "./form"; export * from "./modal"; -export * from "./module-link-modal"; export * from "./sidebar"; export * from "./single-module-card"; diff --git a/apps/app/components/modules/modal.tsx b/apps/app/components/modules/modal.tsx index 22f9a7e49..7acf81339 100644 --- a/apps/app/components/modules/modal.tsx +++ b/apps/app/components/modules/modal.tsx @@ -14,8 +14,6 @@ import { ModuleForm } from "components/modules"; import modulesService from "services/modules.service"; // hooks import useToast from "hooks/use-toast"; -// helpers -import { renderDateFormat } from "helpers/date-time.helper"; // types import type { IModule } from "types"; // fetch-keys @@ -46,7 +44,7 @@ export const CreateUpdateModuleModal: React.FC = ({ isOpen, setIsOpen, da reset(defaultValues); }; - const { reset, setError } = useForm({ + const { reset } = useForm({ defaultValues, }); @@ -58,16 +56,16 @@ export const CreateUpdateModuleModal: React.FC = ({ isOpen, setIsOpen, da handleClose(); setToastAlert({ - title: "Success", type: "success", - message: "Module created successfully", + title: "Success!", + message: "Module created successfully.", }); }) - .catch((err) => { - Object.keys(err).map((key) => { - setError(key as keyof typeof defaultValues, { - message: err[key].join(", "), - }); + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Module could not be created. Please try again.", }); }); }; @@ -92,16 +90,16 @@ export const CreateUpdateModuleModal: React.FC = ({ isOpen, setIsOpen, da handleClose(); setToastAlert({ - title: "Success", type: "success", - message: "Module updated successfully", + title: "Success!", + message: "Module updated successfully.", }); }) - .catch((err) => { - Object.keys(err).map((key) => { - setError(key as keyof typeof defaultValues, { - message: err[key].join(", "), - }); + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Module could not be updated. Please try again.", }); }); }; @@ -117,15 +115,6 @@ export const CreateUpdateModuleModal: React.FC = ({ isOpen, setIsOpen, da else await updateModule(payload); }; - useEffect(() => { - if (data) { - setIsOpen(true); - reset(data); - } else { - reset(defaultValues); - } - }, [data, setIsOpen, reset]); - return ( @@ -157,6 +146,7 @@ export const CreateUpdateModuleModal: React.FC = ({ isOpen, setIsOpen, da handleFormSubmit={handleFormSubmit} handleClose={handleClose} status={data ? true : false} + data={data} /> diff --git a/apps/app/components/modules/sidebar.tsx b/apps/app/components/modules/sidebar.tsx index eb563fe74..57ec33a96 100644 --- a/apps/app/components/modules/sidebar.tsx +++ b/apps/app/components/modules/sidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -13,36 +13,35 @@ import { ChartPieIcon, LinkIcon, PlusIcon, + Squares2X2Icon, TrashIcon, } from "@heroicons/react/24/outline"; -// progress-bar -import { CircularProgressbar } from "react-circular-progressbar"; + +import { Popover, Transition } from "@headlessui/react"; +import DatePicker from "react-datepicker"; + // services import modulesService from "services/modules.service"; // hooks import useToast from "hooks/use-toast"; // components -import { - ModuleLinkModal, - SidebarLeadSelect, - SidebarMembersSelect, - SidebarStatusSelect, -} from "components/modules"; +import { LinkModal, LinksList, SidebarProgressStats } from "components/core"; +import { DeleteModuleModal, SidebarLeadSelect, SidebarMembersSelect } from "components/modules"; +import ProgressChart from "components/core/sidebar/progress-chart"; -import "react-circular-progressbar/dist/styles.css"; // components -import { SidebarProgressStats } from "components/core"; // ui -import { CustomDatePicker, Loader } from "components/ui"; +import { CustomSelect, Loader, ProgressBar } from "components/ui"; // helpers -import { timeAgo } from "helpers/date-time.helper"; +import { renderDateFormat, renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper"; import { copyTextToClipboard } from "helpers/string.helper"; import { groupBy } from "helpers/array.helper"; // types -import { IIssue, IModule, ModuleIssueResponse } from "types"; +import { IIssue, IModule, ModuleIssueResponse, ModuleLink, UserAuth } from "types"; // fetch-keys import { MODULE_DETAILS } from "constants/fetch-keys"; -import ProgressChart from "components/core/sidebar/progress-chart"; +// constant +import { MODULE_STATUS } from "constants/module"; const defaultValues: Partial = { lead: "", @@ -57,7 +56,7 @@ type Props = { module?: IModule; isOpen: boolean; moduleIssues: ModuleIssueResponse[] | undefined; - handleDeleteModule: () => void; + userAuth: UserAuth; }; export const ModuleDetailsSidebar: React.FC = ({ @@ -65,9 +64,12 @@ export const ModuleDetailsSidebar: React.FC = ({ module, isOpen, moduleIssues, - handleDeleteModule, + userAuth, }) => { + const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [moduleLinkModal, setModuleLinkModal] = useState(false); + const [startDateRange, setStartDateRange] = useState(new Date()); + const [endDateRange, setEndDateRange] = useState(null); const router = useRouter(); const { workspaceSlug, projectId, moduleId } = router.query; @@ -110,6 +112,36 @@ export const ModuleDetailsSidebar: React.FC = ({ }); }; + const handleCreateLink = async (formData: ModuleLink) => { + if (!workspaceSlug || !projectId || !moduleId) return; + + const previousLinks = module?.link_module.map((l) => ({ title: l.title, url: l.url })); + + const payload: Partial = { + 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)); + }) + .catch((err) => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't create the link. Please try again.", + }); + }); + }; + + const handleDeleteLink = (linkId: string) => { + if (!module) return; + + const updatedLinks = module.link_module.filter((l) => l.id !== linkId); + submitChanges({ links_list: updatedLinks }); + }; + useEffect(() => { if (module) reset({ @@ -120,12 +152,18 @@ export const ModuleDetailsSidebar: React.FC = ({ const isStartValid = new Date(`${module?.start_date}`) <= new Date(); const isEndValid = new Date(`${module?.target_date}`) >= new Date(`${module?.start_date}`); + return ( <> - setModuleLinkModal(false)} - module={module} + onFormSubmit={handleCreateLink} + /> +
    = ({ > {module ? ( <> +
    +
    + ( + + + {watch("status")} + + } + value={value} + onChange={(value: any) => { + submitChanges({ status: value }); + }} + > + {MODULE_STATUS.map((option) => ( + + {option.label} + + ))} + + )} + /> +
    +
    + + {({ open }) => ( + <> + + + + {renderShortNumericDateFormat(`${module?.start_date}`) + ? renderShortNumericDateFormat(`${module?.start_date}`) + : "N/A"} + + + + + + { + submitChanges({ + start_date: renderDateFormat(date), + }); + setStartDateRange(date); + }} + selectsStart + startDate={startDateRange} + endDate={endDateRange} + inline + /> + + + + )} + + + {({ open }) => ( + <> + + + -{" "} + {renderShortNumericDateFormat(`${module?.target_date}`) + ? renderShortNumericDateFormat(`${module?.target_date}`) + : "N/A"} + + + + + + { + submitChanges({ + target_date: renderDateFormat(date), + }); + setEndDateRange(date); + }} + selectsEnd + startDate={startDateRange} + endDate={endDateRange} + minDate={startDateRange} + inline + /> + + + + )} + +
    +

    {module.name}

    @@ -163,7 +318,7 @@ export const ModuleDetailsSidebar: React.FC = ({ @@ -185,10 +340,9 @@ export const ModuleDetailsSidebar: React.FC = ({
    -
    @@ -196,59 +350,6 @@ export const ModuleDetailsSidebar: React.FC = ({
    -
    -
    -
    - -

    Start date

    -
    -
    - ( - - submitChanges({ - start_date: val, - }) - } - /> - )} - /> -
    -
    -
    -
    - -

    End date

    -
    -
    - ( - - submitChanges({ - target_date: val, - }) - } - /> - )} - /> -
    -
    -
    -
    - -

    Links

    @@ -261,40 +362,13 @@ export const ModuleDetailsSidebar: React.FC = ({
    - {module.link_module && module.link_module.length > 0 - ? module.link_module.map((link) => ( -
    -
    - -
    - - -
    - -
    -
    -
    {link.title}
    -

    - Added {timeAgo(link.created_at)} ago by{" "} - {link.created_by_detail.email} -

    -
    -
    - -
    - )) - : null} + {module.link_module && module.link_module.length > 0 ? ( + + ) : null}
    diff --git a/apps/app/components/modules/single-module-card.tsx b/apps/app/components/modules/single-module-card.tsx index 6bd59d14f..954a6e20a 100644 --- a/apps/app/components/modules/single-module-card.tsx +++ b/apps/app/components/modules/single-module-card.tsx @@ -1,152 +1,115 @@ import React, { useState } from "react"; import Link from "next/link"; -import Image from "next/image"; import { useRouter } from "next/router"; // components import { DeleteModuleModal } from "components/modules"; +// ui +import { AssigneesList, Avatar, CustomMenu } from "components/ui"; // icons import { CalendarDaysIcon, TrashIcon } from "@heroicons/react/24/outline"; -import User from "public/user.png"; // helpers import { renderShortNumericDateFormat } from "helpers/date-time.helper"; // types -import { IModule, SelectModuleType } from "types"; +import { IModule } from "types"; // common import { MODULE_STATUS } from "constants/module"; +import useToast from "hooks/use-toast"; +import { copyTextToClipboard } from "helpers/string.helper"; type Props = { module: IModule; + handleEditModule: () => void; }; -export const SingleModuleCard: React.FC = ({ module }) => { - const router = useRouter(); - const { workspaceSlug } = router.query; +export const SingleModuleCard: React.FC = ({ module, handleEditModule }) => { const [moduleDeleteModal, setModuleDeleteModal] = useState(false); - const [selectedModuleForDelete, setSelectedModuleForDelete] = useState(); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + const { setToastAlert } = useToast(); + const handleDeleteModule = () => { if (!module) return; - setSelectedModuleForDelete({ ...module, actionType: "delete" }); setModuleDeleteModal(true); }; + const handleCopyText = () => { + const originURL = + typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/modules/${module.id}`) + .then(() => { + setToastAlert({ + type: "success", + title: "Module link copied to clipboard", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Some error occurred", + }); + }); + }; + return ( -
    -
    - -
    + <> - - - {module.name} -
    -
    -
    LEAD
    -
    - {module.lead ? ( - module.lead_detail?.avatar && module.lead_detail.avatar !== "" ? ( -
    - {module.lead_detail.first_name} -
    - ) : ( -
    - {module.lead_detail?.first_name && module.lead_detail.first_name !== "" - ? module.lead_detail.first_name.charAt(0) - : module.lead_detail?.email.charAt(0)} -
    - ) - ) : ( - "N/A" - )} +
    +
    + + Copy module link + Edit module + + Delete module permanently + + +
    + +
    + {module.name} +
    +
    +
    LEAD
    +
    + +
    +
    +
    +
    MEMBERS
    +
    + +
    +
    +
    +
    END DATE
    +
    + + {module.target_date ? renderShortNumericDateFormat(module?.target_date) : "N/A"} +
    +
    +
    +
    STATUS
    +
    + s.value === module.status)?.color, + }} + /> + {module.status} +
    -
    -
    MEMBERS
    -
    - {module.members && module.members.length > 0 ? ( - module?.members_detail?.map((member, index: number) => ( -
    - {member?.avatar && member.avatar !== "" ? ( -
    - {member?.first_name} -
    - ) : ( -
    - {member?.first_name && member.first_name !== "" - ? member.first_name.charAt(0) - : member?.email?.charAt(0)} -
    - )} -
    - )) - ) : ( -
    - No user -
    - )} -
    -
    -
    -
    END DATE
    -
    - - {renderShortNumericDateFormat(module.target_date ?? "")} -
    -
    -
    -
    STATUS
    -
    - s.value === module.status)?.color, - }} - /> - {module.status} -
    -
    -
    - - -
    + + +
    + ); }; diff --git a/apps/app/components/onboarding/invite-members.tsx b/apps/app/components/onboarding/invite-members.tsx index efcdd650c..e6fc54e37 100644 --- a/apps/app/components/onboarding/invite-members.tsx +++ b/apps/app/components/onboarding/invite-members.tsx @@ -4,8 +4,7 @@ import useToast from "hooks/use-toast"; import workspaceService from "services/workspace.service"; import { IUser } from "types"; // ui components -import MultiInput from "components/ui/multi-input"; -import OutlineButton from "components/ui/outline-button"; +import { MultiInput, OutlineButton } from "components/ui"; type Props = { setStep: React.Dispatch>; diff --git a/apps/app/components/popup/index.tsx b/apps/app/components/popup/index.tsx new file mode 100644 index 000000000..e97d39493 --- /dev/null +++ b/apps/app/components/popup/index.tsx @@ -0,0 +1,41 @@ +import { useRouter } from "next/router"; +import React, { useRef } from "react"; + +const OAuthPopUp = ({ workspaceSlug, integration }: any) => { + const popup = useRef(); + + const router = useRouter(); + + const checkPopup = () => { + const check = setInterval(() => { + if (!popup || popup.current.closed || popup.current.closed === undefined) { + clearInterval(check); + } + }, 1000); + }; + + const openPopup = () => { + const width = 600, + height = 600; + const left = window.innerWidth / 2 - width / 2; + const top = window.innerHeight / 2 - height / 2; + const url = `https://github.com/apps/${process.env.NEXT_PUBLIC_GITHUB_APP_NAME}/installations/new?state=${workspaceSlug}`; + + return window.open(url, "", `width=${width}, height=${height}, top=${top}, left=${left}`); + }; + + const startAuth = () => { + popup.current = openPopup(); + checkPopup(); + }; + + return ( + <> +
    + +
    + + ); +}; + +export default OAuthPopUp; diff --git a/apps/app/components/project/cycles/create-update-cycle-modal.tsx b/apps/app/components/project/cycles/create-update-cycle-modal.tsx deleted file mode 100644 index 30348e587..000000000 --- a/apps/app/components/project/cycles/create-update-cycle-modal.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import React, { useEffect } from "react"; - -import { useRouter } from "next/router"; - -import { mutate } from "swr"; - -// react hook form -import { Controller, useForm } from "react-hook-form"; -// headless ui -import { Dialog, Transition } from "@headlessui/react"; -// services -import cycleService from "services/cycles.service"; -// hooks -import useToast from "hooks/use-toast"; -// ui -import { Button, Input, TextArea, CustomSelect, CustomDatePicker } from "components/ui"; -// common -import { renderDateFormat } from "helpers/date-time.helper"; -// types -import type { ICycle } from "types"; -// fetch keys -import { CYCLE_LIST } from "constants/fetch-keys"; - -type Props = { - isOpen: boolean; - setIsOpen: React.Dispatch>; - projectId: string; - data?: ICycle; -}; - -const defaultValues: Partial = { - name: "", - description: "", - status: "draft", - start_date: null, - end_date: null, -}; - -const CreateUpdateCycleModal: React.FC = ({ isOpen, setIsOpen, data, projectId }) => { - const router = useRouter(); - const { workspaceSlug } = router.query; - - const { setToastAlert } = useToast(); - - const { - register, - formState: { errors, isSubmitting }, - handleSubmit, - control, - reset, - setError, - } = useForm({ - defaultValues, - }); - - useEffect(() => { - if (data) { - setIsOpen(true); - reset(data); - } else { - reset(defaultValues); - } - }, [data, setIsOpen, reset]); - - const onSubmit = async (formData: ICycle) => { - if (!workspaceSlug) return; - const payload = { - ...formData, - start_date: formData.start_date ? renderDateFormat(formData.start_date) : null, - end_date: formData.end_date ? renderDateFormat(formData.end_date) : null, - }; - if (!data) { - await cycleService - .createCycle(workspaceSlug as string, projectId, payload) - .then((res) => { - mutate(CYCLE_LIST(projectId), (prevData) => [res, ...(prevData ?? [])], false); - - handleClose(); - setToastAlert({ - title: "Success", - type: "success", - message: "Cycle created successfully", - }); - }) - .catch((err) => { - Object.keys(err).map((key) => { - setError(key as keyof typeof defaultValues, { - message: err[key].join(", "), - }); - }); - }); - } else { - await cycleService - .updateCycle(workspaceSlug as string, projectId, data.id, payload) - .then((res) => { - mutate(CYCLE_LIST(projectId)); - handleClose(); - - setToastAlert({ - title: "Success", - type: "success", - message: "Cycle updated successfully", - }); - }) - .catch((err) => { - Object.keys(err).map((key) => { - setError(key as keyof typeof defaultValues, { - message: err[key].join(", "), - }); - }); - }); - } - }; - - const handleClose = () => { - setIsOpen(false); - reset(defaultValues); - }; - - return ( - - - -
    - - -
    -
    - - - -
    - - {data ? "Update" : "Create"} Cycle - -
    -
    - -
    -
    -