diff --git a/.eslintrc.js b/.eslintrc.js index be1ad0f9da5..463c86901c0 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 4933d309e40..3562ab0b34c 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 00000000000..094d628e3bb --- /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 0595770fa7a..9a6904b55b9 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 6343c740e21..123544571e4 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/bin/takeoff b/apiserver/bin/takeoff index 8340f16c704..d22eceb6ee9 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 00000000000..b554d2c405e --- /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/issue.py b/apiserver/plane/api/serializers/issue.py index 3add8f965f1..c501a3d94c0 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -24,9 +24,15 @@ 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 @@ -86,6 +92,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 +115,7 @@ def create(self, validated_data): 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 +184,24 @@ def create(self, validated_data): 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 +209,7 @@ def update(self, instance, validated_data): 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 +279,25 @@ def update(self, instance, validated_data): 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 +460,12 @@ class Meta: ] +class IssueLinkSerializer(BaseSerializer): + class Meta: + model = IssueLink + fields = "__all__" + + class IssueSerializer(BaseSerializer): project_detail = ProjectSerializer(read_only=True, source="project") state_detail = StateSerializer(read_only=True, source="state") @@ -422,6 +478,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/urls.py b/apiserver/plane/api/urls.py index 98c2e87d286..4af139bf511 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -5,7 +5,6 @@ from plane.api.views import ( # Authentication - SignUpEndpoint, SignInEndpoint, SignOutEndpoint, MagicSignInEndpoint, @@ -95,7 +94,6 @@ path("social-auth/", OauthEndpoint.as_view(), name="oauth"), # Auth path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), - path("sign-up/", SignUpEndpoint.as_view(), name="sign-up"), path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), # Magic Sign In/Up path( diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 1212e0dca0d..4fb565e8dd7 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -64,7 +64,6 @@ from .authentication import ( - SignUpEndpoint, SignInEndpoint, SignOutEndpoint, MagicSignInEndpoint, diff --git a/apiserver/plane/api/views/auth_extended.py b/apiserver/plane/api/views/auth_extended.py index 487d10a2260..56dc091f489 100644 --- a/apiserver/plane/api/views/auth_extended.py +++ b/apiserver/plane/api/views/auth_extended.py @@ -84,7 +84,7 @@ def post(self, request): ) return Response( - {"messgae": "Check your email to reset your password"}, + {"message": "Check your email to reset your password"}, status=status.HTTP_200_OK, ) return Response( diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index ac218837dbc..58d75a04952 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -35,7 +35,7 @@ def get_tokens_for_user(user): ) -class SignUpEndpoint(BaseAPIView): +class SignInEndpoint(BaseAPIView): permission_classes = (AllowAny,) def post(self, request): @@ -62,114 +62,67 @@ def post(self, request): user = User.objects.filter(email=email).first() - if user is not None: - return Response( - {"error": "Email ID is already taken"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - user = User.objects.create(email=email) - user.set_password(password) - - # settings last actives for the user - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() - - serialized_user = UserSerializer(user).data - - access_token, refresh_token = get_tokens_for_user(user) - - data = { - "access_token": access_token, - "refresh_token": refresh_token, - "user": serialized_user, - } - - return Response(data, status=status.HTTP_200_OK) - - except Exception as e: - capture_exception(e) - return Response( - { - "error": "Something went wrong. Please try again later or contact the support team." - }, - status=status.HTTP_400_BAD_REQUEST, - ) + # Sign up Process + if user is None: + user = User.objects.create(email=email, username=uuid.uuid4().hex) + user.set_password(password) + # settings last actives for the user + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() -class SignInEndpoint(BaseAPIView): - permission_classes = (AllowAny,) + serialized_user = UserSerializer(user).data - def post(self, request): - try: - email = request.data.get("email", False) - password = request.data.get("password", False) + access_token, refresh_token = get_tokens_for_user(user) - ## Raise exception if any of the above are missing - if not email or not password: - return Response( - {"error": "Both email and password are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - email = email.strip().lower() - - try: - validate_email(email) - except ValidationError as e: - return Response( - {"error": "Please provide a valid email address."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - user = User.objects.get(email=email) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + "user": serialized_user, + } - if not user.check_password(password): - return Response( - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - status=status.HTTP_403_FORBIDDEN, - ) - if not user.is_active: - return Response( - { - "error": "Your account has been deactivated. Please contact your site administrator." - }, - status=status.HTTP_403_FORBIDDEN, - ) + return Response(data, status=status.HTTP_200_OK) + # Sign in Process + else: + if not user.check_password(password): + return Response( + { + "error": "Sorry, we could not find a user with the provided credentials. Please try again." + }, + status=status.HTTP_403_FORBIDDEN, + ) + if not user.is_active: + return Response( + { + "error": "Your account has been deactivated. Please contact your site administrator." + }, + status=status.HTTP_403_FORBIDDEN, + ) - serialized_user = UserSerializer(user).data + serialized_user = UserSerializer(user).data - # settings last active for the user - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() + # settings last active for the user + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() - access_token, refresh_token = get_tokens_for_user(user) + access_token, refresh_token = get_tokens_for_user(user) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - "user": serialized_user, - } + data = { + "access_token": access_token, + "refresh_token": refresh_token, + "user": serialized_user, + } - return Response(data, status=status.HTTP_200_OK) + return Response(data, status=status.HTTP_200_OK) - except User.DoesNotExist: - return Response( - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - status=status.HTTP_403_FORBIDDEN, - ) except Exception as e: capture_exception(e) return Response( diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 37082e0ec8a..4f7e7473b34 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -39,6 +39,7 @@ IssueBlocker, CycleIssue, ModuleIssue, + IssueLink, ) from plane.bgtasks.issue_activites_task import issue_activity @@ -75,7 +76,6 @@ def perform_update(self, serializer): self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() ) if current_instance is not None: - issue_activity.delay( { "type": "issue.activity", @@ -92,7 +92,6 @@ def perform_update(self, serializer): return super().perform_update(serializer) def get_queryset(self): - return ( super() .get_queryset() @@ -136,6 +135,12 @@ def get_queryset(self): ).prefetch_related("module__members"), ), ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("issue"), + ) + ) ) def grouper(self, issue, group_by): @@ -265,6 +270,12 @@ def get(self, request, slug): queryset=ModuleIssue.objects.select_related("module", "issue"), ), ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("issue"), + ) + ) ) serializer = IssueSerializer(issues, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -277,7 +288,6 @@ def get(self, request, slug): class WorkSpaceIssuesEndpoint(BaseAPIView): - permission_classes = [ WorkSpaceAdminPermission, ] @@ -298,7 +308,6 @@ def get(self, request, slug): class IssueActivityEndpoint(BaseAPIView): - permission_classes = [ ProjectEntityPermission, ] @@ -333,7 +342,6 @@ def get(self, request, slug, project_id, issue_id): class IssueCommentViewSet(BaseViewSet): - serializer_class = IssueCommentSerializer model = IssueComment permission_classes = [ @@ -436,7 +444,6 @@ def list(self, request, slug, project_id): 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 +470,6 @@ def create(self, request, slug, project_id): class LabelViewSet(BaseViewSet): - serializer_class = LabelSerializer model = Label permission_classes = [ @@ -490,14 +496,12 @@ def get_queryset(self): 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 +531,12 @@ def delete(self, request, slug, project_id): 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 diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 2ec6faf1e53..e24477ecd30 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -75,7 +75,6 @@ def get_queryset(self): def create(self, request, slug): try: - workspace = Workspace.objects.get(slug=slug) serializer = ProjectSerializer( @@ -96,6 +95,7 @@ def create(self, request, slug): "color": "#5e6ad2", "sequence": 15000, "group": "backlog", + "default": True, }, { "name": "Todo", @@ -132,6 +132,7 @@ def create(self, request, slug): sequence=state["sequence"], workspace=serializer.instance.workspace, group=state["group"], + default=state.get("default", False), ) for state in states ] @@ -188,7 +189,7 @@ def partial_update(self, request, slug, pk=None): {"name": "The project name is already taken"}, status=status.HTTP_410_GONE, ) - except (Project.DoesNotExist or Workspace.DoesNotExist) as e: + except Project.DoesNotExist or Workspace.DoesNotExist as e: return Response( {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND ) @@ -206,14 +207,12 @@ def partial_update(self, request, slug, pk=None): class InviteProjectEndpoint(BaseAPIView): - permission_classes = [ ProjectBasePermission, ] def post(self, request, slug, project_id): try: - email = request.data.get("email", False) role = request.data.get("role", False) @@ -287,7 +286,6 @@ def post(self, request, slug, project_id): class UserProjectInvitationsViewset(BaseViewSet): - serializer_class = ProjectMemberInviteSerializer model = ProjectMemberInvite @@ -301,7 +299,6 @@ def get_queryset(self): def create(self, request): try: - invitations = request.data.get("invitations") project_invitations = ProjectMemberInvite.objects.filter( pk__in=invitations, accepted=True @@ -331,7 +328,6 @@ def create(self, request): class ProjectMemberViewSet(BaseViewSet): - serializer_class = ProjectMemberSerializer model = ProjectMember permission_classes = [ @@ -356,14 +352,12 @@ def get_queryset(self): class AddMemberToProjectEndpoint(BaseAPIView): - permission_classes = [ ProjectBasePermission, ] def post(self, request, slug, project_id): try: - member_id = request.data.get("member_id", False) role = request.data.get("role", False) @@ -412,13 +406,11 @@ def post(self, request, slug, project_id): class AddTeamToProjectEndpoint(BaseAPIView): - permission_classes = [ ProjectBasePermission, ] def post(self, request, slug, project_id): - try: team_members = TeamMember.objects.filter( workspace__slug=slug, team__in=request.data.get("teams", []) @@ -467,7 +459,6 @@ def post(self, request, slug, project_id): class ProjectMemberInvitationsViewset(BaseViewSet): - serializer_class = ProjectMemberInviteSerializer model = ProjectMemberInvite @@ -489,7 +480,6 @@ def get_queryset(self): class ProjectMemberInviteDetailViewSet(BaseViewSet): - serializer_class = ProjectMemberInviteSerializer model = ProjectMemberInvite @@ -509,14 +499,12 @@ def get_queryset(self): class ProjectIdentifierEndpoint(BaseAPIView): - permission_classes = [ ProjectBasePermission, ] def get(self, request, slug): try: - name = request.GET.get("name", "").strip().upper() if name == "": @@ -541,7 +529,6 @@ def get(self, request, slug): def delete(self, request, slug): try: - name = request.data.get("name", "").strip().upper() if name == "": @@ -616,7 +603,6 @@ def post(self, request, slug): class ProjectUserViewsEndpoint(BaseAPIView): def post(self, request, slug, project_id): try: - project = Project.objects.get(pk=project_id, workspace__slug=slug) project_member = ProjectMember.objects.filter( @@ -655,7 +641,6 @@ def post(self, request, slug, project_id): class ProjectMemberUserEndpoint(BaseAPIView): def get(self, request, slug, project_id): try: - project_member = ProjectMember.objects.get( project_id=project_id, workspace__slug=slug, member=request.user ) diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index 8054b15dd92..4616fcee7f0 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -1,3 +1,12 @@ +# Python imports +from itertools import groupby + +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from sentry_sdk import capture_exception + + # Module imports from . import BaseViewSet from plane.api.serializers import StateSerializer @@ -6,7 +15,6 @@ class StateViewSet(BaseViewSet): - serializer_class = StateSerializer model = State permission_classes = [ @@ -27,3 +35,38 @@ def get_queryset(self): .select_related("workspace") .distinct() ) + + def list(self, request, slug, project_id): + try: + state_dict = dict() + states = StateSerializer(self.get_queryset(), many=True).data + + for key, value in groupby( + sorted(states, key=lambda state: state["group"]), + lambda state: state.get("group"), + ): + state_dict[str(key)] = list(value) + + return Response(state_dict, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def destroy(self, request, slug, project_id, pk): + try: + state = State.objects.get( + pk=pk, project_id=project_id, workspace__slug=slug + ) + + if state.default: + return Response( + {"error": "Default state cannot be deleted"}, status=False + ) + + state.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except State.DoesNotExist: + return Response({"error": "State does not exists"}, status=status.HTTP_404) diff --git a/apiserver/plane/db/migrations/0020_auto_20230214_0118.py b/apiserver/plane/db/migrations/0020_auto_20230214_0118.py new file mode 100644 index 00000000000..19276407821 --- /dev/null +++ b/apiserver/plane/db/migrations/0020_auto_20230214_0118.py @@ -0,0 +1,69 @@ +# Generated by Django 3.2.16 on 2023-02-13 19:48 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0019_auto_20230131_0049'), + ] + + operations = [ + migrations.RenameField( + model_name='label', + old_name='colour', + new_name='color', + ), + migrations.AddField( + model_name='apitoken', + name='workspace', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to='db.workspace'), + ), + migrations.AddField( + model_name='issue', + name='completed_at', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='issue', + name='sort_order', + field=models.FloatField(default=65535), + ), + migrations.AddField( + model_name='project', + name='cycle_view', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='project', + name='module_view', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='state', + name='default', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='issue', + name='description', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name='issue', + name='description_html', + field=models.TextField(blank=True, default='

'), + ), + migrations.AlterField( + model_name='issuecomment', + name='comment_html', + field=models.TextField(blank=True, default='

'), + ), + migrations.AlterField( + model_name='issuecomment', + name='comment_json', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index ef7ad5b8d49..d12578fa1bd 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -23,6 +23,7 @@ IssueAssignee, Label, IssueBlocker, + IssueLink, ) from .asset import FileAsset diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 3331b08329e..d212f756556 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -4,6 +4,7 @@ from django.conf import settings from django.db.models.signals import post_save from django.dispatch import receiver +from django.utils import timezone # Module imports from . import ProjectBaseModel @@ -58,6 +59,7 @@ class Issue(ProjectBaseModel): "db.Label", blank=True, related_name="labels", through="IssueLabel" ) sort_order = models.FloatField(default=65535) + completed_at = models.DateTimeField(null=True) class Meta: verbose_name = "Issue" @@ -81,12 +83,32 @@ def save(self, *args, **kwargs): try: from plane.db.models import State - self.state, created = State.objects.get_or_create( - project=self.project, name="Backlog" - ) + default_state = State.objects.filter( + project=self.project, default=True + ).first() + # if there is no default state assign any random state + if default_state is None: + self.state = State.objects.filter(project=self.project).first() + else: + self.state = default_state except ImportError: pass + else: + try: + from plane.db.models import State + # Get the completed states of the project + completed_states = State.objects.filter( + group="completed", project=self.project + ).values_list("pk", flat=True) + # Check if the current issue state and completed state id are same + if self.state.id in completed_states: + self.completed_at = timezone.now() + else: + self.completed_at = None + + except ImportError: + pass # Strip the html tags using html parser self.description_stripped = ( None @@ -139,6 +161,23 @@ def __str__(self): 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" diff --git a/apiserver/plane/db/models/state.py b/apiserver/plane/db/models/state.py index 2c62879181b..d66ecfa72a0 100644 --- a/apiserver/plane/db/models/state.py +++ b/apiserver/plane/db/models/state.py @@ -23,6 +23,7 @@ class State(ProjectBaseModel): default="backlog", max_length=20, ) + default = models.BooleanField(default=False) def __str__(self): """Return name of the state""" diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index e14c250b498..9d270662eb2 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 4d4af9b77d8..3fa0fae5c74 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 @@ } } +DOCKERIZED = os.environ.get("DOCKERIZED", False) + +if DOCKERIZED: + DATABASES["default"] = dj_database_url.config() CACHES = { "default": { @@ -41,15 +46,16 @@ 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,10 @@ }, } -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") diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index c8390148464..0401a0f0e11 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -33,6 +33,10 @@ 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 @@ # 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 - -# AWS Settings End + DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage" + +else: + MEDIA_URL = "/uploads/" + MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") # Enable Connection Pooling (if desired) @@ -155,7 +170,6 @@ ] -DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage" # Simplified static file serving. STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" @@ -165,16 +179,27 @@ 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,4 @@ } -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") diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index e9ca677dbd2..ffe11a2347f 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -1,28 +1,29 @@ # base requirements -Django==3.2.17 +Django==3.2.18 django-braces==1.15.0 -django-taggit==2.1.0 -psycopg2==2.9.3 -django-oauth-toolkit==2.0.0 -mistune==2.0.3 +django-taggit==3.1.0 +psycopg2==2.9.5 +django-oauth-toolkit==2.2.0 +mistune==2.0.4 djangorestframework==3.14.0 -redis==4.2.2 -django-nested-admin==3.4.0 -django-cors-headers==3.11.0 -whitenoise==6.0.0 -django-allauth==0.50.0 +redis==4.4.2 +django-nested-admin==4.0.2 +django-cors-headers==3.13.0 +whitenoise==6.3.0 +django-allauth==0.52.0 faker==13.4.0 -django-filter==21.1 -jsonmodels==2.5.0 -djangorestframework-simplejwt==5.1.0 -sentry-sdk==1.13.0 -django-s3-storage==0.13.6 +django-filter==22.1 +jsonmodels==2.6.0 +djangorestframework-simplejwt==5.2.2 +sentry-sdk==1.14.0 +django-s3-storage==0.13.11 django-crum==0.7.9 django-guardian==2.4.0 dj_rest_auth==2.2.5 -google-auth==2.9.1 -google-api-python-client==2.55.0 -django-rq==2.5.1 +google-auth==2.16.0 +google-api-python-client==2.75.0 +django-rq==2.6.0 django-redis==5.2.0 -uvicorn==0.20.0 \ No newline at end of file +uvicorn==0.20.0 +channels==4.0.0 \ No newline at end of file diff --git a/apiserver/requirements/local.txt b/apiserver/requirements/local.txt index 238fe63f20d..efd74a071bd 100644 --- a/apiserver/requirements/local.txt +++ b/apiserver/requirements/local.txt @@ -1,3 +1,3 @@ -r base.txt -django-debug-toolbar==3.2.4 \ No newline at end of file +django-debug-toolbar==3.8.1 \ No newline at end of file diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt index 231d3c0a129..2547ce255fa 100644 --- a/apiserver/requirements/production.txt +++ b/apiserver/requirements/production.txt @@ -1,12 +1,12 @@ -r base.txt -dj-database-url==0.5.0 +dj-database-url==1.2.0 gunicorn==20.1.0 -whitenoise==6.0.0 -django-storages==1.12.3 +whitenoise==6.3.0 +django-storages==1.13.2 boto==2.49.0 -django-anymail==8.5 -twilio==7.8.2 -django-debug-toolbar==3.2.4 +django-anymail==9.0 +twilio==7.16.2 +django-debug-toolbar==3.8.1 gevent==22.10.2 psycogreen==1.0.2 \ No newline at end of file diff --git a/apiserver/runtime.txt b/apiserver/runtime.txt index cd6f13073e4..2d4e05157e1 100644 --- a/apiserver/runtime.txt +++ b/apiserver/runtime.txt @@ -1 +1 @@ -python-3.11.1 \ No newline at end of file +python-3.11.2 \ No newline at end of file diff --git a/apiserver/templates/admin/base_site.html b/apiserver/templates/admin/base_site.html index 4fdb5e19b54..fd1d890673b 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/apps/app/.env.example b/apps/app/.env.example new file mode 100644 index 00000000000..5d787806dcb --- /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 64b6ff36bff..c8df607506c 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 00000000000..7b802634ce7 --- /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 be8abf5fa12..11bf98bd47b 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 93298b4e072..db201041ccc 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,30 +42,38 @@ export const EmailCodeForm = ({ onSuccess }: any) => { reValidateMode: "onChange", }); - const onSubmit = ({ email }: EmailCodeFormValues) => { - authenticationService + const isResendDisabled = + resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode; + + const onSubmit = async ({ email }: EmailCodeFormValues) => { + setErrorResendingCode(false); + await authenticationService .emailCode({ email }) .then((res) => { setValue("key", res.key); setCodeSent(true); }) .catch((err) => { - console.log(err); + setErrorResendingCode(true); + setToastAlert({ + title: "Oops!", + type: "error", + message: err?.error, + }); }); }; - const handleSignin = (formData: EmailCodeFormValues) => { - authenticationService + const handleSignin = async (formData: EmailCodeFormValues) => { + await authenticationService .magicSignIn(formData) .then((response) => { 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,13 +82,16 @@ export const EmailCodeForm = ({ onSuccess }: any) => { }); }; + const emailOld = getValues("email"); + + useEffect(() => { + setErrorResendingCode(false); + }, [emailOld]); + return ( <> -
- {codeSent && ( + + {(codeSent || codeResent) && (
@@ -80,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."}

@@ -117,16 +138,59 @@ export const EmailCodeForm = ({ onSuccess }: any) => { error={errors.token} placeholder="Enter code" /> +
)}
- + {codeSent ? ( + + ) : ( + + )}
diff --git a/apps/app/components/breadcrumbs/index.tsx b/apps/app/components/breadcrumbs/index.tsx index 75fd3bfe54e..ccd17fb4e32 100644 --- a/apps/app/components/breadcrumbs/index.tsx +++ b/apps/app/components/breadcrumbs/index.tsx @@ -44,10 +44,10 @@ const BreadcrumbItem: React.FC = ({ title, link, icon }) => ) : ( -
+

{icon} - {title} + {title}

)} diff --git a/apps/app/components/command-palette/index.tsx b/apps/app/components/command-palette/command-pallette.tsx similarity index 93% rename from apps/app/components/command-palette/index.tsx rename to apps/app/components/command-palette/command-pallette.tsx index e6138da948f..4110d0a275c 100644 --- a/apps/app/components/command-palette/index.tsx +++ b/apps/app/components/command-palette/command-pallette.tsx @@ -1,4 +1,3 @@ -// TODO: Refactor this component: into a different file, use this file to export the components import React, { useState, useCallback, useEffect } from "react"; import { useRouter } from "next/router"; @@ -14,12 +13,12 @@ import useTheme from "hooks/use-theme"; import useToast from "hooks/use-toast"; import useUser from "hooks/use-user"; // components -import ShortcutsModal from "components/command-palette/shortcuts"; +import { ShortcutsModal } from "components/command-palette"; import { BulkDeleteIssuesModal } from "components/core"; import { CreateProjectModal } from "components/project"; import { CreateUpdateIssueModal } from "components/issues"; +import { CreateUpdateCycleModal } from "components/cycles"; import { CreateUpdateModuleModal } from "components/modules"; -import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal"; // ui import { Button } from "components/ui"; // icons @@ -36,7 +35,7 @@ import { IIssue } from "types"; // fetch-keys import { USER_ISSUE } from "constants/fetch-keys"; -const CommandPalette: React.FC = () => { +export const CommandPalette: React.FC = () => { const [query, setQuery] = useState(""); const [isPaletteOpen, setIsPaletteOpen] = useState(false); @@ -103,10 +102,10 @@ const CommandPalette: React.FC = () => { !(e.target instanceof HTMLInputElement) && !(e.target as Element).classList?.contains("remirror-editor") ) { - if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setIsPaletteOpen(true); - } else if (e.ctrlKey && (e.key === "c" || e.key === "C")) { + } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") { if (e.altKey) { e.preventDefault(); if (!router.query.issueId) return; @@ -125,26 +124,23 @@ const CommandPalette: React.FC = () => { title: "Some error occurred", }); }); - console.log("URL Copied"); - } else { - console.log("Text copied"); } - } else if (e.key === "c" || e.key === "C") { + } else if (e.key.toLowerCase() === "c") { e.preventDefault(); setIsIssueModalOpen(true); - } else if (e.key === "p" || e.key === "P") { + } else if (e.key.toLowerCase() === "p") { e.preventDefault(); setIsProjectModalOpen(true); - } else if ((e.ctrlKey || e.metaKey) && (e.key === "b" || e.key === "B")) { + } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") { e.preventDefault(); toggleCollapsed(); - } else if (e.key === "h" || e.key === "H") { + } else if (e.key.toLowerCase() === "h") { e.preventDefault(); setIsShortcutsModalOpen(true); - } else if (e.key === "q" || e.key === "Q") { + } else if (e.key.toLowerCase() === "q") { e.preventDefault(); setIsCreateCycleModalOpen(true); - } else if (e.key === "m" || e.key === "M") { + } else if (e.key.toLowerCase() === "m") { e.preventDefault(); setIsCreateModuleModalOpen(true); } else if (e.key === "Delete") { @@ -173,8 +169,7 @@ const CommandPalette: React.FC = () => { <> setIsCreateCycleModalOpen(false)} /> { ); }; - -export default CommandPalette; diff --git a/apps/app/components/command-palette/index.ts b/apps/app/components/command-palette/index.ts new file mode 100644 index 00000000000..542d6921475 --- /dev/null +++ b/apps/app/components/command-palette/index.ts @@ -0,0 +1,2 @@ +export * from "./command-pallette"; +export * from "./shortcuts-modal"; diff --git a/apps/app/components/command-palette/shortcuts.tsx b/apps/app/components/command-palette/shortcuts-modal.tsx similarity index 69% rename from apps/app/components/command-palette/shortcuts.tsx rename to apps/app/components/command-palette/shortcuts-modal.tsx index f5435055c90..42e5770b920 100644 --- a/apps/app/components/command-palette/shortcuts.tsx +++ b/apps/app/components/command-palette/shortcuts-modal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; // headless ui import { Dialog, Transition } from "@headlessui/react"; // icons @@ -15,7 +15,7 @@ const shortcuts = [ { title: "Navigation", shortcuts: [ - { keys: "Ctrl,Cmd,K", description: "To open navigator" }, + { keys: "Ctrl,/,Cmd,K", description: "To open navigator" }, { keys: "↑", description: "Move up" }, { keys: "↓", description: "Move down" }, { keys: "←", description: "Move left" }, @@ -34,22 +34,27 @@ const shortcuts = [ { keys: "Delete", description: "To bulk delete issues" }, { keys: "H", description: "To open shortcuts guide" }, { - keys: "Ctrl,Cmd,Alt,C", + keys: "Ctrl,/,Cmd,Alt,C", description: "To copy issue url when on issue detail page.", }, ], }, ]; -const ShortcutsModal: React.FC = ({ isOpen, setIsOpen }) => { - const [query, setQuery] = useState(""); +const allShortcuts = shortcuts.map((i) => i.shortcuts).flat(1); - const filteredShortcuts = shortcuts.filter((shortcut) => - shortcut.shortcuts.some((item) => item.description.includes(query.trim())) || query === "" +export const ShortcutsModal: React.FC = ({ isOpen, setIsOpen }) => { + const [query, setQuery] = useState(""); + const filteredShortcuts = allShortcuts.filter((shortcut) => + shortcut.description.toLowerCase().includes(query.trim().toLowerCase()) || query === "" ? true : false ); + useEffect(() => { + if (!isOpen) setQuery(""); + }, [isOpen]); + return ( @@ -104,8 +109,40 @@ const ShortcutsModal: React.FC = ({ isOpen, setIsOpen }) => { />
- {filteredShortcuts.length > 0 ? ( - filteredShortcuts.map(({ title, shortcuts }) => ( + {query.trim().length > 0 ? ( + filteredShortcuts.length > 0 ? ( + filteredShortcuts.map((shortcut) => ( +
+
+
+

{shortcut.description}

+
+ {shortcut.keys.split(",").map((key, index) => ( + + + {key} + + + ))} +
+
+
+
+ )) + ) : ( +
+

+ No shortcuts found for{" "} + + {`"`} + {query} + {`"`} + +

+
+ ) + ) : ( + shortcuts.map(({ title, shortcuts }) => (

{title}

@@ -126,17 +163,6 @@ const ShortcutsModal: React.FC = ({ isOpen, setIsOpen }) => {
)) - ) : ( -
-

- No shortcuts found for{" "} - - {`"`} - {query} - {`"`} - -

-
)}
@@ -150,5 +176,3 @@ const ShortcutsModal: React.FC = ({ isOpen, setIsOpen }) => { ); }; - -export default ShortcutsModal; diff --git a/apps/app/components/core/board-view/all-boards.tsx b/apps/app/components/core/board-view/all-boards.tsx index 9aa29e7c48e..f5b03267ccf 100644 --- a/apps/app/components/core/board-view/all-boards.tsx +++ b/apps/app/components/core/board-view/all-boards.tsx @@ -1,5 +1,3 @@ -// react-beautiful-dnd -import { DragDropContext, DropResult } from "react-beautiful-dnd"; // hooks import useIssueView from "hooks/use-issue-view"; // components @@ -13,9 +11,11 @@ type Props = { states: IState[] | undefined; members: IProjectMember[] | undefined; addIssueToState: (groupTitle: string, stateId: string | null) => void; + handleEditIssue: (issue: IIssue) => void; openIssuesListModal?: (() => void) | null; handleDeleteIssue: (issue: IIssue) => void; - handleOnDragEnd: (result: DropResult) => void; + handleTrashBox: (isDragging: boolean) => void; + removeIssue: ((bridgeId: string) => void) | null; userAuth: UserAuth; }; @@ -25,9 +25,11 @@ export const AllBoards: React.FC = ({ states, members, addIssueToState, + handleEditIssue, openIssuesListModal, handleDeleteIssue, - handleOnDragEnd, + handleTrashBox, + removeIssue, userAuth, }) => { const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssueView(issues); @@ -36,42 +38,43 @@ export const AllBoards: React.FC = ({ <> {groupedByIssues ? (
- -
-
-
- {Object.keys(groupedByIssues).map((singleGroup, index) => { - const stateId = - selectedGroup === "state_detail.name" - ? states?.find((s) => s.name === singleGroup)?.id ?? null - : null; +
+
+
+ {Object.keys(groupedByIssues).map((singleGroup, index) => { + const stateId = + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup)?.id ?? null + : null; - const bgColor = - selectedGroup === "state_detail.name" - ? states?.find((s) => s.name === singleGroup)?.color - : "#000000"; + const bgColor = + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup)?.color + : "#000000"; - return ( - addIssueToState(singleGroup, stateId)} - handleDeleteIssue={handleDeleteIssue} - openIssuesListModal={openIssuesListModal ?? null} - orderBy={orderBy} - userAuth={userAuth} - /> - ); - })} -
+ return ( + addIssueToState(singleGroup, stateId)} + handleDeleteIssue={handleDeleteIssue} + openIssuesListModal={openIssuesListModal ?? null} + orderBy={orderBy} + handleTrashBox={handleTrashBox} + removeIssue={removeIssue} + userAuth={userAuth} + /> + ); + })}
- +
) : (
Loading...
diff --git a/apps/app/components/core/board-view/board-header.tsx b/apps/app/components/core/board-view/board-header.tsx index 3a77533667e..cf394ae5e8b 100644 --- a/apps/app/components/core/board-view/board-header.tsx +++ b/apps/app/components/core/board-view/board-header.tsx @@ -12,80 +12,100 @@ import { // helpers import { addSpaceIfCamelCase } from "helpers/string.helper"; // types -import { IIssue } from "types"; +import { IIssue, IProjectMember, NestedKeyOf } from "types"; type Props = { - isCollapsed: boolean; - setIsCollapsed: React.Dispatch>; groupedByIssues: { [key: string]: IIssue[]; }; + selectedGroup: NestedKeyOf | null; groupTitle: string; - createdBy: string | null; bgColor?: string; addIssueToState: () => void; + members: IProjectMember[] | undefined; + isCollapsed: boolean; + setIsCollapsed: React.Dispatch>; }; export const BoardHeader: React.FC = ({ - isCollapsed, - setIsCollapsed, groupedByIssues, + selectedGroup, groupTitle, - createdBy, bgColor, addIssueToState, -}) => ( -
-
-
-

{ + const createdBy = + selectedGroup === "created_by" + ? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "loading..." + : null; + + let assignees: any; + if (selectedGroup === "assignees") { + assignees = groupTitle && groupTitle !== "" ? groupTitle.split(",") : []; + assignees = + assignees.length > 0 + ? assignees + .map((a: string) => members?.find((m) => m.member.id === a)?.member.first_name) + .join(", ") + : "No assignee"; + } + + return ( +
+
+
- {groupTitle === null || groupTitle === "null" - ? "None" - : createdBy - ? createdBy - : addSpaceIfCamelCase(groupTitle)} -

- {groupedByIssues[groupTitle].length} +

+ {selectedGroup === "created_by" + ? createdBy + : selectedGroup === "assignees" + ? assignees + : addSpaceIfCamelCase(groupTitle)} +

+ {groupedByIssues[groupTitle].length} +
-
-
- - +
+ + +
-
-); + ); +}; diff --git a/apps/app/components/core/board-view/single-board.tsx b/apps/app/components/core/board-view/single-board.tsx index 82d789a0752..5fee43c889e 100644 --- a/apps/app/components/core/board-view/single-board.tsx +++ b/apps/app/components/core/board-view/single-board.tsx @@ -25,10 +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; }; @@ -39,10 +42,13 @@ export const SingleBoard: React.FC = ({ groupedByIssues, selectedGroup, members, + handleEditIssue, addIssueToState, handleDeleteIssue, openIssuesListModal, orderBy, + handleTrashBox, + removeIssue, userAuth, }) => { // collapse/expand @@ -53,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") @@ -67,43 +68,79 @@ export const SingleBoard: React.FC = ({ ? (bgColor = "#22c55e") : (bgColor = "#ff0000"); + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + return (
{(provided, snapshot) => (
+ {orderBy !== "sort_order" && ( + <> +
+
+ This board is ordered by {orderBy} +
+ + )} {groupedByIssues[groupTitle].map((issue, index: number) => ( - + isDragDisabled={ + isNotAllowed || selectedGroup === "created_by" || selectedGroup === "assignees" + } + > + {(provided, snapshot) => ( + handleEditIssue(issue)} + handleDeleteIssue={handleDeleteIssue} + orderBy={orderBy} + handleTrashBox={handleTrashBox} + removeIssue={() => { + removeIssue && removeIssue(issue.bridge); + }} + userAuth={userAuth} + /> + )} + ))} {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 59786ea7cf7..ea1a37a7a92 100644 --- a/apps/app/components/core/board-view/single-issue.tsx +++ b/apps/app/components/core/board-view/single-issue.tsx @@ -1,21 +1,21 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useEffect } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; -import useSWR, { mutate } from "swr"; +import { mutate } from "swr"; // react-beautiful-dnd import { - Draggable, + DraggableProvided, DraggableStateSnapshot, 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, @@ -37,29 +40,39 @@ import { import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys"; type Props = { - index: number; type?: string; + 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; }; export const SingleBoardIssue: React.FC = ({ - index, type, + provided, + snapshot, issue, selectedGroup, properties, + editIssue, + removeIssue, handleDeleteIssue, orderBy, + handleTrashBox, userAuth, }) => { const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const { setToastAlert } = useToast(); + const partialUpdateIssue = useCallback( (formData: Partial) => { if (!workspaceSlug || !projectId) return; @@ -106,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 ); @@ -137,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; @@ -149,92 +162,111 @@ export const SingleBoardIssue: React.FC = ({ }; } + 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 ( - - {(provided, snapshot) => ( -
-
- {!isNotAllowed && ( -
- +
+ {!isNotAllowed && ( +
+ {type && !isNotAllowed && ( + + Edit + {type !== "issue" && removeIssue && ( + + <>Remove from {type} + + )} + handleDeleteIssue(issue)}> + Delete permanently + + Copy issue link + + )} +
+ )} + + + {properties.key && ( +
+ {issue.project_detail.identifier}-{issue.sequence_id}
)} - -
- {properties.key && ( -
- {issue.project_detail.identifier}-{issue.sequence_id} -
- )} -
- {issue.name} -
-
- -
- {properties.priority && ( - - )} - {properties.state && ( - - )} - {properties.due_date && ( - - )} - {properties.sub_issue_count && ( -
- {issue.sub_issues_count}{" "} - {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} -
- )} - {properties.assignee && ( - - )} +
+ {issue.name} +
+ + +
+ {properties.priority && selectedGroup !== "priority" && ( + + )} + {properties.state && selectedGroup !== "state_detail.name" && ( + + )} + {properties.due_date && ( + + )} + {properties.sub_issue_count && ( +
+ {issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
-
+ )} + {properties.assignee && ( + + )}
- )} - +
+
); }; diff --git a/apps/app/components/core/bulk-delete-issues-modal.tsx b/apps/app/components/core/bulk-delete-issues-modal.tsx index a2d1051ca97..b5556a95f94 100644 --- a/apps/app/components/core/bulk-delete-issues-modal.tsx +++ b/apps/app/components/core/bulk-delete-issues-modal.tsx @@ -18,7 +18,7 @@ import { Button } from "components/ui"; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { LayerDiagonalIcon } from "components/icons"; // types -import { IIssue, IssueResponse } from "types"; +import { IIssue } from "types"; // fetch keys import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; @@ -35,10 +35,7 @@ export const BulkDeleteIssuesModal: React.FC = ({ 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 15a313cb0d5..59faff3f6e7 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 0865ea44136..8146660a1cf 100644 --- a/apps/app/components/core/index.ts +++ b/apps/app/components/core/index.ts @@ -1,8 +1,10 @@ export * from "./board-view"; export * from "./list-view"; +export * from "./sidebar"; export * from "./bulk-delete-issues-modal"; 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 7225f514843..9962ba96d59 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 ec4c5b6887a..98526a2b0ef 100644 --- a/apps/app/components/core/issues-view.tsx +++ b/apps/app/components/core/issues-view.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/router"; import useSWR, { mutate } from "swr"; // react-beautiful-dnd -import { DropResult } from "react-beautiful-dnd"; +import { DragDropContext, DropResult } from "react-beautiful-dnd"; // services import issuesService from "services/issues.service"; import stateService from "services/state.service"; @@ -16,10 +16,13 @@ import useIssueView from "hooks/use-issue-view"; // components import { AllLists, AllBoards } from "components/core"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +import StrictModeDroppable from "components/dnd/StrictModeDroppable"; +// icons +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, @@ -58,10 +61,18 @@ export const IssuesView: React.FC = ({ const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [issueToDelete, setIssueToDelete] = useState(null); + // trash box + const [trashBox, setTrashBox] = useState(false); + 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, @@ -78,110 +89,88 @@ export const IssuesView: React.FC = ({ : null ); + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + const handleOnDragEnd = useCallback( (result: DropResult) => { + setTrashBox(false); + if (!result.destination || !workspaceSlug || !projectId) return; const { source, destination } = result; const draggedItem = groupedByIssues[source.droppableId][source.index]; - if (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; + if (destination.droppableId === "trashBox") { + handleDeleteIssue(draggedItem); + } else { + 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; + } + } - const updatedIssues = prevData.results.map((issue) => { - if (issue.id === draggedItem.id) - return { - ...draggedItem, - priority: destinationGroup, - }; + draggedItem.sort_order = newSortOrder; + } - return issue; - }); + if (orderBy === "sort_order" || source.droppableId !== destination.droppableId) { + const sourceGroup = source.droppableId; // source group id + const destinationGroup = destination.droppableId; // destination group id - return { - ...prevData, - results: updatedIssues, - }; - }, - false - ); + if (!sourceGroup || !destinationGroup) return; - // 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)); + if (selectedGroup === "priority") draggedItem.priority = destinationGroup; + else if (selectedGroup === "state_detail.name") { + const destinationState = states?.find((s) => s.name === destinationGroup); - mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)); - }); - } else if (selectedGroup === "state_detail.name") { - const destinationState = states?.find((s) => s.name === destinationGroup); - const destinationStateId = destinationState?.id; + if (!destinationState) return; - // update the removed item for mutation - if (!destinationStateId || !destinationState) return; - draggedItem.state = destinationStateId; - draggedItem.state_detail = destinationState; + draggedItem.state = destinationState.id; + draggedItem.state_detail = destinationState; + } if (cycleId) mutate( @@ -192,11 +181,7 @@ export const IssuesView: React.FC = ({ if (issue.issue_detail.id === draggedItem.id) { return { ...issue, - issue_detail: { - ...draggedItem, - state_detail: destinationState, - state: destinationStateId, - }, + issue_detail: draggedItem, }; } return issue; @@ -215,11 +200,7 @@ export const IssuesView: React.FC = ({ if (issue.issue_detail.id === draggedItem.id) { return { ...issue, - issue_detail: { - ...draggedItem, - state_detail: destinationState, - state: destinationStateId, - }, + issue_detail: draggedItem, }; } return issue; @@ -229,26 +210,18 @@ export const IssuesView: React.FC = ({ false ); - mutate( + 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, - state_detail: destinationState, - state: destinationStateId, - }; + const updatedIssues = prevData.map((i) => { + if (i.id === draggedItem.id) return draggedItem; - return issue; + return i; }); - return { - ...prevData, - results: updatedIssues, - }; + return updatedIssues; }, false ); @@ -256,7 +229,9 @@ export const IssuesView: React.FC = ({ // patch request issuesService .patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, { - state: destinationStateId, + priority: draggedItem.priority, + state: draggedItem.state, + sort_order: draggedItem.sort_order, }) .then((res) => { if (cycleId) mutate(CYCLE_ISSUES(cycleId as string)); @@ -267,82 +242,106 @@ export const IssuesView: React.FC = ({ } } }, - [workspaceSlug, cycleId, moduleId, groupedByIssues, projectId, selectedGroup, states] + [ + workspaceSlug, + cycleId, + moduleId, + groupedByIssues, + projectId, + selectedGroup, + orderBy, + states, + handleDeleteIssue, + ] ); - const addIssueToState = (groupTitle: string, stateId: string | null) => { - setCreateIssueModal(true); - if (selectedGroup) - setPreloadedData({ - state: stateId ?? undefined, - [selectedGroup]: groupTitle, - actionType: "createIssue", - }); - else setPreloadedData({ actionType: "createIssue" }); - }; - - const handleEditIssue = (issue: IIssue) => { - setEditIssueModal(true); - setIssueToEdit({ - ...issue, - actionType: "edit", - cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, - module: issue.issue_module ? issue.issue_module.module : null, - }); - }; - - const handleDeleteIssue = (issue: IIssue) => { - setDeleteIssueModal(true); - setIssueToDelete(issue); - }; - - const removeIssueFromCycle = (bridgeId: string) => { - if (!workspaceSlug || !projectId) return; - - mutate( - CYCLE_ISSUES(cycleId as string), - (prevData) => prevData?.filter((p) => p.id !== bridgeId), - false - ); - - issuesService - .removeIssueFromCycle( - workspaceSlug as string, - projectId as string, - cycleId as string, - bridgeId - ) - .then((res) => { - console.log(res); - }) - .catch((e) => { - console.log(e); - }); - }; - - const removeIssueFromModule = (bridgeId: string) => { - if (!workspaceSlug || !projectId) return; - - mutate( - MODULE_ISSUES(moduleId as string), - (prevData) => prevData?.filter((p) => p.id !== bridgeId), - false - ); - - modulesService - .removeIssueFromModule( - workspaceSlug as string, - projectId as string, - moduleId as string, - bridgeId - ) - .then((res) => { - console.log(res); - }) - .catch((e) => { - console.log(e); + const addIssueToState = useCallback( + (groupTitle: string, stateId: string | null) => { + setCreateIssueModal(true); + if (selectedGroup) + setPreloadedData({ + state: stateId ?? undefined, + [selectedGroup]: groupTitle, + actionType: "createIssue", + }); + else setPreloadedData({ actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData, selectedGroup] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, }); - }; + }, + [setEditIssueModal, setIssueToEdit] + ); + + const removeIssueFromCycle = useCallback( + (bridgeId: string) => { + if (!workspaceSlug || !projectId) return; + + mutate( + CYCLE_ISSUES(cycleId as string), + (prevData) => prevData?.filter((p) => p.id !== bridgeId), + false + ); + + issuesService + .removeIssueFromCycle( + workspaceSlug as string, + projectId as string, + cycleId as string, + bridgeId + ) + .then((res) => { + console.log(res); + }) + .catch((e) => { + console.log(e); + }); + }, + [workspaceSlug, projectId, cycleId] + ); + + const removeIssueFromModule = useCallback( + (bridgeId: string) => { + if (!workspaceSlug || !projectId) return; + + mutate( + MODULE_ISSUES(moduleId as string), + (prevData) => prevData?.filter((p) => p.id !== bridgeId), + false + ); + + modulesService + .removeIssueFromModule( + workspaceSlug as string, + projectId as string, + moduleId as string, + bridgeId + ) + .then((res) => { + console.log(res); + }) + .catch((e) => { + console.log(e); + }); + }, + [workspaceSlug, projectId, moduleId] + ); + + const handleTrashBox = useCallback( + (isDragging: boolean) => { + if (isDragging && !trashBox) setTrashBox(true); + }, + [trashBox, setTrashBox] + ); return ( <> @@ -364,38 +363,67 @@ export const IssuesView: React.FC = ({ isOpen={deleteIssueModal} data={issueToDelete} /> - {issueView === "list" ? ( - - ) : ( - - )} + +
    + + + {(provided, snapshot) => ( +
    + + Drop issue here to delete +
    + )} +
    + {issueView === "list" ? ( + + ) : ( + + )} +
    +
    ); }; 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 2917717ad49..2b1004e2936 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; - - const previousLinks = module?.link_module.map((l) => ({ title: l.title, url: l.url })); - - const payload: Partial = { - links_list: [...(previousLinks ?? []), formData], - }; + await onFormSubmit(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 b779db59470..7f5741037d0 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, @@ -16,17 +18,12 @@ import { } from "components/issues/view-select"; // ui import { 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 +46,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 +93,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 +120,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 ( @@ -190,6 +204,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 4309b2d3336..9c3a7ac0ff9 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 new file mode 100644 index 00000000000..c5357f576cc --- /dev/null +++ b/apps/app/components/core/sidebar/index.ts @@ -0,0 +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 00000000000..2a30510eb01 --- /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 new file mode 100644 index 00000000000..b0d5bb39466 --- /dev/null +++ b/apps/app/components/core/sidebar/progress-chart.tsx @@ -0,0 +1,97 @@ +import React from "react"; + +import { + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + AreaChart, + Area, + ReferenceLine, +} from "recharts"; + +//types +import { IIssue } from "types"; +// helper +import { getDatesInRange, renderShortNumericDateFormat } from "helpers/date-time.helper"; + +type Props = { + issues: IIssue[]; + start: string; + end: string; +}; + +const ProgressChart: React.FC = ({ issues, start, end }) => { + const startDate = new Date(start); + const endDate = new Date(end); + const getChartData = () => { + const dateRangeArray = getDatesInRange(startDate, endDate); + let count = 0; + const dateWiseData = dateRangeArray.map((d) => { + const current = d.toISOString().split("T")[0]; + const total = issues.length; + const currentData = issues.filter( + (i) => i.completed_at && i.completed_at.toString().split("T")[0] === current + ); + count = currentData ? currentData.length + count : count; + + return { + currentDate: renderShortNumericDateFormat(current), + currentDateData: currentData, + pending: new Date(current) < new Date() ? total - count : null, + }; + }); + return dateWiseData; + }; + const ChartData = getChartData(); + return ( +
    +
    +
    + + Ideal +
    +
    + + Current +
    +
    +
    + + + + + + + + + +
    +
    + ); +}; + +export default ProgressChart; diff --git a/apps/app/components/core/sidebar/sidebar-progress-stats.tsx b/apps/app/components/core/sidebar/sidebar-progress-stats.tsx index 2b150a890ef..01417b62554 100644 --- a/apps/app/components/core/sidebar/sidebar-progress-stats.tsx +++ b/apps/app/components/core/sidebar/sidebar-progress-stats.tsx @@ -10,8 +10,10 @@ 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/sidebar/single-progress-stats"; +import { SingleProgressStats } from "components/core"; // ui import { Avatar } from "components/ui"; // icons @@ -36,9 +38,12 @@ const stateGroupColours: { completed: "#096e8d", }; -const SidebarProgressStats: React.FC = ({ groupedIssues, issues }) => { +export const SidebarProgressStats: React.FC = ({ groupedIssues, issues }) => { const router = useRouter(); 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 @@ -52,133 +57,157 @@ const SidebarProgressStats: React.FC = ({ groupedIssues, issues }) => { ? () => projectService.projectMembers(workspaceSlug as string, projectId as string) : null ); + + const currentValue = (tab: string | null) => { + switch (tab) { + case "Assignees": + return 0; + case "Labels": + return 1; + case "States": + return 2; + default: + return 0; + } + }; return ( -
    - - { + switch (i) { + case 0: + return setTab("Assignees"); + case 1: + return setTab("Labels"); + case 2: + return setTab("States"); + + default: + return setTab("Assignees"); + } + }} + > + + + `w-1/2 rounded py-1 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}` + } > - - `w-1/2 rounded py-1 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}` - } - > - Assignees - - - `w-1/2 rounded py-1 ${selected ? "bg-gray-300 font-semibold" : "hover:bg-gray-200 "}` - } - > - Labels - - - `w-1/2 rounded py-1 ${selected ? "bg-gray-300 font-semibold" : "hover:bg-gray-200 "}` + Assignees + + + `w-1/2 rounded py-1 ${selected ? "bg-gray-300 font-semibold" : "hover:bg-gray-200 "}` + } + > + Labels + + + `w-1/2 rounded py-1 ${selected ? "bg-gray-300 font-semibold" : "hover:bg-gray-200 "}` + } + > + States + + + + + {members?.map((member, index) => { + const totalArray = issues?.filter((i) => i.assignees?.includes(member.member.id)); + const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); + if (totalArray.length > 0) { + return ( + + + {member.member.first_name} + + } + completed={completeArray.length} + total={totalArray.length} + /> + ); } - > - States - - - - - {members?.map((member, index) => { - const totalArray = issues?.filter((i) => i.assignees?.includes(member.member.id)); - const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); - if (totalArray.length > 0) { - return ( - - - {member.member.first_name} - - } - completed={completeArray.length} - total={totalArray.length} - /> - ); + })} + {issues?.filter((i) => i.assignees?.length === 0).length > 0 ? ( + +
    + User +
    + No assignee + + } + completed={ + issues?.filter( + (i) => i.state_detail.group === "completed" && i.assignees?.length === 0 + ).length } - })} - {issues?.filter((i) => i.assignees?.length === 0).length > 0 ? ( - -
    - User i.assignees?.length === 0).length} + /> + ) : ( + "" + )} + + + {issueLabels?.map((issue, index) => { + const totalArray = issues?.filter((i) => i.labels?.includes(issue.id)); + const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); + if (totalArray.length > 0) { + return ( + + -
    - No assignee - - } - completed={ - issues?.filter( - (i) => i.state_detail.group === "completed" && i.assignees?.length === 0 - ).length - } - total={issues?.filter((i) => i.assignees?.length === 0).length} - /> - ) : ( - "" - )} -
    - - {issueLabels?.map((issue, index) => { - const totalArray = issues?.filter((i) => i.labels?.includes(issue.id)); - const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); - if (totalArray.length > 0) { - return ( - - - {issue.name} - - } - completed={completeArray.length} - total={totalArray.length} + {issue.name} + + } + completed={completeArray.length} + total={totalArray.length} + /> + ); + } + })} + + + {Object.keys(groupedIssues).map((group, index) => ( + + - ); + {group} + } - })} - - - {Object.keys(groupedIssues).map((group, index) => ( - - - {group} - - } - completed={groupedIssues[group].length} - total={issues.length} - /> - ))} - -
    -
    -
    + completed={groupedIssues[group].length} + total={issues.length} + /> + ))} + + + ); }; - -export default SidebarProgressStats; diff --git a/apps/app/components/core/sidebar/single-progress-stats.tsx b/apps/app/components/core/sidebar/single-progress-stats.tsx index 885aed23ed2..bb56e154554 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; @@ -8,22 +8,22 @@ type TSingleProgressStatsProps = { total: number; }; -const SingleProgressStats: React.FC = ({ title, completed, total }) => ( - <> -
    -
    {title}
    -
    -
    - - - - {Math.floor((completed / total) * 100)}% -
    - of - {total} +export const SingleProgressStats: React.FC = ({ + title, + completed, + total, +}) => ( +
    +
    {title}
    +
    +
    + + + + {Math.floor((completed / total) * 100)}%
    + of + {total}
    - +
    ); - -export default SingleProgressStats; 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 354bab4a119..8491190e8d6 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 96% rename from apps/app/components/project/cycles/confirm-cycle-deletion.tsx rename to apps/app/components/cycles/delete-cycle-modal.tsx index ab35d343bf6..a962533a4b3 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, @@ -36,10 +36,6 @@ const ConfirmCycleDeletion: React.FC = ({ const { setToastAlert } = useToast(); - useEffect(() => { - data && setIsOpen(true); - }, [data, setIsOpen]); - const handleClose = () => { setIsOpen(false); setIsDeleteLoading(false); @@ -153,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 262db1abcfe..58f57ba1400 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 26de56bb8fb..c482192763b 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/cycles/sidebar.tsx b/apps/app/components/cycles/sidebar.tsx new file mode 100644 index 00000000000..c61102fb16b --- /dev/null +++ b/apps/app/components/cycles/sidebar.tsx @@ -0,0 +1,357 @@ +import React, { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; +import Image from "next/image"; + +import { mutate } from "swr"; + +// react-hook-form +import { Controller, useForm } from "react-hook-form"; +import { Popover, Transition } from "@headlessui/react"; +import DatePicker from "react-datepicker"; +// icons +import { + CalendarDaysIcon, + ChartPieIcon, + LinkIcon, + Squares2X2Icon, + TrashIcon, + UserIcon, +} from "@heroicons/react/24/outline"; +// ui +import { CustomSelect, Loader, ProgressBar } from "components/ui"; +// hooks +import useToast from "hooks/use-toast"; +// services +import cyclesService from "services/cycles.service"; +// components +import { SidebarProgressStats } from "components/core"; +import ProgressChart from "components/core/sidebar/progress-chart"; +import { DeleteCycleModal } from "components/cycles"; +// helpers +import { copyTextToClipboard } from "helpers/string.helper"; +import { groupBy } from "helpers/array.helper"; +import { renderDateFormat, renderShortNumericDateFormat } from "helpers/date-time.helper"; +// types +import { CycleIssueResponse, ICycle, IIssue } from "types"; +// fetch-keys +import { CYCLE_DETAILS } from "constants/fetch-keys"; +// constants +import { CYCLE_STATUS } from "constants/cycle"; + +type Props = { + issues: IIssue[]; + cycle: ICycle | undefined; + isOpen: boolean; + cycleIssues: CycleIssueResponse[]; +}; + +export const CycleDetailsSidebar: React.FC = ({ issues, cycle, isOpen, cycleIssues }) => { + const [cycleDeleteModal, setCycleDeleteModal] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId, cycleId } = router.query; + + const [startDateRange, setStartDateRange] = useState(new Date()); + const [endDateRange, setEndDateRange] = useState(null); + + const { setToastAlert } = useToast(); + + const defaultValues: Partial = { + start_date: new Date().toString(), + end_date: new Date().toString(), + status: cycle?.status, + }; + + const groupedIssues = { + backlog: [], + unstarted: [], + started: [], + cancelled: [], + completed: [], + ...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"), + }; + + const { reset, watch, control } = useForm({ + defaultValues, + }); + + const submitChanges = (data: Partial) => { + if (!workspaceSlug || !projectId || !cycleId) return; + + mutate( + CYCLE_DETAILS(cycleId as string), + (prevData) => ({ ...(prevData as ICycle), ...data }), + false + ); + + cyclesService + .patchCycle(workspaceSlug as string, projectId as string, cycleId as string, data) + .then((res) => { + console.log(res); + mutate(CYCLE_DETAILS(cycleId as string)); + }) + .catch((e) => { + console.log(e); + }); + }; + + useEffect(() => { + if (cycle) + reset({ + ...cycle, + }); + }, [cycle, reset]); + + const isStartValid = new Date(`${cycle?.start_date}`) <= new Date(); + const isEndValid = new Date(`${cycle?.end_date}`) >= new Date(`${cycle?.start_date}`); + + return ( + <> + +
    + {cycle ? ( + <> +
    +
    + ( + + + {watch("status")} + + } + value={value} + onChange={(value: any) => { + submitChanges({ status: value }); + }} + > + {CYCLE_STATUS.map((option) => ( + + {option.label} + + ))} + + )} + /> +
    +
    + + {({ open }) => ( + <> + + + + {renderShortNumericDateFormat(`${cycle.start_date}`) + ? renderShortNumericDateFormat(`${cycle.start_date}`) + : "N/A"} + + + + + + { + 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}

    +
    + + +
    +
    +
    +
    +
    +
    + +

    Owned by

    +
    +
    + {cycle.owned_by && + (cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( +
    + {cycle.owned_by?.first_name} +
    + ) : ( +
    + {cycle.owned_by?.first_name && cycle.owned_by.first_name !== "" + ? cycle.owned_by.first_name.charAt(0) + : cycle.owned_by?.email.charAt(0)} +
    + ))} + {cycle.owned_by.first_name !== "" + ? cycle.owned_by.first_name + : cycle.owned_by.email} +
    +
    +
    +
    + +

    Progress

    +
    +
    +
    + + + +
    + {groupedIssues.completed.length}/{cycleIssues?.length} +
    +
    +
    +
    +
    +
    + {isStartValid && isEndValid ? ( +
    + +
    + ) : ( + "" + )} + {issues.length > 0 ? ( + + ) : ( + "" + )} +
    + + ) : ( + +
    + + +
    +
    + + + +
    +
    + )} +
    + + ); +}; 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 33bc18b055c..86589995fc5 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 2bcc3853d0a..37678b2a2c9 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 9b31a242395..b379b5ce136 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 bbc6552baae..58df96f0d3b 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 84ae61643bc..2caf5b63550 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 ( - + {}}> = ({ > 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 b1b1c43382d..2b810b30ab5 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 { TagIcon } from "@heroicons/react/24/outline"; +import { PlusIcon, RectangleGroupIcon, TagIcon } from "@heroicons/react/24/outline"; // services import issuesServices from "services/issues.service"; // types @@ -18,67 +16,30 @@ 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 { - register, - handleSubmit, - formState: { isSubmitting }, - setFocus, - reset, - } = useForm({ defaultValues }); - - useEffect(() => { - isOpen && setFocus("name"); - }, [isOpen, setFocus]); - - const options = issueLabels?.map((label) => ({ - value: label.id, - display: label.name, - color: label.color, - })); - const filteredOptions = query === "" - ? options - : options?.filter((option) => option.display.toLowerCase().includes(query.toLowerCase())); + ? issueLabels + : issueLabels?.filter((l) => l.name.toLowerCase().includes(query.toLowerCase())); return ( <> @@ -98,10 +59,9 @@ export const IssueLabelSelect: React.FC = ({ value, onChange, projectId } {Array.isArray(value) - ? value - .map((v) => options?.find((option) => option.value === v)?.display) - .join(", ") || "Labels" - : options?.find((option) => option.value === value)?.display || "Labels"} + ? value.map((v) => issueLabels?.find((l) => l.id === v)?.name).join(", ") || + "Labels" + : issueLabels?.find((l) => l.id === value)?.name || "Labels"}
    @@ -122,79 +82,77 @@ export const IssueLabelSelect: React.FC = ({ value, onChange, projectId } displayValue={(assigned: any) => assigned?.name} />
    - {filteredOptions ? ( + {issueLabels && filteredOptions ? ( filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `${active ? "bg-indigo-50" : ""} ${ - selected ? "bg-indigo-50 font-medium" : "" - } flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` - } - value={option.value} - > - {issueLabels && ( - <> - - {option.display} - - )} - - )) + filteredOptions.map((label) => { + const children = issueLabels?.filter((l) => l.parent === label.id); + + if (children.length === 0) { + if (!label.parent) + return ( + + `${active ? "bg-indigo-50" : ""} ${ + selected ? "bg-indigo-50 font-medium" : "" + } flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` + } + value={label.id} + > + + {label.name} + + ); + } else + return ( +
    +
    + {label.name} +
    +
    + {children.map((child) => ( + + `${active ? "bg-indigo-50" : ""} ${ + selected ? "bg-indigo-50 font-medium" : "" + } flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` + } + value={child.id} + > + + {child.name} + + ))} +
    +
    + ); + }) ) : (

    No labels found

    ) ) : (

    Loading...

    )} - {/*
    - {isOpen ? ( -
    - - - -
    - ) : ( - - )} -
    */} +
    diff --git a/apps/app/components/issues/sidebar-select/assignee.tsx b/apps/app/components/issues/sidebar-select/assignee.tsx index 369d0336817..2d8088059f0 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 393d90dcd2a..8b802bdd3db 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 c67e0a8bf2a..3d343260c07 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 353bc512134..b1243fe98ca 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 e5768888785..44bef4d62e0 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 1af86c359e8..f2458a53ef3 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 62a99eac142..24a180bb6b8 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, @@ -38,11 +39,12 @@ import { TrashIcon, PlusIcon, XMarkIcon, + RectangleGroupIcon, } from "@heroicons/react/24/outline"; // 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"; @@ -68,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; @@ -79,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 ); @@ -103,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); @@ -117,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], }) @@ -143,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} @@ -215,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 && @@ -243,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} /> @@ -290,7 +346,7 @@ export const IssueDetailsSidebar: React.FC = ({ />
    -
    +
    @@ -298,30 +354,31 @@ export const IssueDetailsSidebar: React.FC = ({
    - {watchIssue("labels_list")?.map((label) => { - const singleLabel = issueLabels?.find((l) => l.id === label); - - if (!singleLabel) return null; + {watchIssue("labels_list")?.map((labelId) => { + const label = issueLabels?.find((l) => l.id === labelId); - return ( - { - const updatedLabels = watchIssue("labels_list")?.filter((l) => l !== label); - submitChanges({ - labels_list: updatedLabels, - }); - }} - > + if (label) + return ( - {singleLabel.name} - - - ); + key={label.id} + className="group flex cursor-pointer items-center gap-1 rounded-2xl border px-1 py-0.5 text-xs hover:border-red-500 hover:bg-red-50" + onClick={() => { + const updatedLabels = watchIssue("labels_list")?.filter( + (l) => l !== labelId + ); + submitChanges({ + labels_list: updatedLabels, + }); + }} + > + + {label.name} + + + ); })} = ({ disabled={isNotAllowed} > {({ open }) => ( - <> - Label -
    - - Select Label - - - - -
    - {issueLabels ? ( - issueLabels.length > 0 ? ( - issueLabels.map((label: IIssueLabels) => ( - - `${ - active || selected ? "bg-indigo-50" : "" - } relative flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` - } - value={label.id} - > - - {label.name} - - )) - ) : ( -
    No labels found
    - ) +
    + + Select Label + + + + +
    + {issueLabels ? ( + issueLabels.length > 0 ? ( + issueLabels.map((label: IIssueLabels) => { + const children = issueLabels?.filter( + (l) => l.parent === label.id + ); + + if (children.length === 0) { + if (!label.parent) + return ( + + `${active || selected ? "bg-indigo-50" : ""} ${ + selected ? "font-medium" : "" + } flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` + } + value={label.id} + > + + {label.name} + + ); + } else + return ( +
    +
    + {" "} + {label.name} +
    +
    + {children.map((child) => ( + + `${active || selected ? "bg-indigo-50" : ""} ${ + selected ? "font-medium" : "" + } flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` + } + value={child.id} + > + + {child.name} + + ))} +
    +
    + ); + }) ) : ( - - )} -
    -
    -
    -
    - +
    No labels found
    + ) + ) : ( + + )} +
    +
    +
    +
    )} )} /> - + {!isNotAllowed && ( + + )}
    @@ -426,7 +524,7 @@ export const IssueDetailsSidebar: React.FC = ({ )} @@ -478,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 ac3bc759776..00000000000 --- 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 a903f3b6245..4f9b1f3fc30 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[])]; - const closeIssueModal = () => { - setIssueModalActive(false); + 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 openSubIssueModal = () => { - setSubIssuesListModal(true); + 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 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/priority.tsx b/apps/app/components/issues/view-select/priority.tsx index 5517494b2cc..096d6e93f09 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 } from "components/ui"; // icons import { getPriorityIcon } from "components/icons/priority-icon"; // types @@ -22,67 +22,43 @@ export const ViewPrioritySelect: React.FC = ({ position = "right", isNotAllowed, }) => ( - + {getPriorityIcon( + issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None", + "text-sm" + )} + + } + value={issue.state} onChange={(data: string) => { partialUpdateIssue({ priority: data }); }} - className="group relative flex-shrink-0" + 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} > - {({ open }) => ( -
    - - {getPriorityIcon( - issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None", - "text-sm" - )} - - - - - {PRIORITIES?.map((priority) => ( - - `flex cursor-pointer select-none items-center gap-x-2 px-3 py-2 capitalize ${ - active ? "bg-indigo-50" : "bg-white" - }` - } - value={priority} - > - {getPriorityIcon(priority, "text-sm")} - {priority ?? "None"} - - ))} - - -
    - )} -
    + {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 86b8998a902..b6bac7a0b81 100644 --- a/apps/app/components/issues/view-select/state.tsx +++ b/apps/app/components/issues/view-select/state.tsx @@ -24,15 +24,17 @@ type Props = { export const ViewStateSelect: React.FC = ({ issue, partialUpdateIssue, - position, + position = "right", isNotAllowed, }) => { const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; const { data: stateGroups } = useSWR( - workspaceSlug && projectId ? STATE_LIST(issue.project) : null, - workspaceSlug ? () => stateService.getStates(workspaceSlug as string, issue.project) : null + workspaceSlug && issue ? STATE_LIST(issue.project) : null, + workspaceSlug && issue + ? () => stateService.getStates(workspaceSlug as string, issue.project) + : null ); const states = getStatesList(stateGroups ?? {}); 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 00000000000..58967d6d4d6 --- /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 new file mode 100644 index 00000000000..6c61d6d5a5b --- /dev/null +++ b/apps/app/components/labels/create-update-label-inline.tsx @@ -0,0 +1,192 @@ +import React, { useEffect } from "react"; + +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// react-hook-form +import { Controller, SubmitHandler, useForm } from "react-hook-form"; +// react-color +import { TwitterPicker } from "react-color"; +// headless ui +import { Popover, Transition } from "@headlessui/react"; +// services +import issuesService from "services/issues.service"; +// ui +import { Button, Input } from "components/ui"; +// types +import { IIssueLabels } from "types"; +// fetch-keys +import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; + +type Props = { + labelForm: boolean; + setLabelForm: React.Dispatch>; + isUpdating: boolean; + labelToUpdate: IIssueLabels | null; +}; + +const defaultValues: Partial = { + name: "", + color: "#ff0000", +}; + +export const CreateUpdateLabelInline: React.FC = ({ + labelForm, + setLabelForm, + isUpdating, + labelToUpdate, +}) => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { + handleSubmit, + control, + register, + reset, + formState: { errors, isSubmitting }, + watch, + setValue, + } = useForm({ + defaultValues, + }); + + const handleLabelCreate: SubmitHandler = async (formData) => { + if (!workspaceSlug || !projectId || isSubmitting) return; + + await issuesService + .createIssueLabel(workspaceSlug as string, projectId as string, formData) + .then((res) => { + mutate( + PROJECT_ISSUE_LABELS(projectId as string), + (prevData) => [res, ...(prevData ?? [])], + false + ); + reset(defaultValues); + setLabelForm(false); + }); + }; + + const handleLabelUpdate: SubmitHandler = async (formData) => { + if (!workspaceSlug || !projectId || isSubmitting) return; + + await issuesService + .patchIssueLabel( + workspaceSlug as string, + projectId as string, + labelToUpdate?.id ?? "", + formData + ) + .then((res) => { + console.log(res); + reset(defaultValues); + mutate( + PROJECT_ISSUE_LABELS(projectId as string), + (prevData) => + prevData?.map((p) => (p.id === labelToUpdate?.id ? { ...p, ...formData } : p)), + false + ); + setLabelForm(false); + }); + }; + + useEffect(() => { + if (!labelForm && isUpdating) return; + + reset(); + }, [labelForm, isUpdating, reset]); + + useEffect(() => { + if (!labelToUpdate) return; + + setValue( + "color", + labelToUpdate.color && labelToUpdate.color !== "" ? labelToUpdate.color : "#000" + ); + setValue("name", labelToUpdate.name); + }, [labelToUpdate, setValue]); + + return ( +
    +
    + + {({ open }) => ( + <> + + {watch("color") && watch("color") !== "" && ( + + )} + + + + + ( + onChange(value.hex)} /> + )} + /> + + + + )} + +
    +
    + +
    + + {isUpdating ? ( + + ) : ( + + )} +
    + ); +}; diff --git a/apps/app/components/labels/index.ts b/apps/app/components/labels/index.ts index 2a155c8dcfb..db02d29f02b 100644 --- a/apps/app/components/labels/index.ts +++ b/apps/app/components/labels/index.ts @@ -1,2 +1,5 @@ +export * from "./create-label-modal"; +export * from "./create-update-label-inline"; export * from "./labels-list-modal"; +export * from "./single-label-group"; export * from "./single-label"; diff --git a/apps/app/components/labels/single-label-group.tsx b/apps/app/components/labels/single-label-group.tsx new file mode 100644 index 00000000000..efdb26f3878 --- /dev/null +++ b/apps/app/components/labels/single-label-group.tsx @@ -0,0 +1,136 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// headless ui +import { Disclosure, Transition } from "@headlessui/react"; +// services +import issuesService from "services/issues.service"; +// ui +import { CustomMenu } from "components/ui"; +// icons +import { ChevronDownIcon, RectangleGroupIcon } from "@heroicons/react/24/outline"; +// types +import { IIssueLabels } from "types"; +// fetch-keys +import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; + +type Props = { + label: IIssueLabels; + labelChildren: IIssueLabels[]; + addLabelToGroup: (parentLabel: IIssueLabels) => void; + editLabel: (label: IIssueLabels) => void; + handleLabelDelete: (labelId: string) => void; +}; + +export const SingleLabelGroup: React.FC = ({ + label, + labelChildren, + addLabelToGroup, + editLabel, + handleLabelDelete, +}) => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const removeFromGroup = (label: IIssueLabels) => { + if (!workspaceSlug || !projectId) return; + + mutate( + PROJECT_ISSUE_LABELS(projectId as string), + (prevData) => + prevData?.map((l) => { + if (l.id === label.id) return { ...l, parent: null }; + + return l; + }), + false + ); + + issuesService + .patchIssueLabel(workspaceSlug as string, projectId as string, label.id, { + parent: null, + }) + .then((res) => { + mutate(PROJECT_ISSUE_LABELS(projectId as string)); + }); + }; + + return ( + + {({ open }) => ( + <> +
    + +
    + + + + + + +
    {label.name}
    +
    +
    + + addLabelToGroup(label)}> + Add more labels + + editLabel(label)}>Edit + handleLabelDelete(label.id)}> + Delete + + +
    + + +
    + {labelChildren.map((child) => ( +
    +
    + + {child.name} +
    +
    + + removeFromGroup(child)}> + Remove from group + + editLabel(child)}> + Edit + + handleLabelDelete(child.id)}> + Delete + + +
    +
    + ))} +
    +
    +
    + + )} +
    + ); +}; diff --git a/apps/app/components/labels/single-label.tsx b/apps/app/components/labels/single-label.tsx index eb721a8ac54..9c311518e82 100644 --- a/apps/app/components/labels/single-label.tsx +++ b/apps/app/components/labels/single-label.tsx @@ -1,171 +1,43 @@ -import React, { useState } from "react"; +import React from "react"; -import { useRouter } from "next/router"; - -import { mutate } from "swr"; - -// headless ui -import { Disclosure, Transition } from "@headlessui/react"; -// services -import issuesService from "services/issues.service"; -// components -import { LabelsListModal } from "components/labels"; // ui import { CustomMenu } from "components/ui"; -// icons -import { ChevronDownIcon } from "@heroicons/react/24/outline"; // types import { IIssueLabels } from "types"; -// fetch-keys -import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; type Props = { label: IIssueLabels; - issueLabels: IIssueLabels[]; + addLabelToGroup: (parentLabel: IIssueLabels) => void; editLabel: (label: IIssueLabels) => void; handleLabelDelete: (labelId: string) => void; }; export const SingleLabel: React.FC = ({ label, - issueLabels, + addLabelToGroup, editLabel, handleLabelDelete, -}) => { - const [labelsListModal, setLabelsListModal] = useState(false); - - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - - const children = issueLabels?.filter((l) => l.parent === label.id); - - const removeFromGroup = (label: IIssueLabels) => { - if (!workspaceSlug || !projectId) return; - - mutate( - PROJECT_ISSUE_LABELS(projectId as string), - (prevData) => - prevData?.map((l) => { - if (l.id === label.id) return { ...l, parent: null }; - - return l; - }), - false - ); - - issuesService - .patchIssueLabel(workspaceSlug as string, projectId as string, label.id, { - parent: null, - }) - .then((res) => { - mutate(PROJECT_ISSUE_LABELS(projectId as string)); - }); - }; - - return ( - <> - setLabelsListModal(false)} - parent={label} - /> - {children && children.length === 0 ? ( - label.parent === "" || !label.parent ? ( -
    -
    -
    - -
    {label.name}
    -
    - - setLabelsListModal(true)}> - Convert to group - - editLabel(label)}>Edit - handleLabelDelete(label.id)}> - Delete - - -
    -
    - ) : null - ) : ( - - {({ open }) => ( - <> -
    - -
    - - - -
    {label.name}
    -
    -
    - - setLabelsListModal(true)}> - Add more labels - - editLabel(label)}>Edit - handleLabelDelete(label.id)}> - Delete - - -
    - - -
    - {children.map((child) => ( -
    -
    - - {child.name} -
    -
    - - removeFromGroup(child)}> - Remove from group - - editLabel(child)}> - Edit - - handleLabelDelete(child.id)}> - Delete - - -
    -
    - ))} -
    -
    -
    - - )} -
    - )} - - ); -}; +}) => ( +
    +
    +
    + +
    {label.name}
    +
    + + addLabelToGroup(label)}> + Convert to group + + editLabel(label)}>Edit + handleLabelDelete(label.id)}> + Delete + + +
    +
    +); diff --git a/apps/app/components/modules/delete-module-modal.tsx b/apps/app/components/modules/delete-module-modal.tsx index 317f49a6863..05ed6708943 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 6f312129271..5ba7ea47e0a 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 22f9a7e495c..7acf813391f 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 5625933846f..57ec33a9665 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,34 +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 // 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 SidebarProgressStats from "components/core/sidebar/sidebar-progress-stats"; +// constant +import { MODULE_STATUS } from "constants/module"; const defaultValues: Partial = { lead: "", @@ -55,7 +56,7 @@ type Props = { module?: IModule; isOpen: boolean; moduleIssues: ModuleIssueResponse[] | undefined; - handleDeleteModule: () => void; + userAuth: UserAuth; }; export const ModuleDetailsSidebar: React.FC = ({ @@ -63,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; @@ -108,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({ @@ -116,12 +150,20 @@ export const ModuleDetailsSidebar: React.FC = ({ }); }, [module, reset]); + 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}

    @@ -159,7 +318,7 @@ export const ModuleDetailsSidebar: React.FC = ({ @@ -181,10 +340,9 @@ export const ModuleDetailsSidebar: React.FC = ({
    -
    @@ -192,59 +350,6 @@ export const ModuleDetailsSidebar: React.FC = ({
    -
    -
    -
    - -

    Start date

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

    End date

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

    Links

    @@ -257,45 +362,31 @@ 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}
    -
    - +
    + {isStartValid && isEndValid ? ( + + ) : ( + "" + )} + {issues.length > 0 ? ( + + ) : ( + "" + )}
    ) : ( diff --git a/apps/app/components/modules/single-module-card.tsx b/apps/app/components/modules/single-module-card.tsx index 6bd59d14fe1..954a6e20a7d 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
    +
    + +
    -
    -
    + ); }; diff --git a/apps/app/components/onboarding/invite-members.tsx b/apps/app/components/onboarding/invite-members.tsx index efcdd650ce0..e6fc54e3715 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/project/card.tsx b/apps/app/components/project/card.tsx index 0ddaf1cd7f8..656f1d10bac 100644 --- a/apps/app/components/project/card.tsx +++ b/apps/app/components/project/card.tsx @@ -12,13 +12,14 @@ import { ClipboardDocumentListIcon, } from "@heroicons/react/24/outline"; // types -import type { IProject } from "types"; // ui import { Button } from "components/ui"; // hooks import useProjectMembers from "hooks/use-project-members"; // helpers import { renderShortNumericDateFormat } from "helpers/date-time.helper"; +// types +import type { IProject } from "types"; export type ProjectCardProps = { workspaceSlug: string; @@ -85,6 +86,14 @@ export const ProjectCard: React.FC = (props) => {
    + {!isMember ? ( ) : ( - +
    )} -
    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 30348e58789..00000000000 --- 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 - -
    -
    - -
    -
    -