Merge pull request #314 from makeplane/develop

* chore: update all backend dependencies to the latest version

* feat: record issue completed at date when the issues are moved to fompleted group (#262)

* feat: cycle status (#265)

* feat: cycle status and dates added in sidebar

* feat: update status added

* chore: update python runtime

* feat: label grouping in dropdowns, default state in project settings (#266)

* feat: label grouping in dropdowns, default state in project settings

* feat: label disclosure default open

* refactor: label setting page

* chore: tooltip component updated

* chore: tooltip component updated

* feat/state_sequence_change

* fix: remirror buttons (#267)

* feat: burndown chart (#268)

* chore: recharts dependencie added

* chore: tpye added for issue completed at

* feat: date range helper fn added

* feat: progress chart added

* feat: ideal task line added in progress chart

* feat: chart legends added

* fix: state reordering (#269)

* fix: state reordering

* refactor: remove unnecessary argument

* refactor: mutation after setting default

* feat: drag and drop an issue to delete (#270)

* feat: drag and drop an issue to delete

* style: repositioned trash box

* feat : cycle sidebar revamp (#271)

* feat: range date picker added

* feat: cycle status ui improved

* feat : sidebar progress improvement (#272)

* feat: progress chart render validation

* fix: sidebar stats tab

* feat: sidebar active tab context

* chore: removed minor bugs (#273)

* fix: ui bug (#274)

* fix: shortcut search fix
shortcut modal ui fixes
shortcut search fix
email us label change
* fix: email us label updated

* feat: default state for project (#264)

* build: add channels requirement for the asgi configuration (#225)

* refactor: combine sign in and sign up endpoint to a single endpoint (#263)

* feat: state grouping and ordering list (#253)

* feat: state grouping and ordering list

* fix: state grouping in state list endpoint

* dev: added migrations for new models schema changes

* fix: mac text copy fix (#277)

* feat: state description in settings (#275)

* chore: removed minor bugs

* feat: state description in settings

* feat: group by assignee

* refactor: update django admin panel heading (#276)

* feat: create label option in create issue modal (#281)

* refactor: issue details page (#282)

* fix: shortcut search  (#283)

* fix: search case innsensitive

* style: email icon updated

* feat: module sidebar date and status updated (#285)

* feat: bulk assign sub-issues (#284)

* fix: consistent dropdowns, refactor: ui components (#286)

* build(deps): bump django in /apiserver/requirements (#289)

Bumps [django](https://github.com/django/django) from 3.2.17 to 3.2.18.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.2.17...3.2.18)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix: workspace name and breadcrumb title , refactor: command palette (#290)

* refactor: command pallette

* fix: workspace name trim

* fix: breadcrumb title responsiveness added

* feat: copy link option (#292)

* feat: copy issue link added in issue card

* feat: copy cycle link added

* feat: ellipsis added in module card

* fix: origin path and handlecopytext added

* fix: remirror image not updating (#294)

* feat: resend login magic code  (#291)

* feat: resend login code on signing page after 30 seconds

* feat: handling error on code send

* refractor: isResendDisabled varible for resend button

* dev: timer count-down hook

* refractor: using new timer hook in sign in page

* feat: issue links (#288)

* feat: links for issues

* fix: add issue link in serilaizer

* feat: links can be added to issues

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>

* fix: default label color (#295)

* fix: colors of old labels can now be changed

* fix: black color for labels with no color

* fix: ui changes (#297)

* fix: module card height and invalid date

* fix: issue details page title resizing fix

* refractor: use local storage hook (#293)

* feat: resend login code on signing page after 30 seconds

* refractor: use local storage hook

* refractor: properly using new local storage hook on modules sidebar

* fix: assignee and labels field while editing an issue (#296)

* fix: assignee and labels field while editing an issue

* chore: remove unused declarations

* fix: issue title resizing fix (#300)

* fix: issue title resizing fix

* fix: header ui fix and invalid date label updated

* fix: try/catch for invalid values stored in local storage (#301)

* fix: create issue modal closing on clicking on Grammarly recommendation (#299)

fixed it by not closing modal on outside click

* style: not showing pointer & theme color on resend code button disabled (#298)

* fix: new project issues response (#303)

* refactor/cycles_folder_structure (#304)

* fix: ui changes (#306)

* fix: sidebar date range

* fix: renamed key with id in filters

* fix: replace progress bar

* chore: react progress bar package removed

* fix: progress chart legends position

* fix: progress chart legends alignment fix

* feat: manual ordering of issues (#305)

* feat: global component for links list (#307)

* Feat: Dockerizing using nginx reverse proxy (#280)

* minor docker fixes

* eslint config changes

* dockerfile changes to backend and frontend

* oauth enabled env flag

* sentry enabled env flag

* build: get alternatives for environment variables and static file storage

* build: automatically generate random secret key if not provided

* build: update docker compose for next url env add channels to requirements for asgi server and save files in local machine for docker environment

* build: update nginx conf for backend base url update backend dockerfile to make way for static file uploads

* feat: create a default user with given values else default values

* chore: update docker python version and other dependency version in docker

* build: update local settings file to run it in docker

* fix: update script to run in default production setting

* fix: env variable changes and env setup shell script added

* Added Single Dockerfile to run the Entire plane application

* docs build fixes

---------

Co-authored-by: Narayana <narayana.vadapalli1996@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>

* feat: edit module (#309)

* feat: edit module

* fix: build fix

* refactor: dnd function (#308)

* refactor: manual ordering bugs (#312)

* refactor: create issue modal input fields (#310)

* style: showing user sign-in progress on sign-in with code (#311)

* style: not showing pointer & theme color on resend code button disabled

* style: showing user sign-in progress on sign-in with code

* style: showing error from server on sign-in with code fail

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia1001@gmail.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: vamsi <vamsi.kurama@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Narayana <narayana.vadapalli1996@gmail.com>
This commit is contained in:
sriram veeraghanta 2023-02-21 19:17:32 +05:30 committed by GitHub
commit 1b94c7b640
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
195 changed files with 14238 additions and 14883 deletions

View File

@ -1,10 +1,10 @@
module.exports = { module.exports = {
root: true, root: true,
// This tells ESLint to load the config from the package `config` // This tells ESLint to load the config from the package `eslint-config-custom`
// extends: ["custom"], extends: ["custom"],
settings: { settings: {
next: { next: {
rootDir: ["apps/*/"], rootDir: ["apps/*"],
}, },
}, },
}; };

7
.gitignore vendored
View File

@ -64,4 +64,9 @@ package-lock.json
.vscode .vscode
# Sentry # Sentry
.sentryclirc .sentryclirc
# lock files
package-lock.json
pnpm-lock.yaml
pnpm-workspace.yaml

116
Dockerfile Normal file
View File

@ -0,0 +1,116 @@
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
RUN yarn global add turbo
COPY . .
RUN turbo prune --scope=app --docker
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install
# Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
RUN yarn turbo run build --filter=app
FROM python:3.11.1-alpine3.17 AS backend
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /code
RUN apk --update --no-cache add \
"libpq~=15" \
"libxslt~=1.1" \
"nodejs-current~=19" \
"xmlsec~=1.2" \
"nginx" \
"nodejs" \
"npm" \
"supervisor"
COPY apiserver/requirements.txt ./
COPY apiserver/requirements ./requirements
RUN apk add libffi-dev
RUN apk --update --no-cache --virtual .build-deps add \
"bash~=5.2" \
"g++~=12.2" \
"gcc~=12.2" \
"cargo~=1.64" \
"git~=2" \
"make~=4.3" \
"postgresql13-dev~=13" \
"libc-dev" \
"linux-headers" \
&& \
pip install -r requirements.txt --compile --no-cache-dir \
&& \
apk del .build-deps
# Add in Django deps and generate Django's static files
COPY apiserver/manage.py manage.py
COPY apiserver/plane plane/
COPY apiserver/templates templates/
COPY apiserver/gunicorn.config.py ./
RUN apk --update --no-cache add "bash~=5.2"
COPY apiserver/bin ./bin/
RUN chmod +x ./bin/takeoff ./bin/worker
RUN chmod -R 777 /code
# Expose container port and run entry point script
EXPOSE 8000
EXPOSE 3000
EXPOSE 80
WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 plane
RUN adduser --system --uid 1001 captain
COPY --from=installer /app/apps/app/next.config.js .
COPY --from=installer /app/apps/app/package.json .
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
ENV NEXT_TELEMETRY_DISABLED 1
# RUN rm /etc/nginx/conf.d/default.conf
#######################################################################
COPY nginx/nginx-single-docker-image.conf /etc/nginx/http.d/default.conf
#######################################################################
COPY nginx/supervisor.conf /code/supervisor.conf
CMD ["supervisord","-c","/code/supervisor.conf"]

View File

@ -1,18 +1,22 @@
# Backend
SECRET_KEY="<-- django secret -->" 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="<-- email smtp -->"
EMAIL_HOST_USER="<-- email host user -->" EMAIL_HOST_USER="<-- email host user -->"
EMAIL_HOST_PASSWORD="<-- email host password -->" EMAIL_HOST_PASSWORD="<-- email host password -->"
# AWS
AWS_REGION="<-- aws region -->" AWS_REGION="<-- aws region -->"
AWS_ACCESS_KEY_ID="<-- aws access key -->" AWS_ACCESS_KEY_ID="<-- aws access key -->"
AWS_SECRET_ACCESS_KEY="<-- aws secret acess key -->" AWS_SECRET_ACCESS_KEY="<-- aws secret acess key -->"
AWS_S3_BUCKET_NAME="<-- aws s3 bucket name -->" AWS_S3_BUCKET_NAME="<-- aws s3 bucket name -->"
# FE
SENTRY_DSN="<-- sentry dsn -->" WEB_URL="localhost/"
WEB_URL="<-- frontend web url -->" # OAUTH
GITHUB_CLIENT_SECRET="<-- github secret -->" GITHUB_CLIENT_SECRET="<-- github secret -->"
# Flags
DISABLE_COLLECTSTATIC=1 DISABLE_COLLECTSTATIC=1
DOCKERIZED=0 //True if running docker compose else 0 DOCKERIZED=1

View File

@ -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 # set environment variables
ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONDONTWRITEBYTECODE 1
@ -8,19 +8,19 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /code WORKDIR /code
RUN apk --update --no-cache add \ RUN apk --update --no-cache add \
"libpq~=14" \ "libpq~=15" \
"libxslt~=1.1" \ "libxslt~=1.1" \
"nodejs-current~=18" \ "nodejs-current~=19" \
"xmlsec~=1.2" "xmlsec~=1.2"
COPY requirements.txt ./ COPY requirements.txt ./
COPY requirements ./requirements COPY requirements ./requirements
RUN apk add libffi-dev RUN apk add libffi-dev
RUN apk --update --no-cache --virtual .build-deps add \ RUN apk --update --no-cache --virtual .build-deps add \
"bash~=5.1" \ "bash~=5.2" \
"g++~=11.2" \ "g++~=12.2" \
"gcc~=11.2" \ "gcc~=12.2" \
"cargo~=1.60" \ "cargo~=1.64" \
"git~=2" \ "git~=2" \
"make~=4.3" \ "make~=4.3" \
"postgresql13-dev~=13" \ "postgresql13-dev~=13" \
@ -46,15 +46,16 @@ COPY templates templates/
COPY gunicorn.config.py ./ COPY gunicorn.config.py ./
USER root USER root
RUN apk --update --no-cache add "bash~=5.1" RUN apk --update --no-cache add "bash~=5.2"
COPY ./bin ./bin/ COPY ./bin ./bin/
RUN chmod +x ./bin/takeoff ./bin/worker RUN chmod +x ./bin/takeoff ./bin/worker
RUN chmod -R 777 /code
USER captain USER captain
# Expose container port and run entry point script # Expose container port and run entry point script
EXPOSE 8000 EXPOSE 8000
CMD [ "./bin/takeoff" ] # CMD [ "./bin/takeoff" ]

View File

@ -2,4 +2,8 @@
set -e set -e
python manage.py wait_for_db python manage.py wait_for_db
python manage.py migrate 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 - 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 -

View File

@ -0,0 +1,28 @@
import os, sys
import uuid
sys.path.append("/code")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
import django
django.setup()
from plane.db.models import User
def populate():
default_email = os.environ.get("DEFAULT_EMAIL", "captain@plane.so")
default_password = os.environ.get("DEFAULT_PASSWORD", "password123")
if not User.objects.filter(email=default_email).exists():
user = User.objects.create(email=default_email, username=uuid.uuid4().hex)
user.set_password(default_password)
user.save()
print("User created")
print("Success")
if __name__ == "__main__":
populate()

View File

@ -24,9 +24,15 @@ from plane.db.models import (
Cycle, Cycle,
Module, Module,
ModuleIssue, ModuleIssue,
IssueLink,
) )
class IssueLinkCreateSerializer(serializers.Serializer):
url = serializers.CharField(required=True)
title = serializers.CharField(required=False)
class IssueFlatSerializer(BaseSerializer): class IssueFlatSerializer(BaseSerializer):
## Contain only flat fields ## Contain only flat fields
@ -86,6 +92,11 @@ class IssueCreateSerializer(BaseSerializer):
write_only=True, write_only=True,
required=False, required=False,
) )
links_list = serializers.ListField(
child=IssueLinkCreateSerializer(),
write_only=True,
required=False,
)
class Meta: class Meta:
model = Issue model = Issue
@ -104,6 +115,7 @@ class IssueCreateSerializer(BaseSerializer):
assignees = validated_data.pop("assignees_list", None) assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None) labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None) blocks = validated_data.pop("blocks_list", None)
links = validated_data.pop("links_list", None)
project = self.context["project"] project = self.context["project"]
issue = Issue.objects.create(**validated_data, project=project) issue = Issue.objects.create(**validated_data, project=project)
@ -172,6 +184,24 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10, 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 return issue
def update(self, instance, validated_data): def update(self, instance, validated_data):
@ -179,6 +209,7 @@ class IssueCreateSerializer(BaseSerializer):
assignees = validated_data.pop("assignees_list", None) assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None) labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None) blocks = validated_data.pop("blocks_list", None)
links = validated_data.pop("links_list", None)
if blockers is not None: if blockers is not None:
IssueBlocker.objects.filter(block=instance).delete() IssueBlocker.objects.filter(block=instance).delete()
@ -248,6 +279,25 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10, 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) return super().update(instance, validated_data)
@ -410,6 +460,12 @@ class IssueModuleDetailSerializer(BaseSerializer):
] ]
class IssueLinkSerializer(BaseSerializer):
class Meta:
model = IssueLink
fields = "__all__"
class IssueSerializer(BaseSerializer): class IssueSerializer(BaseSerializer):
project_detail = ProjectSerializer(read_only=True, source="project") project_detail = ProjectSerializer(read_only=True, source="project")
state_detail = StateSerializer(read_only=True, source="state") state_detail = StateSerializer(read_only=True, source="state")
@ -422,6 +478,7 @@ class IssueSerializer(BaseSerializer):
blocker_issues = BlockerIssueSerializer(read_only=True, many=True) blocker_issues = BlockerIssueSerializer(read_only=True, many=True)
issue_cycle = IssueCycleDetailSerializer(read_only=True) issue_cycle = IssueCycleDetailSerializer(read_only=True)
issue_module = IssueModuleDetailSerializer(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) sub_issues_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:

View File

@ -5,7 +5,6 @@ from django.urls import path
from plane.api.views import ( from plane.api.views import (
# Authentication # Authentication
SignUpEndpoint,
SignInEndpoint, SignInEndpoint,
SignOutEndpoint, SignOutEndpoint,
MagicSignInEndpoint, MagicSignInEndpoint,
@ -95,7 +94,6 @@ urlpatterns = [
path("social-auth/", OauthEndpoint.as_view(), name="oauth"), path("social-auth/", OauthEndpoint.as_view(), name="oauth"),
# Auth # Auth
path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), 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"), path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"),
# Magic Sign In/Up # Magic Sign In/Up
path( path(

View File

@ -64,7 +64,6 @@ from .auth_extended import (
from .authentication import ( from .authentication import (
SignUpEndpoint,
SignInEndpoint, SignInEndpoint,
SignOutEndpoint, SignOutEndpoint,
MagicSignInEndpoint, MagicSignInEndpoint,

View File

@ -84,7 +84,7 @@ class ForgotPasswordEndpoint(BaseAPIView):
) )
return Response( return Response(
{"messgae": "Check your email to reset your password"}, {"message": "Check your email to reset your password"},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
return Response( return Response(

View File

@ -35,7 +35,7 @@ def get_tokens_for_user(user):
) )
class SignUpEndpoint(BaseAPIView): class SignInEndpoint(BaseAPIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
def post(self, request): def post(self, request):
@ -62,114 +62,67 @@ class SignUpEndpoint(BaseAPIView):
user = User.objects.filter(email=email).first() user = User.objects.filter(email=email).first()
if user is not None: # Sign up Process
return Response( if user is None:
{"error": "Email ID is already taken"}, user = User.objects.create(email=email, username=uuid.uuid4().hex)
status=status.HTTP_400_BAD_REQUEST, user.set_password(password)
)
user = User.objects.create(email=email) # settings last actives for the user
user.set_password(password) 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 actives for the user serialized_user = UserSerializer(user).data
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)
access_token, refresh_token = get_tokens_for_user(user) data = {
"access_token": access_token,
"refresh_token": refresh_token,
"user": serialized_user,
}
data = { return Response(data, status=status.HTTP_200_OK)
"access_token": access_token, # Sign in Process
"refresh_token": refresh_token, else:
"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) serialized_user = UserSerializer(user).data
except Exception as e: # settings last active for the user
capture_exception(e) user.last_active = timezone.now()
return Response( user.last_login_time = timezone.now()
{ user.last_login_ip = request.META.get("REMOTE_ADDR")
"error": "Something went wrong. Please try again later or contact the support team." user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
}, user.token_updated_at = timezone.now()
status=status.HTTP_400_BAD_REQUEST, user.save()
)
access_token, refresh_token = get_tokens_for_user(user)
class SignInEndpoint(BaseAPIView): data = {
permission_classes = (AllowAny,) "access_token": access_token,
"refresh_token": refresh_token,
"user": serialized_user,
}
def post(self, request): return Response(data, status=status.HTTP_200_OK)
try:
email = request.data.get("email", False)
password = request.data.get("password", False)
## 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)
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
# 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)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
"user": serialized_user,
}
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: except Exception as e:
capture_exception(e) capture_exception(e)
return Response( return Response(

View File

@ -39,6 +39,7 @@ from plane.db.models import (
IssueBlocker, IssueBlocker,
CycleIssue, CycleIssue,
ModuleIssue, ModuleIssue,
IssueLink,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
@ -75,7 +76,6 @@ class IssueViewSet(BaseViewSet):
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
) )
if current_instance is not None: if current_instance is not None:
issue_activity.delay( issue_activity.delay(
{ {
"type": "issue.activity", "type": "issue.activity",
@ -92,7 +92,6 @@ class IssueViewSet(BaseViewSet):
return super().perform_update(serializer) return super().perform_update(serializer)
def get_queryset(self): def get_queryset(self):
return ( return (
super() super()
.get_queryset() .get_queryset()
@ -136,6 +135,12 @@ class IssueViewSet(BaseViewSet):
).prefetch_related("module__members"), ).prefetch_related("module__members"),
), ),
) )
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("issue"),
)
)
) )
def grouper(self, issue, group_by): def grouper(self, issue, group_by):
@ -265,6 +270,12 @@ class UserWorkSpaceIssues(BaseAPIView):
queryset=ModuleIssue.objects.select_related("module", "issue"), queryset=ModuleIssue.objects.select_related("module", "issue"),
), ),
) )
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("issue"),
)
)
) )
serializer = IssueSerializer(issues, many=True) serializer = IssueSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@ -277,7 +288,6 @@ class UserWorkSpaceIssues(BaseAPIView):
class WorkSpaceIssuesEndpoint(BaseAPIView): class WorkSpaceIssuesEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
WorkSpaceAdminPermission, WorkSpaceAdminPermission,
] ]
@ -298,7 +308,6 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
class IssueActivityEndpoint(BaseAPIView): class IssueActivityEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]
@ -333,7 +342,6 @@ class IssueActivityEndpoint(BaseAPIView):
class IssueCommentViewSet(BaseViewSet): class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer serializer_class = IssueCommentSerializer
model = IssueComment model = IssueComment
permission_classes = [ permission_classes = [
@ -436,7 +444,6 @@ class IssuePropertyViewSet(BaseViewSet):
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
try: try:
issue_property, created = IssueProperty.objects.get_or_create( issue_property, created = IssueProperty.objects.get_or_create(
user=request.user, user=request.user,
project_id=project_id, project_id=project_id,
@ -463,7 +470,6 @@ class IssuePropertyViewSet(BaseViewSet):
class LabelViewSet(BaseViewSet): class LabelViewSet(BaseViewSet):
serializer_class = LabelSerializer serializer_class = LabelSerializer
model = Label model = Label
permission_classes = [ permission_classes = [
@ -490,14 +496,12 @@ class LabelViewSet(BaseViewSet):
class BulkDeleteIssuesEndpoint(BaseAPIView): class BulkDeleteIssuesEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]
def delete(self, request, slug, project_id): def delete(self, request, slug, project_id):
try: try:
issue_ids = request.data.get("issue_ids", []) issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids): if not len(issue_ids):
@ -527,14 +531,12 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
class SubIssuesEndpoint(BaseAPIView): class SubIssuesEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]
def get(self, request, slug, project_id, issue_id): def get(self, request, slug, project_id, issue_id):
try: try:
sub_issues = ( sub_issues = (
Issue.objects.filter( Issue.objects.filter(
parent_id=issue_id, workspace__slug=slug, project_id=project_id parent_id=issue_id, workspace__slug=slug, project_id=project_id

View File

@ -75,7 +75,6 @@ class ProjectViewSet(BaseViewSet):
def create(self, request, slug): def create(self, request, slug):
try: try:
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
serializer = ProjectSerializer( serializer = ProjectSerializer(
@ -96,6 +95,7 @@ class ProjectViewSet(BaseViewSet):
"color": "#5e6ad2", "color": "#5e6ad2",
"sequence": 15000, "sequence": 15000,
"group": "backlog", "group": "backlog",
"default": True,
}, },
{ {
"name": "Todo", "name": "Todo",
@ -132,6 +132,7 @@ class ProjectViewSet(BaseViewSet):
sequence=state["sequence"], sequence=state["sequence"],
workspace=serializer.instance.workspace, workspace=serializer.instance.workspace,
group=state["group"], group=state["group"],
default=state.get("default", False),
) )
for state in states for state in states
] ]
@ -188,7 +189,7 @@ class ProjectViewSet(BaseViewSet):
{"name": "The project name is already taken"}, {"name": "The project name is already taken"},
status=status.HTTP_410_GONE, status=status.HTTP_410_GONE,
) )
except (Project.DoesNotExist or Workspace.DoesNotExist) as e: except Project.DoesNotExist or Workspace.DoesNotExist as e:
return Response( return Response(
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
) )
@ -206,14 +207,12 @@ class ProjectViewSet(BaseViewSet):
class InviteProjectEndpoint(BaseAPIView): class InviteProjectEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectBasePermission, ProjectBasePermission,
] ]
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
try: try:
email = request.data.get("email", False) email = request.data.get("email", False)
role = request.data.get("role", False) role = request.data.get("role", False)
@ -287,7 +286,6 @@ class InviteProjectEndpoint(BaseAPIView):
class UserProjectInvitationsViewset(BaseViewSet): class UserProjectInvitationsViewset(BaseViewSet):
serializer_class = ProjectMemberInviteSerializer serializer_class = ProjectMemberInviteSerializer
model = ProjectMemberInvite model = ProjectMemberInvite
@ -301,7 +299,6 @@ class UserProjectInvitationsViewset(BaseViewSet):
def create(self, request): def create(self, request):
try: try:
invitations = request.data.get("invitations") invitations = request.data.get("invitations")
project_invitations = ProjectMemberInvite.objects.filter( project_invitations = ProjectMemberInvite.objects.filter(
pk__in=invitations, accepted=True pk__in=invitations, accepted=True
@ -331,7 +328,6 @@ class UserProjectInvitationsViewset(BaseViewSet):
class ProjectMemberViewSet(BaseViewSet): class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberSerializer serializer_class = ProjectMemberSerializer
model = ProjectMember model = ProjectMember
permission_classes = [ permission_classes = [
@ -356,14 +352,12 @@ class ProjectMemberViewSet(BaseViewSet):
class AddMemberToProjectEndpoint(BaseAPIView): class AddMemberToProjectEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectBasePermission, ProjectBasePermission,
] ]
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
try: try:
member_id = request.data.get("member_id", False) member_id = request.data.get("member_id", False)
role = request.data.get("role", False) role = request.data.get("role", False)
@ -412,13 +406,11 @@ class AddMemberToProjectEndpoint(BaseAPIView):
class AddTeamToProjectEndpoint(BaseAPIView): class AddTeamToProjectEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectBasePermission, ProjectBasePermission,
] ]
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
try: try:
team_members = TeamMember.objects.filter( team_members = TeamMember.objects.filter(
workspace__slug=slug, team__in=request.data.get("teams", []) workspace__slug=slug, team__in=request.data.get("teams", [])
@ -467,7 +459,6 @@ class AddTeamToProjectEndpoint(BaseAPIView):
class ProjectMemberInvitationsViewset(BaseViewSet): class ProjectMemberInvitationsViewset(BaseViewSet):
serializer_class = ProjectMemberInviteSerializer serializer_class = ProjectMemberInviteSerializer
model = ProjectMemberInvite model = ProjectMemberInvite
@ -489,7 +480,6 @@ class ProjectMemberInvitationsViewset(BaseViewSet):
class ProjectMemberInviteDetailViewSet(BaseViewSet): class ProjectMemberInviteDetailViewSet(BaseViewSet):
serializer_class = ProjectMemberInviteSerializer serializer_class = ProjectMemberInviteSerializer
model = ProjectMemberInvite model = ProjectMemberInvite
@ -509,14 +499,12 @@ class ProjectMemberInviteDetailViewSet(BaseViewSet):
class ProjectIdentifierEndpoint(BaseAPIView): class ProjectIdentifierEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectBasePermission, ProjectBasePermission,
] ]
def get(self, request, slug): def get(self, request, slug):
try: try:
name = request.GET.get("name", "").strip().upper() name = request.GET.get("name", "").strip().upper()
if name == "": if name == "":
@ -541,7 +529,6 @@ class ProjectIdentifierEndpoint(BaseAPIView):
def delete(self, request, slug): def delete(self, request, slug):
try: try:
name = request.data.get("name", "").strip().upper() name = request.data.get("name", "").strip().upper()
if name == "": if name == "":
@ -616,7 +603,6 @@ class ProjectJoinEndpoint(BaseAPIView):
class ProjectUserViewsEndpoint(BaseAPIView): class ProjectUserViewsEndpoint(BaseAPIView):
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
try: try:
project = Project.objects.get(pk=project_id, workspace__slug=slug) project = Project.objects.get(pk=project_id, workspace__slug=slug)
project_member = ProjectMember.objects.filter( project_member = ProjectMember.objects.filter(
@ -655,7 +641,6 @@ class ProjectUserViewsEndpoint(BaseAPIView):
class ProjectMemberUserEndpoint(BaseAPIView): class ProjectMemberUserEndpoint(BaseAPIView):
def get(self, request, slug, project_id): def get(self, request, slug, project_id):
try: try:
project_member = ProjectMember.objects.get( project_member = ProjectMember.objects.get(
project_id=project_id, workspace__slug=slug, member=request.user project_id=project_id, workspace__slug=slug, member=request.user
) )

View File

@ -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 # Module imports
from . import BaseViewSet from . import BaseViewSet
from plane.api.serializers import StateSerializer from plane.api.serializers import StateSerializer
@ -6,7 +15,6 @@ from plane.db.models import State
class StateViewSet(BaseViewSet): class StateViewSet(BaseViewSet):
serializer_class = StateSerializer serializer_class = StateSerializer
model = State model = State
permission_classes = [ permission_classes = [
@ -27,3 +35,38 @@ class StateViewSet(BaseViewSet):
.select_related("workspace") .select_related("workspace")
.distinct() .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)

View File

@ -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='<p></p>'),
),
migrations.AlterField(
model_name='issuecomment',
name='comment_html',
field=models.TextField(blank=True, default='<p></p>'),
),
migrations.AlterField(
model_name='issuecomment',
name='comment_json',
field=models.JSONField(blank=True, default=dict),
),
]

View File

@ -23,6 +23,7 @@ from .issue import (
IssueAssignee, IssueAssignee,
Label, Label,
IssueBlocker, IssueBlocker,
IssueLink,
) )
from .asset import FileAsset from .asset import FileAsset

View File

@ -4,6 +4,7 @@ from django.db import models
from django.conf import settings from django.conf import settings
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone
# Module imports # Module imports
from . import ProjectBaseModel from . import ProjectBaseModel
@ -58,6 +59,7 @@ class Issue(ProjectBaseModel):
"db.Label", blank=True, related_name="labels", through="IssueLabel" "db.Label", blank=True, related_name="labels", through="IssueLabel"
) )
sort_order = models.FloatField(default=65535) sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
class Meta: class Meta:
verbose_name = "Issue" verbose_name = "Issue"
@ -81,12 +83,32 @@ class Issue(ProjectBaseModel):
try: try:
from plane.db.models import State from plane.db.models import State
self.state, created = State.objects.get_or_create( default_state = State.objects.filter(
project=self.project, name="Backlog" 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: except ImportError:
pass 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 # Strip the html tags using html parser
self.description_stripped = ( self.description_stripped = (
None None
@ -139,6 +161,23 @@ class IssueAssignee(ProjectBaseModel):
return f"{self.issue.name} {self.assignee.email}" 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): class IssueActivity(ProjectBaseModel):
issue = models.ForeignKey( issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_activity" Issue, on_delete=models.CASCADE, related_name="issue_activity"

View File

@ -23,6 +23,7 @@ class State(ProjectBaseModel):
default="backlog", default="backlog",
max_length=20, max_length=20,
) )
default = models.BooleanField(default=False)
def __str__(self): def __str__(self):
"""Return name of the state""" """Return name of the state"""

View File

@ -1,12 +1,13 @@
import os import os
import datetime import datetime
from datetime import timedelta 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__))) 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! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True

View File

@ -2,6 +2,7 @@
from __future__ import absolute_import from __future__ import absolute_import
import dj_database_url
import sentry_sdk import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration from sentry_sdk.integrations.redis import RedisIntegration
@ -24,6 +25,10 @@ DATABASES = {
} }
} }
DOCKERIZED = os.environ.get("DOCKERIZED", False)
if DOCKERIZED:
DATABASES["default"] = dj_database_url.config()
CACHES = { CACHES = {
"default": { "default": {
@ -41,15 +46,16 @@ INTERNAL_IPS = ("127.0.0.1",)
CORS_ORIGIN_ALLOW_ALL = True CORS_ORIGIN_ALLOW_ALL = True
sentry_sdk.init( if os.environ.get("SENTRY_DSN", False):
dsn=os.environ.get("SENTRY_DSN"), sentry_sdk.init(
integrations=[DjangoIntegration(), RedisIntegration()], dsn=os.environ.get("SENTRY_DSN"),
# If you wish to associate users to errors (assuming you are using integrations=[DjangoIntegration(), RedisIntegration()],
# django.contrib.auth) you may enable sending PII data. # If you wish to associate users to errors (assuming you are using
send_default_pii=True, # django.contrib.auth) you may enable sending PII data.
environment="local", send_default_pii=True,
traces_sample_rate=0.7, environment="local",
) traces_sample_rate=0.7,
)
REDIS_HOST = "localhost" REDIS_HOST = "localhost"
REDIS_PORT = 6379 REDIS_PORT = 6379
@ -64,5 +70,10 @@ RQ_QUEUES = {
}, },
} }
WEB_URL = "http://localhost:3000" MEDIA_URL = "/uploads/"
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
if DOCKERIZED:
REDIS_URL = os.environ.get("REDIS_URL")
WEB_URL = os.environ.get("WEB_URL", "localhost:3000")

View File

@ -33,6 +33,10 @@ CORS_ORIGIN_WHITELIST = [
DATABASES["default"] = dj_database_url.config() DATABASES["default"] = dj_database_url.config()
SITE_ID = 1 SITE_ID = 1
DOCKERIZED = os.environ.get(
"DOCKERIZED", False
) # Set the variable true if running in docker-compose environment
# Enable Connection Pooling (if desired) # Enable Connection Pooling (if desired)
# DATABASES['default']['ENGINE'] = 'django_postgrespool' # DATABASES['default']['ENGINE'] = 'django_postgrespool'
@ -48,99 +52,110 @@ CORS_ALLOW_ALL_ORIGINS = True
# Simplified static file serving. # Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" 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( if (
dsn=os.environ.get("SENTRY_DSN"), os.environ.get("AWS_REGION", False)
integrations=[DjangoIntegration(), RedisIntegration()], and os.environ.get("AWS_ACCESS_KEY_ID", False)
# If you wish to associate users to errors (assuming you are using and os.environ.get("AWS_SECRET_ACCESS_KEY", False)
# django.contrib.auth) you may enable sending PII data. and os.environ.get("AWS_S3_BUCKET_NAME", False)
traces_sample_rate=1, ):
send_default_pii=True, # The AWS region to connect to.
environment="production", AWS_REGION = os.environ.get("AWS_REGION", "")
)
# The AWS region to connect to. # The AWS access key to use.
AWS_REGION = os.environ.get("AWS_REGION") AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
# The AWS access key to use. # The AWS secret access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
# The AWS secret access key to use. # The optional AWS session token to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY") # AWS_SESSION_TOKEN = ""
# The optional AWS session token to use. # The name of the bucket to store files in.
# AWS_SESSION_TOKEN = "" 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. # The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") AWS_S3_ENDPOINT_URL = ""
# How to construct S3 URLs ("auto", "path", "virtual"). # A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
AWS_S3_ADDRESSING_STYLE = "auto" AWS_S3_KEY_PREFIX = ""
# The full URL to the S3 endpoint. Leave blank to use the default region URL. # Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
AWS_S3_ENDPOINT_URL = "" # 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. # How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
AWS_S3_KEY_PREFIX = "" # 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 # A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, # cannot be used with `AWS_S3_BUCKET_AUTH`.
# and their permissions will be set to "public-read". AWS_S3_PUBLIC_URL = ""
AWS_S3_BUCKET_AUTH = False
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` # If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
# is True. It also affects the "Cache-Control" header of the files. # understand the consequences before enabling.
# Important: Changing this setting will not affect existing files. # Important: Changing this setting will not affect existing files.
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours. 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 # The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
# cannot be used with `AWS_S3_BUCKET_AUTH`. # single `name` argument.
AWS_S3_PUBLIC_URL = "" # 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 # The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
# understand the consequences before enabling. # single `name` argument.
# Important: Changing this setting will not affect existing files. # Important: Changing this setting will not affect existing files.
AWS_S3_REDUCED_REDUNDANCY = False AWS_S3_CONTENT_LANGUAGE = ""
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a # A mapping of custom metadata for each file. Each value can be a string, or a function taking a
# single `name` argument. # single `name` argument.
# Important: Changing this setting will not affect existing files. # Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_DISPOSITION = "" AWS_S3_METADATA = {}
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a # If True, then files will be stored using AES256 server-side encryption.
# single `name` argument. # If this is a string value (e.g., "aws:kms"), that encryption type will be used.
# Important: Changing this setting will not affect existing files. # Otherwise, server-side encryption is not be enabled.
AWS_S3_CONTENT_LANGUAGE = "" # 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 # The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
# single `name` argument. # This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
# Important: Changing this setting will not affect existing files. # AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
AWS_S3_METADATA = {}
# If True, then files will be stored using AES256 server-side encryption. # If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
# If this is a string value (e.g., "aws:kms"), that encryption type will be used. # compressed size is smaller than their uncompressed size.
# Otherwise, server-side encryption is not be enabled. # Important: Changing this setting will not affect existing files.
# Important: Changing this setting will not affect existing files. AWS_S3_GZIP = True
AWS_S3_ENCRYPT_KEY = False
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present. # The signature version to use for S3 requests.
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above). AWS_S3_SIGNATURE_VERSION = None
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their # If True, then files with the same name will overwrite each other. By default it's set to False to have
# compressed size is smaller than their uncompressed size. # extra characters appended.
# Important: Changing this setting will not affect existing files. AWS_S3_FILE_OVERWRITE = False
AWS_S3_GZIP = True
# The signature version to use for S3 requests. # AWS Settings End
AWS_S3_SIGNATURE_VERSION = None
# If True, then files with the same name will overwrite each other. By default it's set to False to have DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
# extra characters appended.
AWS_S3_FILE_OVERWRITE = False
# AWS Settings End else:
MEDIA_URL = "/uploads/"
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
# Enable Connection Pooling (if desired) # Enable Connection Pooling (if desired)
@ -155,7 +170,6 @@ ALLOWED_HOSTS = [
] ]
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
# Simplified static file serving. # Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
@ -165,16 +179,27 @@ CSRF_COOKIE_SECURE = True
REDIS_URL = os.environ.get("REDIS_URL") REDIS_URL = os.environ.get("REDIS_URL")
CACHES = { if DOCKERIZED:
"default": { CACHES = {
"BACKEND": "django_redis.cache.RedisCache", "default": {
"LOCATION": REDIS_URL, "BACKEND": "django_redis.cache.RedisCache",
"OPTIONS": { "LOCATION": REDIS_URL,
"CLIENT_CLASS": "django_redis.client.DefaultClient", "OPTIONS": {
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, "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 = { RQ_QUEUES = {
"default": { "default": {
@ -183,10 +208,4 @@ RQ_QUEUES = {
} }
url = urlparse(os.environ.get("REDIS_URL"))
DOCKERIZED = os.environ.get(
"DOCKERIZED", False
) # Set the variable true if running in docker-compose environment
WEB_URL = os.environ.get("WEB_URL") WEB_URL = os.environ.get("WEB_URL")

View File

@ -1,28 +1,29 @@
# base requirements # base requirements
Django==3.2.17 Django==3.2.18
django-braces==1.15.0 django-braces==1.15.0
django-taggit==2.1.0 django-taggit==3.1.0
psycopg2==2.9.3 psycopg2==2.9.5
django-oauth-toolkit==2.0.0 django-oauth-toolkit==2.2.0
mistune==2.0.3 mistune==2.0.4
djangorestframework==3.14.0 djangorestframework==3.14.0
redis==4.2.2 redis==4.4.2
django-nested-admin==3.4.0 django-nested-admin==4.0.2
django-cors-headers==3.11.0 django-cors-headers==3.13.0
whitenoise==6.0.0 whitenoise==6.3.0
django-allauth==0.50.0 django-allauth==0.52.0
faker==13.4.0 faker==13.4.0
django-filter==21.1 django-filter==22.1
jsonmodels==2.5.0 jsonmodels==2.6.0
djangorestframework-simplejwt==5.1.0 djangorestframework-simplejwt==5.2.2
sentry-sdk==1.13.0 sentry-sdk==1.14.0
django-s3-storage==0.13.6 django-s3-storage==0.13.11
django-crum==0.7.9 django-crum==0.7.9
django-guardian==2.4.0 django-guardian==2.4.0
dj_rest_auth==2.2.5 dj_rest_auth==2.2.5
google-auth==2.9.1 google-auth==2.16.0
google-api-python-client==2.55.0 google-api-python-client==2.75.0
django-rq==2.5.1 django-rq==2.6.0
django-redis==5.2.0 django-redis==5.2.0
uvicorn==0.20.0 uvicorn==0.20.0
channels==4.0.0

View File

@ -1,3 +1,3 @@
-r base.txt -r base.txt
django-debug-toolbar==3.2.4 django-debug-toolbar==3.8.1

View File

@ -1,12 +1,12 @@
-r base.txt -r base.txt
dj-database-url==0.5.0 dj-database-url==1.2.0
gunicorn==20.1.0 gunicorn==20.1.0
whitenoise==6.0.0 whitenoise==6.3.0
django-storages==1.12.3 django-storages==1.13.2
boto==2.49.0 boto==2.49.0
django-anymail==8.5 django-anymail==9.0
twilio==7.8.2 twilio==7.16.2
django-debug-toolbar==3.2.4 django-debug-toolbar==3.8.1
gevent==22.10.2 gevent==22.10.2
psycogreen==1.0.2 psycogreen==1.0.2

View File

@ -1 +1 @@
python-3.11.1 python-3.11.2

View File

@ -17,7 +17,7 @@
color: #FFFFFF; color: #FFFFFF;
} }
</style> </style>
<h1 id="site-name">{% trans 'plane Admin' %} </h1> <h1 id="site-name">{% trans 'Plane Django Admin' %} </h1>
{% endblock %}{% block nav-global %}{% endblock %} {% endblock %}{% block nav-global %}{% endblock %}

6
apps/app/.env.example Normal file
View File

@ -0,0 +1,6 @@
NEXT_PUBLIC_API_BASE_URL = "localhost/"
NEXT_PUBLIC_GOOGLE_CLIENTID="<-- google client id -->"
NEXT_PUBLIC_GITHUB_ID="<-- github client id -->"
NEXT_PUBLIC_SENTRY_DSN="<-- sentry dns -->"
NEXT_PUBLIC_ENABLE_OAUTH=0
NEXT_PUBLIC_ENABLE_SENTRY=0

View File

@ -1 +1,4 @@
module.exports = require("config/.eslintrc"); module.exports = {
root: true,
extends: ["custom"],
};

12
apps/app/Dockerfile.dev Normal file
View File

@ -0,0 +1,12 @@
FROM node:18-alpine
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
COPY . .
RUN yarn global add turbo
RUN yarn install
EXPOSE 3000
CMD ["yarn","dev"]

View File

@ -4,33 +4,14 @@ RUN apk update
# Set working directory # Set working directory
WORKDIR /app 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 RUN turbo prune --scope=app --docker
# Add lockfile and package.json's of isolated subworkspace # Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer 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 add --no-cache libc6-compat
RUN apk update RUN apk update
@ -39,14 +20,14 @@ WORKDIR /app
# First install the dependencies (as they change less often) # First install the dependencies (as they change less often)
COPY .gitignore .gitignore COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ . COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN pnpm install RUN yarn install
# Build the project # Build the project
COPY --from=builder /app/out/full/ . COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json 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 FROM node:18-alpine AS runner
WORKDIR /app WORKDIR /app
@ -62,8 +43,9 @@ COPY --from=installer /app/apps/app/package.json .
# Automatically leverage output traces to reduce image size # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # 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 ./
# 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 COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
EXPOSE 3000 ENV NEXT_TELEMETRY_DISABLED 1
CMD node apps/app/server.js EXPOSE 3000

View File

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// ui // ui
import { CheckCircleIcon } from "@heroicons/react/20/solid"; import { CheckCircleIcon } from "@heroicons/react/20/solid";
@ -6,6 +6,7 @@ import { Button, Input } from "components/ui";
// services // services
import authenticationService from "services/authentication.service"; import authenticationService from "services/authentication.service";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
// icons // icons
// types // types
@ -17,12 +18,19 @@ type EmailCodeFormValues = {
export const EmailCodeForm = ({ onSuccess }: any) => { export const EmailCodeForm = ({ onSuccess }: any) => {
const [codeSent, setCodeSent] = useState(false); const [codeSent, setCodeSent] = useState(false);
const [codeResent, setCodeResent] = useState(false);
const [isCodeResending, setIsCodeResending] = useState(false);
const [errorResendingCode, setErrorResendingCode] = useState(false);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer();
const { const {
register, register,
handleSubmit, handleSubmit,
setError, setError,
setValue, setValue,
getValues,
formState: { errors, isSubmitting, isValid, isDirty }, formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailCodeFormValues>({ } = useForm<EmailCodeFormValues>({
defaultValues: { defaultValues: {
@ -34,30 +42,38 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
reValidateMode: "onChange", reValidateMode: "onChange",
}); });
const onSubmit = ({ email }: EmailCodeFormValues) => { const isResendDisabled =
authenticationService resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode;
const onSubmit = async ({ email }: EmailCodeFormValues) => {
setErrorResendingCode(false);
await authenticationService
.emailCode({ email }) .emailCode({ email })
.then((res) => { .then((res) => {
setValue("key", res.key); setValue("key", res.key);
setCodeSent(true); setCodeSent(true);
}) })
.catch((err) => { .catch((err) => {
console.log(err); setErrorResendingCode(true);
setToastAlert({
title: "Oops!",
type: "error",
message: err?.error,
});
}); });
}; };
const handleSignin = (formData: EmailCodeFormValues) => { const handleSignin = async (formData: EmailCodeFormValues) => {
authenticationService await authenticationService
.magicSignIn(formData) .magicSignIn(formData)
.then((response) => { .then((response) => {
onSuccess(response); onSuccess(response);
}) })
.catch((error) => { .catch((error) => {
console.log(error);
setToastAlert({ setToastAlert({
title: "Oops!", title: "Oops!",
type: "error", 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, { setError("token" as keyof EmailCodeFormValues, {
type: "manual", type: "manual",
@ -66,13 +82,16 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
}); });
}; };
const emailOld = getValues("email");
useEffect(() => {
setErrorResendingCode(false);
}, [emailOld]);
return ( return (
<> <>
<form <form className="mt-5 space-y-5">
className="mt-5 space-y-5" {(codeSent || codeResent) && (
onSubmit={codeSent ? handleSubmit(handleSignin) : handleSubmit(onSubmit)}
>
{codeSent && (
<div className="rounded-md bg-green-50 p-4"> <div className="rounded-md bg-green-50 p-4">
<div className="flex"> <div className="flex">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@ -80,7 +99,9 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
</div> </div>
<div className="ml-3"> <div className="ml-3">
<p className="text-sm font-medium text-green-800"> <p className="text-sm font-medium text-green-800">
Please check your mail for code. {codeResent
? "Please check your mail for new code."
: "Please check your mail for code."}
</p> </p>
</div> </div>
</div> </div>
@ -117,16 +138,59 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
error={errors.token} error={errors.token}
placeholder="Enter code" placeholder="Enter code"
/> />
<button
type="button"
className={`text-xs mt-5 w-full flex justify-end outline-none ${
isResendDisabled ? "text-gray-400 cursor-default" : "cursor-pointer text-theme"
} `}
onClick={() => {
setIsCodeResending(true);
onSubmit({ email: getValues("email") }).then(() => {
setCodeResent(true);
setIsCodeResending(false);
setResendCodeTimer(30);
});
}}
disabled={isResendDisabled}
>
{resendCodeTimer > 0 ? (
<p className="text-right">
Didn{"'"}t receive code? Get new code in {resendCodeTimer} seconds.
</p>
) : isCodeResending ? (
"Sending code..."
) : errorResendingCode ? (
"Please try again later"
) : (
"Resend code"
)}
</button>
</div> </div>
)} )}
<div> <div>
<Button {codeSent ? (
disabled={isSubmitting || (!isValid && isDirty)} <Button
className="w-full text-center" type="submit"
type="submit" className="w-full text-center"
> onClick={handleSubmit(handleSignin)}
{isSubmitting ? "Signing in..." : codeSent ? "Sign In" : "Continue with Email ID"} disabled={isSubmitting || (!isValid && isDirty)}
</Button> >
{isSubmitting ? "Signing in..." : "Sign in"}
</Button>
) : (
<Button
type="submit"
className="w-full text-center"
onClick={() => {
handleSubmit(onSubmit)().then(() => {
setResendCodeTimer(30);
});
}}
disabled={isSubmitting || (!isValid && isDirty)}
>
{isSubmitting ? "Sending code..." : "Send code"}
</Button>
)}
</div> </div>
</form> </form>
</> </>

View File

@ -44,10 +44,10 @@ const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) =>
</a> </a>
</Link> </Link>
) : ( ) : (
<div className="px-3 text-sm"> <div className="px-3 text-sm max-w-64">
<p className={`${icon ? "flex items-center gap-2" : ""}`}> <p className={`${icon ? "flex items-center gap-2" : ""}`}>
{icon} {icon}
{title} <span className="break-all">{title}</span>
</p> </p>
</div> </div>
)} )}

View File

@ -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 React, { useState, useCallback, useEffect } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -14,12 +13,12 @@ import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
// components // components
import ShortcutsModal from "components/command-palette/shortcuts"; import { ShortcutsModal } from "components/command-palette";
import { BulkDeleteIssuesModal } from "components/core"; import { BulkDeleteIssuesModal } from "components/core";
import { CreateProjectModal } from "components/project"; import { CreateProjectModal } from "components/project";
import { CreateUpdateIssueModal } from "components/issues"; import { CreateUpdateIssueModal } from "components/issues";
import { CreateUpdateCycleModal } from "components/cycles";
import { CreateUpdateModuleModal } from "components/modules"; import { CreateUpdateModuleModal } from "components/modules";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
// ui // ui
import { Button } from "components/ui"; import { Button } from "components/ui";
// icons // icons
@ -36,7 +35,7 @@ import { IIssue } from "types";
// fetch-keys // fetch-keys
import { USER_ISSUE } from "constants/fetch-keys"; import { USER_ISSUE } from "constants/fetch-keys";
const CommandPalette: React.FC = () => { export const CommandPalette: React.FC = () => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isPaletteOpen, setIsPaletteOpen] = useState(false); const [isPaletteOpen, setIsPaletteOpen] = useState(false);
@ -103,10 +102,10 @@ const CommandPalette: React.FC = () => {
!(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLInputElement) &&
!(e.target as Element).classList?.contains("remirror-editor") !(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(); e.preventDefault();
setIsPaletteOpen(true); 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) { if (e.altKey) {
e.preventDefault(); e.preventDefault();
if (!router.query.issueId) return; if (!router.query.issueId) return;
@ -125,26 +124,23 @@ const CommandPalette: React.FC = () => {
title: "Some error occurred", 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(); e.preventDefault();
setIsIssueModalOpen(true); setIsIssueModalOpen(true);
} else if (e.key === "p" || e.key === "P") { } else if (e.key.toLowerCase() === "p") {
e.preventDefault(); e.preventDefault();
setIsProjectModalOpen(true); 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(); e.preventDefault();
toggleCollapsed(); toggleCollapsed();
} else if (e.key === "h" || e.key === "H") { } else if (e.key.toLowerCase() === "h") {
e.preventDefault(); e.preventDefault();
setIsShortcutsModalOpen(true); setIsShortcutsModalOpen(true);
} else if (e.key === "q" || e.key === "Q") { } else if (e.key.toLowerCase() === "q") {
e.preventDefault(); e.preventDefault();
setIsCreateCycleModalOpen(true); setIsCreateCycleModalOpen(true);
} else if (e.key === "m" || e.key === "M") { } else if (e.key.toLowerCase() === "m") {
e.preventDefault(); e.preventDefault();
setIsCreateModuleModalOpen(true); setIsCreateModuleModalOpen(true);
} else if (e.key === "Delete") { } else if (e.key === "Delete") {
@ -173,8 +169,7 @@ const CommandPalette: React.FC = () => {
<> <>
<CreateUpdateCycleModal <CreateUpdateCycleModal
isOpen={isCreateCycleModalOpen} isOpen={isCreateCycleModalOpen}
setIsOpen={setIsCreateCycleModalOpen} handleClose={() => setIsCreateCycleModalOpen(false)}
projectId={projectId as string}
/> />
<CreateUpdateModuleModal <CreateUpdateModuleModal
isOpen={isCreateModuleModalOpen} isOpen={isCreateModuleModalOpen}
@ -369,5 +364,3 @@ const CommandPalette: React.FC = () => {
</> </>
); );
}; };
export default CommandPalette;

View File

@ -0,0 +1,2 @@
export * from "./command-pallette";
export * from "./shortcuts-modal";

View File

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
// headless ui // headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// icons // icons
@ -15,7 +15,7 @@ const shortcuts = [
{ {
title: "Navigation", title: "Navigation",
shortcuts: [ shortcuts: [
{ keys: "Ctrl,Cmd,K", description: "To open navigator" }, { keys: "Ctrl,/,Cmd,K", description: "To open navigator" },
{ keys: "↑", description: "Move up" }, { keys: "↑", description: "Move up" },
{ keys: "↓", description: "Move down" }, { keys: "↓", description: "Move down" },
{ keys: "←", description: "Move left" }, { keys: "←", description: "Move left" },
@ -34,22 +34,27 @@ const shortcuts = [
{ keys: "Delete", description: "To bulk delete issues" }, { keys: "Delete", description: "To bulk delete issues" },
{ keys: "H", description: "To open shortcuts guide" }, { 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.", description: "To copy issue url when on issue detail page.",
}, },
], ],
}, },
]; ];
const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => { const allShortcuts = shortcuts.map((i) => i.shortcuts).flat(1);
const [query, setQuery] = useState("");
const filteredShortcuts = shortcuts.filter((shortcut) => export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
shortcut.shortcuts.some((item) => item.description.includes(query.trim())) || query === "" const [query, setQuery] = useState("");
const filteredShortcuts = allShortcuts.filter((shortcut) =>
shortcut.description.toLowerCase().includes(query.trim().toLowerCase()) || query === ""
? true ? true
: false : false
); );
useEffect(() => {
if (!isOpen) setQuery("");
}, [isOpen]);
return ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={setIsOpen}> <Dialog as="div" className="relative z-20" onClose={setIsOpen}>
@ -104,8 +109,40 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
/> />
</div> </div>
<div className="flex w-full flex-col gap-y-3"> <div className="flex w-full flex-col gap-y-3">
{filteredShortcuts.length > 0 ? ( {query.trim().length > 0 ? (
filteredShortcuts.map(({ title, shortcuts }) => ( filteredShortcuts.length > 0 ? (
filteredShortcuts.map((shortcut) => (
<div key={shortcut.keys} className="flex w-full flex-col">
<div className="flex flex-col gap-y-3">
<div className="flex justify-between">
<p className="text-sm text-gray-500">{shortcut.description}</p>
<div className="flex items-center gap-x-1">
{shortcut.keys.split(",").map((key, index) => (
<span key={index} className="flex items-center gap-1">
<kbd className="rounded bg-gray-200 px-1 text-sm">
{key}
</kbd>
</span>
))}
</div>
</div>
</div>
</div>
))
) : (
<div className="flex flex-col gap-y-3">
<p className="text-sm text-gray-500">
No shortcuts found for{" "}
<span className="font-semibold italic">
{`"`}
{query}
{`"`}
</span>
</p>
</div>
)
) : (
shortcuts.map(({ title, shortcuts }) => (
<div key={title} className="flex w-full flex-col"> <div key={title} className="flex w-full flex-col">
<p className="mb-4 font-medium">{title}</p> <p className="mb-4 font-medium">{title}</p>
<div className="flex flex-col gap-y-3"> <div className="flex flex-col gap-y-3">
@ -126,17 +163,6 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
</div> </div>
</div> </div>
)) ))
) : (
<div className="flex flex-col gap-y-3">
<p className="text-sm text-gray-500">
No shortcuts found for{" "}
<span className="font-semibold italic">
{`"`}
{query}
{`"`}
</span>
</p>
</div>
)} )}
</div> </div>
</div> </div>
@ -150,5 +176,3 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
</Transition.Root> </Transition.Root>
); );
}; };
export default ShortcutsModal;

View File

@ -1,5 +1,3 @@
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
// hooks // hooks
import useIssueView from "hooks/use-issue-view"; import useIssueView from "hooks/use-issue-view";
// components // components
@ -13,9 +11,11 @@ type Props = {
states: IState[] | undefined; states: IState[] | undefined;
members: IProjectMember[] | undefined; members: IProjectMember[] | undefined;
addIssueToState: (groupTitle: string, stateId: string | null) => void; addIssueToState: (groupTitle: string, stateId: string | null) => void;
handleEditIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void; handleDeleteIssue: (issue: IIssue) => void;
handleOnDragEnd: (result: DropResult) => void; handleTrashBox: (isDragging: boolean) => void;
removeIssue: ((bridgeId: string) => void) | null;
userAuth: UserAuth; userAuth: UserAuth;
}; };
@ -25,9 +25,11 @@ export const AllBoards: React.FC<Props> = ({
states, states,
members, members,
addIssueToState, addIssueToState,
handleEditIssue,
openIssuesListModal, openIssuesListModal,
handleDeleteIssue, handleDeleteIssue,
handleOnDragEnd, handleTrashBox,
removeIssue,
userAuth, userAuth,
}) => { }) => {
const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssueView(issues); const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssueView(issues);
@ -36,42 +38,43 @@ export const AllBoards: React.FC<Props> = ({
<> <>
{groupedByIssues ? ( {groupedByIssues ? (
<div className="h-[calc(100vh-157px)] lg:h-[calc(100vh-115px)] w-full"> <div className="h-[calc(100vh-157px)] lg:h-[calc(100vh-115px)] w-full">
<DragDropContext onDragEnd={handleOnDragEnd}> <div className="h-full w-full overflow-hidden">
<div className="h-full w-full overflow-hidden"> <div className="h-full w-full">
<div className="h-full w-full"> <div className="flex h-full gap-x-4 overflow-x-auto overflow-y-hidden">
<div className="flex h-full gap-x-4 overflow-x-auto overflow-y-hidden"> {Object.keys(groupedByIssues).map((singleGroup, index) => {
{Object.keys(groupedByIssues).map((singleGroup, index) => { const stateId =
const stateId = selectedGroup === "state_detail.name"
selectedGroup === "state_detail.name" ? states?.find((s) => s.name === singleGroup)?.id ?? null
? states?.find((s) => s.name === singleGroup)?.id ?? null : null;
: null;
const bgColor = const bgColor =
selectedGroup === "state_detail.name" selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.color ? states?.find((s) => s.name === singleGroup)?.color
: "#000000"; : "#000000";
return ( return (
<SingleBoard <SingleBoard
key={index} key={index}
type={type} type={type}
bgColor={bgColor} bgColor={bgColor}
groupTitle={singleGroup} groupTitle={singleGroup}
groupedByIssues={groupedByIssues} groupedByIssues={groupedByIssues}
selectedGroup={selectedGroup} selectedGroup={selectedGroup}
members={members} members={members}
addIssueToState={() => addIssueToState(singleGroup, stateId)} handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue} addIssueToState={() => addIssueToState(singleGroup, stateId)}
openIssuesListModal={openIssuesListModal ?? null} handleDeleteIssue={handleDeleteIssue}
orderBy={orderBy} openIssuesListModal={openIssuesListModal ?? null}
userAuth={userAuth} orderBy={orderBy}
/> handleTrashBox={handleTrashBox}
); removeIssue={removeIssue}
})} userAuth={userAuth}
</div> />
);
})}
</div> </div>
</div> </div>
</DragDropContext> </div>
</div> </div>
) : ( ) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div> <div className="flex h-full w-full items-center justify-center">Loading...</div>

View File

@ -12,80 +12,100 @@ import {
// helpers // helpers
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
// types // types
import { IIssue } from "types"; import { IIssue, IProjectMember, NestedKeyOf } from "types";
type Props = { type Props = {
isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
groupedByIssues: { groupedByIssues: {
[key: string]: IIssue[]; [key: string]: IIssue[];
}; };
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string; groupTitle: string;
createdBy: string | null;
bgColor?: string; bgColor?: string;
addIssueToState: () => void; addIssueToState: () => void;
members: IProjectMember[] | undefined;
isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
}; };
export const BoardHeader: React.FC<Props> = ({ export const BoardHeader: React.FC<Props> = ({
isCollapsed,
setIsCollapsed,
groupedByIssues, groupedByIssues,
selectedGroup,
groupTitle, groupTitle,
createdBy,
bgColor, bgColor,
addIssueToState, addIssueToState,
}) => ( isCollapsed,
<div setIsCollapsed,
className={`flex justify-between p-3 pb-0 ${ members,
!isCollapsed ? "flex-col rounded-md border bg-gray-50" : "" }) => {
}`} const createdBy =
> selectedGroup === "created_by"
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}> ? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "loading..."
<div : null;
className={`flex cursor-pointer items-center gap-x-1 rounded-md bg-slate-900 px-2 ${
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : "" let assignees: any;
}`} if (selectedGroup === "assignees") {
style={{ assignees = groupTitle && groupTitle !== "" ? groupTitle.split(",") : [];
border: `2px solid ${bgColor}`, assignees =
backgroundColor: `${bgColor}20`, assignees.length > 0
}} ? assignees
> .map((a: string) => members?.find((m) => m.member.id === a)?.member.first_name)
<h2 .join(", ")
className={`text-[0.9rem] font-medium capitalize`} : "No assignee";
}
return (
<div
className={`flex justify-between p-3 pb-0 ${
!isCollapsed ? "flex-col rounded-md border bg-gray-50" : ""
}`}
>
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
<div
className={`flex cursor-pointer items-center gap-x-1 rounded-md bg-slate-900 px-2 ${
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
}`}
style={{ style={{
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb", border: `2px solid ${bgColor}`,
backgroundColor: `${bgColor}20`,
}} }}
> >
{groupTitle === null || groupTitle === "null" <h2
? "None" className={`text-[0.9rem] font-medium capitalize`}
: createdBy style={{
? createdBy writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
: addSpaceIfCamelCase(groupTitle)} }}
</h2> >
<span className="ml-0.5 text-sm text-gray-500">{groupedByIssues[groupTitle].length}</span> {selectedGroup === "created_by"
? createdBy
: selectedGroup === "assignees"
? assignees
: addSpaceIfCamelCase(groupTitle)}
</h2>
<span className="ml-0.5 text-sm text-gray-500">{groupedByIssues[groupTitle].length}</span>
</div>
</div>
<div className={`flex items-center ${!isCollapsed ? "flex-col pb-2" : ""}`}>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
onClick={() => {
setIsCollapsed((prevData) => !prevData);
}}
>
{isCollapsed ? (
<ArrowsPointingInIcon className="h-4 w-4" />
) : (
<ArrowsPointingOutIcon className="h-4 w-4" />
)}
</button>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
onClick={addIssueToState}
>
<PlusIcon className="h-4 w-4" />
</button>
</div> </div>
</div> </div>
);
<div className={`flex items-center ${!isCollapsed ? "flex-col pb-2" : ""}`}> };
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
onClick={() => {
setIsCollapsed((prevData) => !prevData);
}}
>
{isCollapsed ? (
<ArrowsPointingInIcon className="h-4 w-4" />
) : (
<ArrowsPointingOutIcon className="h-4 w-4" />
)}
</button>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
onClick={addIssueToState}
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
</div>
);

View File

@ -25,10 +25,13 @@ type Props = {
}; };
selectedGroup: NestedKeyOf<IIssue> | null; selectedGroup: NestedKeyOf<IIssue> | null;
members: IProjectMember[] | undefined; members: IProjectMember[] | undefined;
handleEditIssue: (issue: IIssue) => void;
addIssueToState: () => void; addIssueToState: () => void;
handleDeleteIssue: (issue: IIssue) => void; handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
orderBy: NestedKeyOf<IIssue> | "manual" | null; orderBy: NestedKeyOf<IIssue> | null;
handleTrashBox: (isDragging: boolean) => void;
removeIssue: ((bridgeId: string) => void) | null;
userAuth: UserAuth; userAuth: UserAuth;
}; };
@ -39,10 +42,13 @@ export const SingleBoard: React.FC<Props> = ({
groupedByIssues, groupedByIssues,
selectedGroup, selectedGroup,
members, members,
handleEditIssue,
addIssueToState, addIssueToState,
handleDeleteIssue, handleDeleteIssue,
openIssuesListModal, openIssuesListModal,
orderBy, orderBy,
handleTrashBox,
removeIssue,
userAuth, userAuth,
}) => { }) => {
// collapse/expand // collapse/expand
@ -53,11 +59,6 @@ export const SingleBoard: React.FC<Props> = ({
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); 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") if (selectedGroup === "priority")
groupTitle === "high" groupTitle === "high"
? (bgColor = "#dc2626") ? (bgColor = "#dc2626")
@ -67,43 +68,79 @@ export const SingleBoard: React.FC<Props> = ({
? (bgColor = "#22c55e") ? (bgColor = "#22c55e")
: (bgColor = "#ff0000"); : (bgColor = "#ff0000");
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return ( return (
<div className={`h-full flex-shrink-0 rounded ${!isCollapsed ? "" : "w-80 border bg-gray-50"}`}> <div className={`h-full flex-shrink-0 rounded ${!isCollapsed ? "" : "w-80 border bg-gray-50"}`}>
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3 overflow-y-auto"}`}> <div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3 overflow-y-auto"}`}>
<BoardHeader <BoardHeader
addIssueToState={addIssueToState} addIssueToState={addIssueToState}
bgColor={bgColor} bgColor={bgColor}
createdBy={createdBy} selectedGroup={selectedGroup}
groupTitle={groupTitle} groupTitle={groupTitle}
groupedByIssues={groupedByIssues} groupedByIssues={groupedByIssues}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed} setIsCollapsed={setIsCollapsed}
members={members}
/> />
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}> <StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => ( {(provided, snapshot) => (
<div <div
className={`relative mt-3 h-full space-y-3 px-3 pb-3 ${ className={`relative mt-3 h-full px-3 pb-3 ${
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : "" snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
} ${!isCollapsed ? "hidden" : "block"}`} } ${!isCollapsed ? "hidden" : "block"}`}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.droppableProps} {...provided.droppableProps}
> >
{orderBy !== "sort_order" && (
<>
<div
className={`absolute ${
snapshot.isDraggingOver ? "block" : "hidden"
} top-0 left-0 h-full w-full bg-indigo-200 opacity-50 pointer-events-none z-[99999998]`}
/>
<div
className={`absolute ${
snapshot.isDraggingOver ? "block" : "hidden"
} top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 text-xs whitespace-nowrap bg-white p-2 rounded pointer-events-none z-[99999999]`}
>
This board is ordered by {orderBy}
</div>
</>
)}
{groupedByIssues[groupTitle].map((issue, index: number) => ( {groupedByIssues[groupTitle].map((issue, index: number) => (
<SingleBoardIssue <Draggable
key={index} key={issue.id}
draggableId={issue.id}
index={index} index={index}
type={type} isDragDisabled={
issue={issue} isNotAllowed || selectedGroup === "created_by" || selectedGroup === "assignees"
selectedGroup={selectedGroup} }
properties={properties} >
handleDeleteIssue={handleDeleteIssue} {(provided, snapshot) => (
orderBy={orderBy} <SingleBoardIssue
userAuth={userAuth} key={index}
/> provided={provided}
snapshot={snapshot}
type={type}
issue={issue}
selectedGroup={selectedGroup}
properties={properties}
editIssue={() => handleEditIssue(issue)}
handleDeleteIssue={handleDeleteIssue}
orderBy={orderBy}
handleTrashBox={handleTrashBox}
removeIssue={() => {
removeIssue && removeIssue(issue.bridge);
}}
userAuth={userAuth}
/>
)}
</Draggable>
))} ))}
<span <span
style={{ style={{
display: orderBy === "manual" ? "inline" : "none", display: orderBy === "sort_order" ? "inline" : "none",
}} }}
> >
{provided.placeholder} {provided.placeholder}

View File

@ -1,21 +1,21 @@
import React, { useCallback } from "react"; import React, { useCallback, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR, { mutate } from "swr"; import { mutate } from "swr";
// react-beautiful-dnd // react-beautiful-dnd
import { import {
Draggable, DraggableProvided,
DraggableStateSnapshot, DraggableStateSnapshot,
DraggingStyle, DraggingStyle,
NotDraggingStyle, NotDraggingStyle,
} from "react-beautiful-dnd"; } from "react-beautiful-dnd";
// constants
import { TrashIcon } from "@heroicons/react/24/outline";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
// hooks
import useToast from "hooks/use-toast";
// components // components
import { import {
ViewAssigneeSelect, ViewAssigneeSelect,
@ -23,11 +23,14 @@ import {
ViewPrioritySelect, ViewPrioritySelect,
ViewStateSelect, ViewStateSelect,
} from "components/issues/view-select"; } from "components/issues/view-select";
// ui
import { CustomMenu } from "components/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import { import {
CycleIssueResponse, CycleIssueResponse,
IIssue, IIssue,
IssueResponse,
ModuleIssueResponse, ModuleIssueResponse,
NestedKeyOf, NestedKeyOf,
Properties, Properties,
@ -37,29 +40,39 @@ import {
import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys"; import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
type Props = { type Props = {
index: number;
type?: string; type?: string;
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
issue: IIssue; issue: IIssue;
selectedGroup: NestedKeyOf<IIssue> | null; selectedGroup: NestedKeyOf<IIssue> | null;
properties: Properties; properties: Properties;
editIssue: () => void;
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void; handleDeleteIssue: (issue: IIssue) => void;
orderBy: NestedKeyOf<IIssue> | "manual" | null; orderBy: NestedKeyOf<IIssue> | null;
handleTrashBox: (isDragging: boolean) => void;
userAuth: UserAuth; userAuth: UserAuth;
}; };
export const SingleBoardIssue: React.FC<Props> = ({ export const SingleBoardIssue: React.FC<Props> = ({
index,
type, type,
provided,
snapshot,
issue, issue,
selectedGroup, selectedGroup,
properties, properties,
editIssue,
removeIssue,
handleDeleteIssue, handleDeleteIssue,
orderBy, orderBy,
handleTrashBox,
userAuth, userAuth,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback( const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => { (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -106,15 +119,15 @@ export const SingleBoardIssue: React.FC<Props> = ({
false false
); );
mutate<IssueResponse>( mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => ({ (prevData) =>
...(prevData as IssueResponse), (prevData ?? []).map((p) => {
results: (prevData?.results ?? []).map((p) => {
if (p.id === issue.id) return { ...p, ...formData }; if (p.id === issue.id) return { ...p, ...formData };
return p; return p;
}), }),
}),
false false
); );
@ -137,7 +150,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
style: DraggingStyle | NotDraggingStyle | undefined, style: DraggingStyle | NotDraggingStyle | undefined,
snapshot: DraggableStateSnapshot snapshot: DraggableStateSnapshot
) { ) {
if (orderBy === "manual") return style; if (orderBy === "sort_order") return style;
if (!snapshot.isDragging) return {}; if (!snapshot.isDragging) return {};
if (!snapshot.isDropAnimating) { if (!snapshot.isDropAnimating) {
return style; return style;
@ -149,92 +162,111 @@ export const SingleBoardIssue: React.FC<Props> = ({
}; };
} }
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; const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return ( return (
<Draggable <div
key={issue.id} className={`rounded border bg-white shadow-sm mb-3 ${
draggableId={issue.id} snapshot.isDragging ? "border-theme bg-indigo-50 shadow-lg" : ""
index={index} }`}
isDragDisabled={selectedGroup === "created_by"} ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={getStyle(provided.draggableProps.style, snapshot)}
> >
{(provided, snapshot) => ( <div className="group/card relative select-none p-2">
<div {!isNotAllowed && (
className={`rounded border bg-white shadow-sm ${ <div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
snapshot.isDragging ? "border-theme bg-indigo-50 shadow-lg" : "" {type && !isNotAllowed && (
}`} <CustomMenu width="auto" ellipsis>
ref={provided.innerRef} <CustomMenu.MenuItem onClick={editIssue}>Edit</CustomMenu.MenuItem>
{...provided.draggableProps} {type !== "issue" && removeIssue && (
{...provided.dragHandleProps} <CustomMenu.MenuItem onClick={removeIssue}>
style={getStyle(provided.draggableProps.style, snapshot)} <>Remove from {type}</>
> </CustomMenu.MenuItem>
<div className="group/card relative select-none p-2"> )}
{!isNotAllowed && ( <CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100"> Delete permanently
<button </CustomMenu.MenuItem>
type="button" <CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
className="grid h-7 w-7 place-items-center rounded bg-white p-1 text-red-500 outline-none duration-300 hover:bg-red-50" </CustomMenu>
onClick={() => handleDeleteIssue(issue)} )}
> </div>
<TrashIcon className="h-4 w-4" /> )}
</button> <Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a>
{properties.key && (
<div className="mb-2 text-xs font-medium text-gray-500">
{issue.project_detail.identifier}-{issue.sequence_id}
</div> </div>
)} )}
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}> <h5
<a> className="mb-3 text-sm group-hover:text-theme"
{properties.key && ( style={{ lineClamp: 3, WebkitLineClamp: 3 }}
<div className="mb-2 text-xs font-medium text-gray-500"> >
{issue.project_detail.identifier}-{issue.sequence_id} {issue.name}
</div> </h5>
)} </a>
<h5 </Link>
className="mb-3 text-sm group-hover:text-theme" <div className="flex flex-wrap items-center gap-x-1 gap-y-2 text-xs">
style={{ lineClamp: 3, WebkitLineClamp: 3 }} {properties.priority && selectedGroup !== "priority" && (
> <ViewPrioritySelect
{issue.name} issue={issue}
</h5> partialUpdateIssue={partialUpdateIssue}
</a> isNotAllowed={isNotAllowed}
</Link> position="left"
<div className="flex flex-wrap items-center gap-x-1 gap-y-2 text-xs"> />
{properties.priority && ( )}
<ViewPrioritySelect {properties.state && selectedGroup !== "state_detail.name" && (
issue={issue} <ViewStateSelect
partialUpdateIssue={partialUpdateIssue} issue={issue}
isNotAllowed={isNotAllowed} partialUpdateIssue={partialUpdateIssue}
position="left" isNotAllowed={isNotAllowed}
/> />
)} )}
{properties.state && ( {properties.due_date && (
<ViewStateSelect <ViewDueDateSelect
issue={issue} issue={issue}
partialUpdateIssue={partialUpdateIssue} partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
/> />
)} )}
{properties.due_date && ( {properties.sub_issue_count && (
<ViewDueDateSelect <div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
issue={issue} {issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
{issue.sub_issues_count}{" "}
{issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
</div> </div>
</div> )}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
</div> </div>
)} </div>
</Draggable> </div>
); );
}; };

View File

@ -18,7 +18,7 @@ import { Button } from "components/ui";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons"; import { LayerDiagonalIcon } from "components/icons";
// types // types
import { IIssue, IssueResponse } from "types"; import { IIssue } from "types";
// fetch keys // fetch keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
@ -35,10 +35,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
query: { workspaceSlug, projectId },
} = router;
const { data: issues } = useSWR( const { data: issues } = useSWR(
workspaceSlug && projectId workspaceSlug && projectId
@ -65,8 +62,8 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
const filteredIssues: IIssue[] = const filteredIssues: IIssue[] =
query === "" query === ""
? issues?.results ?? [] ? issues ?? []
: issues?.results.filter( : issues?.filter(
(issue) => (issue) =>
issue.name.toLowerCase().includes(query.toLowerCase()) || issue.name.toLowerCase().includes(query.toLowerCase()) ||
`${issue.project_detail.identifier}-${issue.sequence_id}` `${issue.project_detail.identifier}-${issue.sequence_id}`
@ -104,17 +101,9 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
message: res.message, message: res.message,
}); });
mutate<IssueResponse>( mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => ({ (prevData) => (prevData ?? []).filter((p) => !data.delete_issue_ids.includes(p.id)),
...(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)
),
}),
false false
); );
handleClose(); handleClose();

View File

@ -20,7 +20,6 @@ type FormInput = {
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
type: string;
issues: IIssue[]; issues: IIssue[];
handleOnSubmit: any; handleOnSubmit: any;
}; };
@ -30,7 +29,6 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
handleClose: onClose, handleClose: onClose,
issues, issues,
handleOnSubmit, handleOnSubmit,
type,
}) => { }) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@ -105,7 +103,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all"> <Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
<form> <form>
<Controller <Controller
control={control} control={control}
@ -132,7 +130,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
<li className="p-2"> <li className="p-2">
{query === "" && ( {query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900"> <h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select issues to add to {type} Select issues to add
</h2> </h2>
)} )}
<ul className="text-sm text-gray-700"> <ul className="text-sm text-gray-700">
@ -203,7 +201,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
onClick={handleSubmit(onSubmit)} onClick={handleSubmit(onSubmit)}
disabled={isSubmitting} disabled={isSubmitting}
> >
{isSubmitting ? "Adding..." : `Add to ${type}`} {isSubmitting ? "Adding..." : "Add selected issues"}
</Button> </Button>
</div> </div>
)} )}

View File

@ -1,8 +1,10 @@
export * from "./board-view"; export * from "./board-view";
export * from "./list-view"; export * from "./list-view";
export * from "./sidebar";
export * from "./bulk-delete-issues-modal"; export * from "./bulk-delete-issues-modal";
export * from "./existing-issues-list-modal"; export * from "./existing-issues-list-modal";
export * from "./image-upload-modal"; export * from "./image-upload-modal";
export * from "./issues-view-filter"; export * from "./issues-view-filter";
export * from "./issues-view"; export * from "./issues-view";
export * from "./link-modal";
export * from "./not-authorized-view"; export * from "./not-authorized-view";

View File

@ -130,7 +130,9 @@ export const IssuesFilterView: React.FC<Props> = ({ issues }) => {
option.key === "priority" ? null : ( option.key === "priority" ? null : (
<CustomMenu.MenuItem <CustomMenu.MenuItem
key={option.key} key={option.key}
onClick={() => setOrderBy(option.key)} onClick={() => {
setOrderBy(option.key);
}}
> >
{option.name} {option.name}
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
@ -178,20 +180,29 @@ export const IssuesFilterView: React.FC<Props> = ({ issues }) => {
<div className="space-y-2 py-3"> <div className="space-y-2 py-3">
<h4 className="text-sm text-gray-600">Display Properties</h4> <h4 className="text-sm text-gray-600">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => ( {Object.keys(properties).map((key) => {
<button if (
key={key} issueView === "kanban" &&
type="button" ((groupByProperty === "state_detail.name" && key === "state") ||
className={`rounded border px-2 py-1 text-xs capitalize ${ (groupByProperty === "priority" && key === "priority"))
properties[key as keyof Properties] )
? "border-theme bg-theme text-white" return;
: "border-gray-300"
}`} return (
onClick={() => setProperties(key as keyof Properties)} <button
> key={key}
{replaceUnderscoreIfSnakeCase(key)} type="button"
</button> className={`rounded border px-2 py-1 text-xs capitalize ${
))} properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: "border-gray-300"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// react-beautiful-dnd // react-beautiful-dnd
import { DropResult } from "react-beautiful-dnd"; import { DragDropContext, DropResult } from "react-beautiful-dnd";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
import stateService from "services/state.service"; import stateService from "services/state.service";
@ -16,10 +16,13 @@ import useIssueView from "hooks/use-issue-view";
// components // components
import { AllLists, AllBoards } from "components/core"; import { AllLists, AllBoards } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// icons
import { TrashIcon } from "@heroicons/react/24/outline";
// helpers // helpers
import { getStatesList } from "helpers/state.helper"; import { getStatesList } from "helpers/state.helper";
// types // types
import { CycleIssueResponse, IIssue, IssueResponse, ModuleIssueResponse, UserAuth } from "types"; import { CycleIssueResponse, IIssue, ModuleIssueResponse, UserAuth } from "types";
// fetch-keys // fetch-keys
import { import {
CYCLE_ISSUES, CYCLE_ISSUES,
@ -58,10 +61,18 @@ export const IssuesView: React.FC<Props> = ({
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [issueToDelete, setIssueToDelete] = useState<IIssue | null>(null); const [issueToDelete, setIssueToDelete] = useState<IIssue | null>(null);
// trash box
const [trashBox, setTrashBox] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; 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( const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null, workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
@ -78,23 +89,88 @@ export const IssuesView: React.FC<Props> = ({
: null : null
); );
const handleDeleteIssue = useCallback(
(issue: IIssue) => {
setDeleteIssueModal(true);
setIssueToDelete(issue);
},
[setDeleteIssueModal, setIssueToDelete]
);
const handleOnDragEnd = useCallback( const handleOnDragEnd = useCallback(
(result: DropResult) => { (result: DropResult) => {
setTrashBox(false);
if (!result.destination || !workspaceSlug || !projectId) return; if (!result.destination || !workspaceSlug || !projectId) return;
const { source, destination } = result; const { source, destination } = result;
const draggedItem = groupedByIssues[source.droppableId][source.index]; const draggedItem = groupedByIssues[source.droppableId][source.index];
if (source.droppableId !== destination.droppableId) { if (destination.droppableId === "trashBox") {
const sourceGroup = source.droppableId; // source group id handleDeleteIssue(draggedItem);
const destinationGroup = destination.droppableId; // destination group id } else {
if (orderBy === "sort_order") {
let newSortOrder = draggedItem.sort_order;
if (!sourceGroup || !destinationGroup) return; const destinationGroupArray = groupedByIssues[destination.droppableId];
if (selectedGroup === "priority") { if (destinationGroupArray.length !== 0) {
// update the removed item for mutation // check if dropping in the same group
draggedItem.priority = destinationGroup; if (source.droppableId === destination.droppableId) {
// check if dropping at beginning
if (destination.index === 0)
newSortOrder = destinationGroupArray[0].sort_order - 10000;
// check if dropping at last
else if (destination.index === destinationGroupArray.length - 1)
newSortOrder =
destinationGroupArray[destinationGroupArray.length - 1].sort_order + 10000;
else {
if (destination.index > source.index)
newSortOrder =
(destinationGroupArray[source.index + 1].sort_order +
destinationGroupArray[source.index + 2].sort_order) /
2;
else if (destination.index < source.index)
newSortOrder =
(destinationGroupArray[source.index - 1].sort_order +
destinationGroupArray[source.index - 2].sort_order) /
2;
}
} else {
// check if dropping at beginning
if (destination.index === 0)
newSortOrder = destinationGroupArray[0].sort_order - 10000;
// check if dropping at last
else if (destination.index === destinationGroupArray.length)
newSortOrder =
destinationGroupArray[destinationGroupArray.length - 1].sort_order + 10000;
else
newSortOrder =
(destinationGroupArray[destination.index - 1].sort_order +
destinationGroupArray[destination.index].sort_order) /
2;
}
}
draggedItem.sort_order = newSortOrder;
}
if (orderBy === "sort_order" || source.droppableId !== destination.droppableId) {
const sourceGroup = source.droppableId; // source group id
const destinationGroup = destination.droppableId; // destination group id
if (!sourceGroup || !destinationGroup) return;
if (selectedGroup === "priority") draggedItem.priority = destinationGroup;
else if (selectedGroup === "state_detail.name") {
const destinationState = states?.find((s) => s.name === destinationGroup);
if (!destinationState) return;
draggedItem.state = destinationState.id;
draggedItem.state_detail = destinationState;
}
if (cycleId) if (cycleId)
mutate<CycleIssueResponse[]>( mutate<CycleIssueResponse[]>(
@ -105,10 +181,7 @@ export const IssuesView: React.FC<Props> = ({
if (issue.issue_detail.id === draggedItem.id) { if (issue.issue_detail.id === draggedItem.id) {
return { return {
...issue, ...issue,
issue_detail: { issue_detail: draggedItem,
...draggedItem,
priority: destinationGroup,
},
}; };
} }
return issue; return issue;
@ -127,10 +200,7 @@ export const IssuesView: React.FC<Props> = ({
if (issue.issue_detail.id === draggedItem.id) { if (issue.issue_detail.id === draggedItem.id) {
return { return {
...issue, ...issue,
issue_detail: { issue_detail: draggedItem,
...draggedItem,
priority: destinationGroup,
},
}; };
} }
return issue; return issue;
@ -140,25 +210,18 @@ export const IssuesView: React.FC<Props> = ({
false false
); );
mutate<IssueResponse>( mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => { (prevData) => {
if (!prevData) return prevData; if (!prevData) return prevData;
const updatedIssues = prevData.results.map((issue) => { const updatedIssues = prevData.map((i) => {
if (issue.id === draggedItem.id) if (i.id === draggedItem.id) return draggedItem;
return {
...draggedItem,
priority: destinationGroup,
};
return issue; return i;
}); });
return { return updatedIssues;
...prevData,
results: updatedIssues,
};
}, },
false false
); );
@ -166,97 +229,9 @@ export const IssuesView: React.FC<Props> = ({
// patch request // patch request
issuesService issuesService
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, { .patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
priority: destinationGroup, priority: draggedItem.priority,
}) state: draggedItem.state,
.then((res) => { sort_order: draggedItem.sort_order,
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
});
} else if (selectedGroup === "state_detail.name") {
const destinationState = states?.find((s) => s.name === destinationGroup);
const destinationStateId = destinationState?.id;
// update the removed item for mutation
if (!destinationStateId || !destinationState) return;
draggedItem.state = destinationStateId;
draggedItem.state_detail = destinationState;
if (cycleId)
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(cycleId as string),
(prevData) => {
if (!prevData) return prevData;
const updatedIssues = prevData.map((issue) => {
if (issue.issue_detail.id === draggedItem.id) {
return {
...issue,
issue_detail: {
...draggedItem,
state_detail: destinationState,
state: destinationStateId,
},
};
}
return issue;
});
return [...updatedIssues];
},
false
);
if (moduleId)
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(moduleId as string),
(prevData) => {
if (!prevData) return prevData;
const updatedIssues = prevData.map((issue) => {
if (issue.issue_detail.id === draggedItem.id) {
return {
...issue,
issue_detail: {
...draggedItem,
state_detail: destinationState,
state: destinationStateId,
},
};
}
return issue;
});
return [...updatedIssues];
},
false
);
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => {
if (!prevData) return prevData;
const updatedIssues = prevData.results.map((issue) => {
if (issue.id === draggedItem.id)
return {
...draggedItem,
state_detail: destinationState,
state: destinationStateId,
};
return issue;
});
return {
...prevData,
results: updatedIssues,
};
},
false
);
// patch request
issuesService
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
state: destinationStateId,
}) })
.then((res) => { .then((res) => {
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string)); if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
@ -267,82 +242,106 @@ export const IssuesView: React.FC<Props> = ({
} }
} }
}, },
[workspaceSlug, cycleId, moduleId, groupedByIssues, projectId, selectedGroup, states] [
workspaceSlug,
cycleId,
moduleId,
groupedByIssues,
projectId,
selectedGroup,
orderBy,
states,
handleDeleteIssue,
]
); );
const addIssueToState = (groupTitle: string, stateId: string | null) => { const addIssueToState = useCallback(
setCreateIssueModal(true); (groupTitle: string, stateId: string | null) => {
if (selectedGroup) setCreateIssueModal(true);
setPreloadedData({ if (selectedGroup)
state: stateId ?? undefined, setPreloadedData({
[selectedGroup]: groupTitle, state: stateId ?? undefined,
actionType: "createIssue", [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,
}); });
else setPreloadedData({ actionType: "createIssue" }); },
}; [setEditIssueModal, setIssueToEdit]
);
const handleEditIssue = (issue: IIssue) => { const removeIssueFromCycle = useCallback(
setEditIssueModal(true); (bridgeId: string) => {
setIssueToEdit({ if (!workspaceSlug || !projectId) return;
...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) => { mutate<CycleIssueResponse[]>(
setDeleteIssueModal(true); CYCLE_ISSUES(cycleId as string),
setIssueToDelete(issue); (prevData) => prevData?.filter((p) => p.id !== bridgeId),
}; false
);
const removeIssueFromCycle = (bridgeId: string) => { issuesService
if (!workspaceSlug || !projectId) return; .removeIssueFromCycle(
workspaceSlug as string,
projectId as string,
cycleId as string,
bridgeId
)
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
});
},
[workspaceSlug, projectId, cycleId]
);
mutate<CycleIssueResponse[]>( const removeIssueFromModule = useCallback(
CYCLE_ISSUES(cycleId as string), (bridgeId: string) => {
(prevData) => prevData?.filter((p) => p.id !== bridgeId), if (!workspaceSlug || !projectId) return;
false
);
issuesService mutate<ModuleIssueResponse[]>(
.removeIssueFromCycle( MODULE_ISSUES(moduleId as string),
workspaceSlug as string, (prevData) => prevData?.filter((p) => p.id !== bridgeId),
projectId as string, false
cycleId as string, );
bridgeId
)
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
});
};
const removeIssueFromModule = (bridgeId: string) => { modulesService
if (!workspaceSlug || !projectId) return; .removeIssueFromModule(
workspaceSlug as string,
projectId as string,
moduleId as string,
bridgeId
)
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
});
},
[workspaceSlug, projectId, moduleId]
);
mutate<ModuleIssueResponse[]>( const handleTrashBox = useCallback(
MODULE_ISSUES(moduleId as string), (isDragging: boolean) => {
(prevData) => prevData?.filter((p) => p.id !== bridgeId), if (isDragging && !trashBox) setTrashBox(true);
false },
); [trashBox, setTrashBox]
);
modulesService
.removeIssueFromModule(
workspaceSlug as string,
projectId as string,
moduleId as string,
bridgeId
)
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
});
};
return ( return (
<> <>
@ -364,38 +363,67 @@ export const IssuesView: React.FC<Props> = ({
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
data={issueToDelete} data={issueToDelete}
/> />
{issueView === "list" ? (
<AllLists <div className="relative">
type={type} <DragDropContext onDragEnd={handleOnDragEnd}>
issues={issues} <StrictModeDroppable droppableId="trashBox">
states={states} {(provided, snapshot) => (
members={members} <div
addIssueToState={addIssueToState} className={`${
handleEditIssue={handleEditIssue} trashBox ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
handleDeleteIssue={handleDeleteIssue} } fixed z-20 top-12 left-1/2 -translate-x-1/2 flex items-center gap-2 bg-red-100 border-2 border-red-500 p-3 text-xs rounded ${
openIssuesListModal={type !== "issue" ? openIssuesListModal : null} snapshot.isDraggingOver ? "bg-red-500 text-white" : ""
removeIssue={ } duration-200`}
type === "cycle" ref={provided.innerRef}
? removeIssueFromCycle {...provided.droppableProps}
: type === "module" >
? removeIssueFromModule <TrashIcon className="h-3 w-3" />
: null Drop issue here to delete
} </div>
userAuth={userAuth} )}
/> </StrictModeDroppable>
) : ( {issueView === "list" ? (
<AllBoards <AllLists
type={type} type={type}
issues={issues} issues={issues}
states={states} states={states}
members={members} members={members}
addIssueToState={addIssueToState} addIssueToState={addIssueToState}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null} handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue} handleDeleteIssue={handleDeleteIssue}
handleOnDragEnd={handleOnDragEnd} openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
userAuth={userAuth} removeIssue={
/> type === "cycle"
)} ? removeIssueFromCycle
: type === "module"
? removeIssueFromModule
: null
}
userAuth={userAuth}
/>
) : (
<AllBoards
type={type}
issues={issues}
states={states}
members={members}
addIssueToState={addIssueToState}
handleEditIssue={handleEditIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
handleDeleteIssue={handleDeleteIssue}
handleTrashBox={handleTrashBox}
removeIssue={
type === "cycle"
? removeIssueFromCycle
: type === "module"
? removeIssueFromModule
: null
}
userAuth={userAuth}
/>
)}
</DragDropContext>
</div>
</> </>
); );
}; };

View File

@ -8,19 +8,15 @@ import { mutate } from "swr";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// headless ui // headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services
import modulesService from "services/modules.service";
// ui // ui
import { Button, Input } from "components/ui"; import { Button, Input } from "components/ui";
// types // types
import type { IModule, ModuleLink } from "types"; import type { IIssueLink, ModuleLink } from "types";
// fetch-keys
import { MODULE_DETAILS } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
module: IModule | undefined;
handleClose: () => void; handleClose: () => void;
onFormSubmit: (formData: IIssueLink | ModuleLink) => void;
}; };
const defaultValues: ModuleLink = { const defaultValues: ModuleLink = {
@ -28,42 +24,20 @@ const defaultValues: ModuleLink = {
url: "", url: "",
}; };
export const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => { export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }) => {
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
const { const {
register, register,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
handleSubmit, handleSubmit,
reset, reset,
setError,
} = useForm<ModuleLink>({ } = useForm<ModuleLink>({
defaultValues, defaultValues,
}); });
const onSubmit = async (formData: ModuleLink) => { const onSubmit = async (formData: ModuleLink) => {
if (!workspaceSlug || !projectId || !moduleId) return; await onFormSubmit(formData);
const previousLinks = module?.link_module.map((l) => ({ title: l.title, url: l.url })); onClose();
const payload: Partial<IModule> = {
links_list: [...(previousLinks ?? []), formData],
};
await modulesService
.patchModule(workspaceSlug as string, projectId as string, moduleId as string, payload)
.then((res) => {
mutate(MODULE_DETAILS(moduleId as string));
onClose();
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof ModuleLink, {
message: err[key].join(", "),
});
});
});
}; };
const onClose = () => { const onClose = () => {

View File

@ -7,6 +7,8 @@ import { mutate } from "swr";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
// hooks
import useToast from "hooks/use-toast";
// components // components
import { import {
ViewAssigneeSelect, ViewAssigneeSelect,
@ -16,17 +18,12 @@ import {
} from "components/issues/view-select"; } from "components/issues/view-select";
// ui // ui
import { CustomMenu } from "components/ui"; import { CustomMenu } from "components/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import { import { CycleIssueResponse, IIssue, ModuleIssueResponse, Properties, UserAuth } from "types";
CycleIssueResponse,
IIssue,
IssueResponse,
ModuleIssueResponse,
Properties,
UserAuth,
} from "types";
// fetch-keys // 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 Props = {
type?: string; type?: string;
@ -49,7 +46,7 @@ export const SingleListIssue: React.FC<Props> = ({
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback( const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => { (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -96,15 +93,15 @@ export const SingleListIssue: React.FC<Props> = ({
false false
); );
mutate<IssueResponse>( mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => ({ (prevData) =>
...(prevData as IssueResponse), (prevData ?? []).map((p) => {
results: (prevData?.results ?? []).map((p) => {
if (p.id === issue.id) return { ...p, ...formData }; if (p.id === issue.id) return { ...p, ...formData };
return p; return p;
}), }),
}),
false false
); );
@ -123,6 +120,23 @@ export const SingleListIssue: React.FC<Props> = ({
[workspaceSlug, projectId, cycleId, moduleId, issue] [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; const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return ( return (
@ -190,6 +204,7 @@ export const SingleListIssue: React.FC<Props> = ({
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}> <CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
Delete permanently Delete permanently
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
</CustomMenu> </CustomMenu>
)} )}
</div> </div>

View File

@ -50,9 +50,20 @@ export const SingleList: React.FC<Props> = ({
const createdBy = const createdBy =
selectedGroup === "created_by" 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; : 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 ( return (
<Disclosure key={groupTitle} as="div" defaultOpen> <Disclosure key={groupTitle} as="div" defaultOpen>
{({ open }) => ( {({ open }) => (
@ -67,10 +78,10 @@ export const SingleList: React.FC<Props> = ({
</span> </span>
{selectedGroup !== null ? ( {selectedGroup !== null ? (
<h2 className="font-medium capitalize leading-5"> <h2 className="font-medium capitalize leading-5">
{groupTitle === null || groupTitle === "null" {selectedGroup === "created_by"
? "None"
: createdBy
? createdBy ? createdBy
: selectedGroup === "assignees"
? assignees
: addSpaceIfCamelCase(groupTitle)} : addSpaceIfCamelCase(groupTitle)}
</h2> </h2>
) : ( ) : (

View File

@ -0,0 +1,3 @@
export * from "./links-list";
export * from "./sidebar-progress-stats";
export * from "./single-progress-stats";

View File

@ -0,0 +1,58 @@
import Link from "next/link";
// icons
import { LinkIcon, TrashIcon } from "@heroicons/react/24/outline";
// helpers
import { timeAgo } from "helpers/date-time.helper";
// types
import { IUserLite, UserAuth } from "types";
type Props = {
links: {
id: string;
created_at: Date;
created_by: string;
created_by_detail: IUserLite;
title: string;
url: string;
}[];
handleDeleteLink: (linkId: string) => void;
userAuth: UserAuth;
};
export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }) => {
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<>
{links.map((link) => (
<div key={link.id} className="group relative">
{!isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover:opacity-100">
<button
type="button"
className="grid h-7 w-7 place-items-center rounded bg-gray-100 p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
onClick={() => handleDeleteLink(link.id)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
)}
<Link href={link.url} target="_blank">
<a className="group relative flex gap-2 rounded-md border bg-gray-100 p-2">
<div className="mt-0.5">
<LinkIcon className="h-3.5 w-3.5" />
</div>
<div>
<h5>{link.title}</h5>
{/* <p className="mt-0.5 text-gray-500">
Added {timeAgo(link.created_at)} ago by {link.created_by_detail.email}
</p> */}
</div>
</a>
</Link>
</div>
))}
</>
);
};

View File

@ -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<Props> = ({ 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 (
<div className="relative h-[200px] w-full ">
<div className="flex justify-start items-start gap-4 text-xs">
<div className="flex justify-center items-center gap-1">
<span className="h-2 w-2 bg-green-600 rounded-full" />
<span>Ideal</span>
</div>
<div className="flex justify-center items-center gap-1">
<span className="h-2 w-2 bg-[#8884d8] rounded-full" />
<span>Current</span>
</div>
</div>
<div className="flex items-center justify-center h-full w-full absolute -left-8 py-3 text-xs">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
width={300}
height={200}
data={ChartData}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
>
<XAxis dataKey="currentDate" />
<YAxis />
<Tooltip />
<Area
type="monotone"
dataKey="pending"
stroke="#8884d8"
fill="#98d1fb"
activeDot={{ r: 8 }}
/>
<ReferenceLine
stroke="#16a34a"
strokeDasharray="3 3"
segment={[
{ x: `${renderShortNumericDateFormat(endDate)}`, y: 0 },
{ x: `${renderShortNumericDateFormat(startDate)}`, y: issues.length },
]}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default ProgressChart;

View File

@ -10,8 +10,10 @@ import { Tab } from "@headlessui/react";
// services // services
import issuesServices from "services/issues.service"; import issuesServices from "services/issues.service";
import projectService from "services/project.service"; import projectService from "services/project.service";
// hooks
import useLocalStorage from "hooks/use-local-storage";
// components // components
import SingleProgressStats from "components/core/sidebar/single-progress-stats"; import { SingleProgressStats } from "components/core";
// ui // ui
import { Avatar } from "components/ui"; import { Avatar } from "components/ui";
// icons // icons
@ -36,9 +38,12 @@ const stateGroupColours: {
completed: "#096e8d", completed: "#096e8d",
}; };
const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues }) => { export const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees");
const { data: issueLabels } = useSWR<IIssueLabels[]>( const { data: issueLabels } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId workspaceSlug && projectId
@ -52,133 +57,157 @@ const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues }) => {
? () => projectService.projectMembers(workspaceSlug as string, projectId as string) ? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null : null
); );
const currentValue = (tab: string | null) => {
switch (tab) {
case "Assignees":
return 0;
case "Labels":
return 1;
case "States":
return 2;
default:
return 0;
}
};
return ( return (
<div className="flex flex-col items-center justify-center w-full gap-2 "> <Tab.Group
<Tab.Group> defaultIndex={currentValue(tab)}
<Tab.List onChange={(i) => {
as="div" switch (i) {
className="flex items-center justify-between w-full rounded bg-gray-100 text-xs" case 0:
return setTab("Assignees");
case 1:
return setTab("Labels");
case 2:
return setTab("States");
default:
return setTab("Assignees");
}
}}
>
<Tab.List
as="div"
className="flex items-center justify-between w-full rounded bg-gray-100 text-xs"
>
<Tab
className={({ selected }) =>
`w-1/2 rounded py-1 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}`
}
> >
<Tab Assignees
className={({ selected }) => </Tab>
`w-1/2 rounded py-1 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}` <Tab
className={({ selected }) =>
`w-1/2 rounded py-1 ${selected ? "bg-gray-300 font-semibold" : "hover:bg-gray-200 "}`
}
>
Labels
</Tab>
<Tab
className={({ selected }) =>
`w-1/2 rounded py-1 ${selected ? "bg-gray-300 font-semibold" : "hover:bg-gray-200 "}`
}
>
States
</Tab>
</Tab.List>
<Tab.Panels className="flex items-center justify-between w-full">
<Tab.Panel as="div" className="w-full flex flex-col ">
{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 (
<SingleProgressStats
key={index}
title={
<>
<Avatar user={member.member} />
<span>{member.member.first_name}</span>
</>
}
completed={completeArray.length}
total={totalArray.length}
/>
);
} }
> })}
Assignees {issues?.filter((i) => i.assignees?.length === 0).length > 0 ? (
</Tab> <SingleProgressStats
<Tab title={
className={({ selected }) => <>
`w-1/2 rounded py-1 ${selected ? "bg-gray-300 font-semibold" : "hover:bg-gray-200 "}` <div className="h-5 w-5 rounded-full border-2 border-white bg-white">
} <Image
> src={User}
Labels height="100%"
</Tab> width="100%"
<Tab className="rounded-full"
className={({ selected }) => alt="User"
`w-1/2 rounded py-1 ${selected ? "bg-gray-300 font-semibold" : "hover:bg-gray-200 "}`
}
>
States
</Tab>
</Tab.List>
<Tab.Panels className="flex items-center justify-between w-full">
<Tab.Panel as="div" className="w-full flex flex-col ">
{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 (
<SingleProgressStats
key={index}
title={
<>
<Avatar user={member.member} />
<span>{member.member.first_name}</span>
</>
}
completed={completeArray.length}
total={totalArray.length}
/>
);
}
})}
{issues?.filter((i) => i.assignees?.length === 0).length > 0 ? (
<SingleProgressStats
title={
<>
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="User"
/>
</div>
<span>No assignee</span>
</>
}
completed={
issues?.filter(
(i) => i.state_detail.group === "completed" && i.assignees?.length === 0
).length
}
total={issues?.filter((i) => i.assignees?.length === 0).length}
/>
) : (
""
)}
</Tab.Panel>
<Tab.Panel as="div" className="w-full flex flex-col ">
{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 (
<SingleProgressStats
key={index}
title={
<>
<span
className="block h-2 w-2 rounded-full "
style={{
backgroundColor: issue.color,
}}
/>
<span className="text-xs capitalize">{issue.name}</span>
</>
}
completed={completeArray.length}
total={totalArray.length}
/>
);
}
})}
</Tab.Panel>
<Tab.Panel as="div" className="w-full flex flex-col ">
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<>
<span
className="block h-2 w-2 rounded-full "
style={{
backgroundColor: stateGroupColours[group],
}}
/> />
<span className="text-xs capitalize">{group}</span> </div>
</> <span>No assignee</span>
} </>
completed={groupedIssues[group].length} }
total={issues.length} completed={
/> issues?.filter(
))} (i) => i.state_detail.group === "completed" && i.assignees?.length === 0
</Tab.Panel> ).length
</Tab.Panels> }
</Tab.Group> total={issues?.filter((i) => i.assignees?.length === 0).length}
</div> />
) : (
""
)}
</Tab.Panel>
<Tab.Panel as="div" className="w-full flex flex-col ">
{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 (
<SingleProgressStats
key={index}
title={
<>
<span
className="block h-2 w-2 rounded-full "
style={{
backgroundColor: issue.color,
}}
/>
<span className="text-xs capitalize">{issue.name}</span>
</>
}
completed={completeArray.length}
total={totalArray.length}
/>
);
}
})}
</Tab.Panel>
<Tab.Panel as="div" className="w-full flex flex-col ">
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<>
<span
className="block h-2 w-2 rounded-full "
style={{
backgroundColor: stateGroupColours[group],
}}
/>
<span className="text-xs capitalize">{group}</span>
</>
}
completed={groupedIssues[group].length}
total={issues.length}
/>
))}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
); );
}; };
export default SidebarProgressStats;

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { CircularProgressbar } from "react-circular-progressbar"; import { ProgressBar } from "components/ui";
type TSingleProgressStatsProps = { type TSingleProgressStatsProps = {
title: any; title: any;
@ -8,22 +8,22 @@ type TSingleProgressStatsProps = {
total: number; total: number;
}; };
const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({ title, completed, total }) => ( export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
<> title,
<div className="flex items-center justify-between w-full py-3 text-xs border-b-[1px] border-gray-200"> completed,
<div className="flex items-center justify-start w-1/2 gap-2">{title}</div> total,
<div className="flex items-center justify-end w-1/2 gap-1 px-2"> }) => (
<div className="flex h-5 justify-center items-center gap-1 "> <div className="flex items-center justify-between w-full py-3 text-xs border-b-[1px] border-gray-200">
<span className="h-4 w-4 "> <div className="flex items-center justify-start w-1/2 gap-2">{title}</div>
<CircularProgressbar value={completed} maxValue={total} strokeWidth={10} /> <div className="flex items-center justify-end w-1/2 gap-1 px-2">
</span> <div className="flex h-5 justify-center items-center gap-1 ">
<span className="w-8 text-right">{Math.floor((completed / total) * 100)}%</span> <span className="h-4 w-4 ">
</div> <ProgressBar value={completed} maxValue={total} />
<span>of</span> </span>
<span>{total}</span> <span className="w-8 text-right">{Math.floor((completed / total) * 100)}%</span>
</div> </div>
<span>of</span>
<span>{total}</span>
</div> </div>
</> </div>
); );
export default SingleProgressStats;

View File

@ -1,8 +1,7 @@
// react // react
import { useState } from "react"; import { useState } from "react";
// components // components
import SingleStat from "components/project/cycles/stats-view/single-stat"; import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
import ConfirmCycleDeletion from "components/project/cycles/confirm-cycle-deletion";
// types // types
import { ICycle, SelectCycleType } from "types"; import { ICycle, SelectCycleType } from "types";
import { CompletedCycleIcon, CurrentCycleIcon, UpcomingCycleIcon } from "components/icons"; import { CompletedCycleIcon, CurrentCycleIcon, UpcomingCycleIcon } from "components/icons";
@ -14,7 +13,7 @@ type TCycleStatsViewProps = {
type: "current" | "upcoming" | "completed"; type: "current" | "upcoming" | "completed";
}; };
const CycleStatsView: React.FC<TCycleStatsViewProps> = ({ export const CyclesListView: React.FC<TCycleStatsViewProps> = ({
cycles, cycles,
setCreateUpdateCycleModal, setCreateUpdateCycleModal,
setSelectedCycle, setSelectedCycle,
@ -35,7 +34,7 @@ const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
return ( return (
<> <>
<ConfirmCycleDeletion <DeleteCycleModal
isOpen={ isOpen={
cycleDeleteModal && cycleDeleteModal &&
!!selectedCycleForDelete && !!selectedCycleForDelete &&
@ -46,7 +45,7 @@ const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
/> />
{cycles.length > 0 ? ( {cycles.length > 0 ? (
cycles.map((cycle) => ( cycles.map((cycle) => (
<SingleStat <SingleCycleCard
key={cycle.id} key={cycle.id}
cycle={cycle} cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)} handleDeleteCycle={() => handleDeleteCycle(cycle)}
@ -71,5 +70,3 @@ const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
</> </>
); );
}; };
export default CycleStatsView;

View File

@ -23,7 +23,7 @@ type TConfirmCycleDeletionProps = {
// fetch-keys // fetch-keys
import { CYCLE_LIST } from "constants/fetch-keys"; import { CYCLE_LIST } from "constants/fetch-keys";
const ConfirmCycleDeletion: React.FC<TConfirmCycleDeletionProps> = ({ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
isOpen, isOpen,
setIsOpen, setIsOpen,
data, data,
@ -36,10 +36,6 @@ const ConfirmCycleDeletion: React.FC<TConfirmCycleDeletionProps> = ({
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
useEffect(() => {
data && setIsOpen(true);
}, [data, setIsOpen]);
const handleClose = () => { const handleClose = () => {
setIsOpen(false); setIsOpen(false);
setIsDeleteLoading(false); setIsDeleteLoading(false);
@ -153,5 +149,3 @@ const ConfirmCycleDeletion: React.FC<TConfirmCycleDeletionProps> = ({
</Transition.Root> </Transition.Root>
); );
}; };
export default ConfirmCycleDeletion;

View File

@ -1,39 +1,59 @@
import { FC } from "react"; import { useEffect } from "react";
// react-hook-form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// components // ui
import { Button, Input, TextArea, CustomSelect } from "components/ui"; import { Button, CustomDatePicker, CustomSelect, Input, TextArea } from "components/ui";
// types // types
import type { ICycle } from "types"; import { ICycle } from "types";
type Props = {
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
handleClose: () => void;
status: boolean;
data?: ICycle;
};
const defaultValues: Partial<ICycle> = { const defaultValues: Partial<ICycle> = {
name: "", name: "",
description: "", description: "",
status: "draft", status: "draft",
start_date: new Date().toString(), start_date: "",
end_date: new Date().toString(), end_date: "",
}; };
export interface CycleFormProps { export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status, data }) => {
handleFormSubmit: (values: Partial<ICycle>) => void;
handleFormCancel?: () => void;
initialData?: Partial<ICycle>;
}
export const CycleForm: FC<CycleFormProps> = (props) => {
const { handleFormSubmit, handleFormCancel = () => {}, initialData = null } = props;
// form handler
const { const {
register, register,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
handleSubmit, handleSubmit,
control, control,
reset,
} = useForm<ICycle>({ } = useForm<ICycle>({
defaultValues: initialData || defaultValues, defaultValues,
}); });
const handleCreateUpdateCycle = async (formData: Partial<ICycle>) => {
await handleFormSubmit(formData);
reset({
...defaultValues,
});
};
useEffect(() => {
reset({
...defaultValues,
...data,
});
}, [data, reset]);
return ( return (
<form onSubmit={handleSubmit(handleFormSubmit)}> <form onSubmit={handleSubmit(handleCreateUpdateCycle)}>
<div className="space-y-5"> <div className="space-y-5">
<h3 className="text-lg font-medium leading-6 text-gray-900">
{status ? "Update" : "Create"} Cycle
</h3>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<Input <Input
@ -47,6 +67,10 @@ export const CycleForm: FC<CycleFormProps> = (props) => {
register={register} register={register}
validations={{ validations={{
required: "Name is required", required: "Name is required",
maxLength: {
value: 255,
message: "Name should be less than 255 characters",
},
}} }}
/> />
</div> </div>
@ -86,42 +110,56 @@ export const CycleForm: FC<CycleFormProps> = (props) => {
</div> </div>
<div className="flex gap-x-2"> <div className="flex gap-x-2">
<div className="w-full"> <div className="w-full">
<Input <h6 className="text-gray-500">Start Date</h6>
id="start_date" <div className="w-full">
label="Start Date" <Controller
name="start_date" control={control}
type="date" name="start_date"
placeholder="Enter start date" rules={{ required: "Start date is required" }}
error={errors.start_date} render={({ field: { value, onChange } }) => (
register={register} <CustomDatePicker
validations={{ renderAs="input"
required: "Start date is required", value={value}
}} onChange={onChange}
/> error={errors.start_date ? true : false}
/>
)}
/>
{errors.start_date && (
<h6 className="text-sm text-red-500">{errors.start_date.message}</h6>
)}
</div>
</div> </div>
<div className="w-full"> <div className="w-full">
<Input <h6 className="text-gray-500">End Date</h6>
id="end_date" <div className="w-full">
label="End Date" <Controller
name="end_date" control={control}
type="date" name="end_date"
placeholder="Enter end date" rules={{ required: "End date is required" }}
error={errors.end_date} render={({ field: { value, onChange } }) => (
register={register} <CustomDatePicker
validations={{ renderAs="input"
required: "End date is required", value={value}
}} onChange={onChange}
/> error={errors.end_date ? true : false}
/>
)}
/>
{errors.end_date && (
<h6 className="text-sm text-red-500">{errors.end_date.message}</h6>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
<Button theme="secondary" onClick={handleFormCancel}> <Button theme="secondary" onClick={handleClose}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting}>
{initialData {status
? isSubmitting ? isSubmitting
? "Updating Cycle..." ? "Updating Cycle..."
: "Update Cycle" : "Update Cycle"

View File

@ -1,3 +1,7 @@
export * from "./cycles-list-view";
export * from "./delete-cycle-modal";
export * from "./form";
export * from "./modal"; export * from "./modal";
export * from "./select"; export * from "./select";
export * from "./form"; export * from "./sidebar";
export * from "./single-cycle-card";

View File

@ -1,74 +1,91 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services // services
import cycleService from "services/cycles.service"; import cycleService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
// components // components
import { CycleForm } from "components/cycles"; import { CycleForm } from "components/cycles";
// helpers
import { renderDateFormat } from "helpers/date-time.helper";
// types // types
import type { ICycle } from "types"; import type { ICycle } from "types";
// fetch keys // fetch keys
import { CYCLE_LIST } from "constants/fetch-keys"; import { CYCLE_LIST } from "constants/fetch-keys";
export interface CycleModalProps { type CycleModalProps = {
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
projectId: string; data?: ICycle;
workspaceSlug: string; };
initialData?: ICycle;
}
export const CycleModal: React.FC<CycleModalProps> = (props) => { export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
const { isOpen, handleClose, initialData, projectId, workspaceSlug } = props; isOpen,
handleClose,
data,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const createCycle = (payload: Partial<ICycle>) => { const { setToastAlert } = useToast();
cycleService
.createCycle(workspaceSlug as string, projectId, payload) const createCycle = async (payload: Partial<ICycle>) => {
await cycleService
.createCycle(workspaceSlug as string, projectId as string, payload)
.then((res) => { .then((res) => {
mutate(CYCLE_LIST(projectId)); mutate(CYCLE_LIST(projectId as string));
handleClose(); handleClose();
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle created successfully.",
});
}) })
.catch((err) => { .catch((err) => {
// TODO: Handle this ERROR. setToastAlert({
// Object.keys(err).map((key) => { type: "error",
// setError(key as keyof typeof defaultValues, { title: "Error!",
// message: err[key].join(", "), message: "Error in creating cycle. Please try again.",
// }); });
// });
}); });
}; };
const updateCycle = (cycleId: string, payload: Partial<ICycle>) => { const updateCycle = async (cycleId: string, payload: Partial<ICycle>) => {
cycleService await cycleService
.updateCycle(workspaceSlug, projectId, cycleId, payload) .updateCycle(workspaceSlug as string, projectId as string, cycleId, payload)
.then((res) => { .then((res) => {
mutate(CYCLE_LIST(projectId)); mutate(CYCLE_LIST(projectId as string));
handleClose(); handleClose();
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
}) })
.catch((err) => { .catch((err) => {
// TODO: Handle this ERROR. setToastAlert({
// Object.keys(err).map((key) => { type: "error",
// setError(key as keyof typeof defaultValues, { title: "Error!",
// message: err[key].join(", "), message: "Error in updating cycle. Please try again.",
// }); });
// });
}); });
}; };
const handleFormSubmit = (formValues: Partial<ICycle>) => { const handleFormSubmit = async (formData: Partial<ICycle>) => {
if (workspaceSlug && projectId) { if (!workspaceSlug || !projectId) return;
const payload = {
...formValues,
};
if (initialData) { const payload: Partial<ICycle> = {
updateCycle(initialData.id, payload); ...formData,
} else { };
createCycle(payload);
} if (!data) await createCycle(payload);
} else await updateCycle(data.id, payload);
}; };
return ( return (
@ -97,10 +114,12 @@ export const CycleModal: React.FC<CycleModalProps> = (props) => {
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6"> <Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900"> <CycleForm
{initialData ? "Update" : "Create"} Cycle handleFormSubmit={handleFormSubmit}
</Dialog.Title> handleClose={handleClose}
<CycleForm handleFormSubmit={handleFormSubmit} handleFormCancel={handleClose} /> status={data ? true : false}
data={data}
/>
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>
</div> </div>

View File

@ -12,7 +12,7 @@ import { CyclesIcon } from "components/icons";
// services // services
import cycleServices from "services/cycles.service"; import cycleServices from "services/cycles.service";
// components // components
import { CycleModal } from "components/cycles"; import { CreateUpdateCycleModal } from "components/cycles";
// fetch-keys // fetch-keys
import { CYCLE_LIST } from "constants/fetch-keys"; import { CYCLE_LIST } from "constants/fetch-keys";
@ -54,12 +54,7 @@ export const CycleSelect: React.FC<IssueCycleSelectProps> = ({
return ( return (
<> <>
<CycleModal <CreateUpdateCycleModal isOpen={isCycleModalActive} handleClose={closeCycleModal} />
isOpen={isCycleModalActive}
handleClose={closeCycleModal}
projectId={projectId}
workspaceSlug={workspaceSlug as string}
/>
<Listbox as="div" className="relative" value={value} onChange={onChange} multiple={multiple}> <Listbox as="div" className="relative" value={value} onChange={onChange} multiple={multiple}>
{({ open }) => ( {({ open }) => (
<> <>

View File

@ -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<Props> = ({ issues, cycle, isOpen, cycleIssues }) => {
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const [startDateRange, setStartDateRange] = useState<Date | null>(new Date());
const [endDateRange, setEndDateRange] = useState<Date | null>(null);
const { setToastAlert } = useToast();
const defaultValues: Partial<ICycle> = {
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<ICycle>) => {
if (!workspaceSlug || !projectId || !cycleId) return;
mutate<ICycle>(
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 (
<>
<DeleteCycleModal isOpen={cycleDeleteModal} setIsOpen={setCycleDeleteModal} data={cycle} />
<div
className={`fixed top-0 ${
isOpen ? "right-0" : "-right-[24rem]"
} z-20 h-full w-[24rem] overflow-y-auto border-l bg-gray-50 p-5 duration-300`}
>
{cycle ? (
<>
<div className="flex gap-1 text-sm my-2">
<div className="flex items-center ">
<Controller
control={control}
name="status"
render={({ field: { value } }) => (
<CustomSelect
label={
<span
className={`flex items-center gap-1 text-left capitalize p-1 text-xs h-full w-full text-gray-900`}
>
<Squares2X2Icon className="h-4 w-4 flex-shrink-0" />
{watch("status")}
</span>
}
value={value}
onChange={(value: any) => {
submitChanges({ status: value });
}}
>
{CYCLE_STATUS.map((option) => (
<CustomSelect.Option key={option.value} value={option.value}>
<span className="text-xs">{option.label}</span>
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex justify-center items-center gap-2 rounded-md border bg-transparent h-full p-2 px-4 text-xs font-medium text-gray-900 hover:bg-gray-100 hover:text-gray-900 focus:outline-none">
<Popover className="flex justify-center items-center relative rounded-lg">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center ${open ? "bg-gray-100" : ""}`}
>
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0 mr-2" />
<span>
{renderShortNumericDateFormat(`${cycle.start_date}`)
? renderShortNumericDateFormat(`${cycle.start_date}`)
: "N/A"}
</span>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -left-10 z-20 transform overflow-hidden">
<DatePicker
selected={startDateRange}
onChange={(date) => {
submitChanges({
start_date: renderDateFormat(date),
});
setStartDateRange(date);
}}
selectsStart
startDate={startDateRange}
endDate={endDateRange}
inline
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<Popover className="flex justify-center items-center relative rounded-lg">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center ${open ? "bg-gray-100" : ""}`}
>
<span>
-{" "}
{renderShortNumericDateFormat(`${cycle.end_date}`)
? renderShortNumericDateFormat(`${cycle.end_date}`)
: "N/A"}
</span>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -right-20 z-20 transform overflow-hidden">
<DatePicker
selected={endDateRange}
onChange={(date) => {
submitChanges({
end_date: renderDateFormat(date),
});
setEndDateRange(date);
}}
selectsEnd
startDate={startDateRange}
endDate={endDateRange}
minDate={startDateRange}
inline
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
</div>
<div className="flex items-center justify-between pb-3">
<h4 className="text-sm font-medium">{cycle.name}</h4>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="rounded-md border p-2 shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`
)
.then(() => {
setToastAlert({
type: "success",
title: "Cycle link copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<LinkIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-50 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
onClick={() => setCycleDeleteModal(true)}
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="divide-y-2 divide-gray-100 text-xs">
<div className="py-1">
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserIcon className="h-4 w-4 flex-shrink-0" />
<p>Owned by</p>
</div>
<div className="sm:basis-1/2 flex items-center gap-1">
{cycle.owned_by &&
(cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<div className="h-5 w-5 rounded-full border-2 border-transparent">
<Image
src={cycle.owned_by.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={cycle.owned_by?.first_name}
/>
</div>
) : (
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
{cycle.owned_by?.first_name && cycle.owned_by.first_name !== ""
? cycle.owned_by.first_name.charAt(0)
: cycle.owned_by?.email.charAt(0)}
</div>
))}
{cycle.owned_by.first_name !== ""
? cycle.owned_by.first_name
: cycle.owned_by.email}
</div>
</div>
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<ChartPieIcon className="h-4 w-4 flex-shrink-0" />
<p>Progress</p>
</div>
<div className="flex items-center gap-2 sm:basis-1/2">
<div className="grid flex-shrink-0 place-items-center">
<span className="h-4 w-4">
<ProgressBar
value={groupedIssues.completed.length}
maxValue={cycleIssues?.length}
/>
</span>
</div>
{groupedIssues.completed.length}/{cycleIssues?.length}
</div>
</div>
</div>
<div className="py-1" />
</div>
<div className="flex flex-col items-center justify-center w-full gap-2 ">
{isStartValid && isEndValid ? (
<div className="relative h-[200px] w-full ">
<ProgressChart
issues={issues}
start={cycle?.start_date ?? ""}
end={cycle?.end_date ?? ""}
/>
</div>
) : (
""
)}
{issues.length > 0 ? (
<SidebarProgressStats issues={issues} groupedIssues={groupedIssues} />
) : (
""
)}
</div>
</>
) : (
<Loader>
<div className="space-y-2">
<Loader.Item height="15px" width="50%" />
<Loader.Item height="15px" width="30%" />
</div>
<div className="mt-8 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
)}
</div>
</>
);
};

View File

@ -8,6 +8,8 @@ import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// services // services
import cyclesService from "services/cycles.service"; import cyclesService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
// ui // ui
import { Button, CustomMenu } from "components/ui"; import { Button, CustomMenu } from "components/ui";
// icons // icons
@ -17,6 +19,7 @@ import { CyclesIcon } from "components/icons";
// helpers // helpers
import { renderShortNumericDateFormat } from "helpers/date-time.helper"; import { renderShortNumericDateFormat } from "helpers/date-time.helper";
import { groupBy } from "helpers/array.helper"; import { groupBy } from "helpers/array.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import { CycleIssueResponse, ICycle } from "types"; import { CycleIssueResponse, ICycle } from "types";
// fetch-keys // fetch-keys
@ -38,11 +41,12 @@ const stateGroupColours: {
completed: "#096e8d", completed: "#096e8d",
}; };
const SingleStat: React.FC<TSingleStatProps> = (props) => { export const SingleCycleCard: React.FC<TSingleStatProps> = (props) => {
const { cycle, handleEditCycle, handleDeleteCycle } = props; const { cycle, handleEditCycle, handleDeleteCycle } = props;
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const { data: cycleIssues } = useSWR<CycleIssueResponse[]>( const { data: cycleIssues } = useSWR<CycleIssueResponse[]>(
workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null, workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null,
@ -63,6 +67,24 @@ const SingleStat: React.FC<TSingleStatProps> = (props) => {
...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"), ...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 ( return (
<> <>
<div className="rounded-md border bg-white p-3"> <div className="rounded-md border bg-white p-3">
@ -77,6 +99,7 @@ const SingleStat: React.FC<TSingleStatProps> = (props) => {
</a> </a>
</Link> </Link>
<CustomMenu width="auto" ellipsis> <CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy cycle link</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleEditCycle}>Edit cycle</CustomMenu.MenuItem> <CustomMenu.MenuItem onClick={handleEditCycle}>Edit cycle</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}> <CustomMenu.MenuItem onClick={handleDeleteCycle}>
Delete cycle permanently Delete cycle permanently
@ -161,5 +184,3 @@ const SingleStat: React.FC<TSingleStatProps> = (props) => {
</> </>
); );
}; };
export default SingleStat;

View File

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Image from "next/image"; import Image from "next/image";
import { KeyedMutator } from "swr"; import useSWR from "swr";
// icons // icons
import { import {
@ -13,7 +13,7 @@ import {
UserIcon, UserIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// services // services
import issuesServices from "services/issues.service"; import issuesService from "services/issues.service";
// components // components
import { CommentCard } from "components/issues/comment"; import { CommentCard } from "components/issues/comment";
// ui // ui
@ -24,7 +24,8 @@ import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "co
import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper"; import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
// types // types
import { IIssueActivity, IIssueComment } from "types"; import { IIssueComment } from "types";
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
const activityDetails: { const activityDetails: {
[key: string]: { [key: string]: {
@ -85,19 +86,27 @@ const activityDetails: {
}, },
}; };
type Props = { type Props = {};
issueActivities: IIssueActivity[];
mutate: KeyedMutator<IIssueActivity[]>;
};
export const IssueActivitySection: React.FC<Props> = ({ issueActivities, mutate }) => { export const IssueActivitySection: React.FC<Props> = () => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query; 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; if (!workspaceSlug || !projectId || !issueId) return;
await issuesServices await issuesService
.patchIssueComment( .patchIssueComment(
workspaceSlug as string, workspaceSlug as string,
projectId as string, projectId as string,
@ -106,13 +115,13 @@ export const IssueActivitySection: React.FC<Props> = ({ issueActivities, mutate
comment comment
) )
.then((res) => { .then((res) => {
mutate(); mutateIssueActivities();
}); });
}; };
const onCommentDelete = async (commentId: string) => { const handleCommentDelete = async (commentId: string) => {
if (!workspaceSlug || !projectId || !issueId) return; if (!workspaceSlug || !projectId || !issueId) return;
await issuesServices await issuesService
.deleteIssueComment( .deleteIssueComment(
workspaceSlug as string, workspaceSlug as string,
projectId as string, projectId as string,
@ -120,7 +129,7 @@ export const IssueActivitySection: React.FC<Props> = ({ issueActivities, mutate
commentId commentId
) )
.then((response) => { .then((response) => {
mutate(); mutateIssueActivities();
console.log(response); console.log(response);
}); });
}; };
@ -234,8 +243,8 @@ export const IssueActivitySection: React.FC<Props> = ({ issueActivities, mutate
<CommentCard <CommentCard
key={activity.id} key={activity.id}
comment={activity as any} comment={activity as any}
onSubmit={onCommentUpdate} onSubmit={handleCommentUpdate}
handleCommentDeletion={onCommentDelete} handleCommentDeletion={handleCommentDelete}
/> />
); );
})} })}

View File

@ -3,6 +3,8 @@ import React, { useMemo } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { mutate } from "swr";
// react-hook-form // react-hook-form
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
// services // services
@ -12,8 +14,9 @@ import { Loader } from "components/ui";
// helpers // helpers
import { debounce } from "helpers/common.helper"; import { debounce } from "helpers/common.helper";
// types // types
import type { IIssueActivity, IIssueComment } from "types"; import type { IIssueComment } from "types";
import type { KeyedMutator } from "swr"; // fetch-keys
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false, ssr: false,
@ -29,9 +32,7 @@ const defaultValues: Partial<IIssueComment> = {
comment_json: "", comment_json: "",
}; };
export const AddComment: React.FC<{ export const AddComment: React.FC = () => {
mutate: KeyedMutator<IIssueActivity[]>;
}> = ({ mutate }) => {
const { const {
handleSubmit, handleSubmit,
control, control,
@ -57,7 +58,7 @@ export const AddComment: React.FC<{
await issuesServices await issuesServices
.createIssueComment(workspaceSlug as string, projectId as string, issueId as string, formData) .createIssueComment(workspaceSlug as string, projectId as string, issueId as string, formData)
.then(() => { .then(() => {
mutate(); mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
reset(defaultValues); reset(defaultValues);
}) })
.catch((error) => { .catch((error) => {

View File

@ -15,7 +15,7 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui // ui
import { Button } from "components/ui"; import { Button } from "components/ui";
// types // types
import type { CycleIssueResponse, IIssue, IssueResponse, ModuleIssueResponse } from "types"; import type { CycleIssueResponse, IIssue, ModuleIssueResponse } from "types";
// fetch-keys // fetch-keys
import { CYCLE_ISSUES, PROJECT_ISSUES_LIST, MODULE_ISSUES, USER_ISSUE } from "constants/fetch-keys"; import { CYCLE_ISSUES, PROJECT_ISSUES_LIST, MODULE_ISSUES, USER_ISSUE } from "constants/fetch-keys";
@ -77,13 +77,9 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data })
false false
); );
mutate<IssueResponse>( mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId), PROJECT_ISSUES_LIST(workspaceSlug as string, projectId),
(prevData) => ({ (prevData) => (prevData ?? []).filter((i) => i.id !== data.id),
...(prevData as IssueResponse),
results: prevData?.results.filter((i) => i.id !== data.id) ?? [],
count: (prevData?.count as number) - 1,
}),
false false
); );

View File

@ -7,7 +7,7 @@ import { useForm } from "react-hook-form";
// lodash // lodash
import debounce from "lodash.debounce"; import debounce from "lodash.debounce";
// components // components
import { Loader, Input } from "components/ui"; import { Loader, TextArea } from "components/ui";
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false, ssr: false,
loading: () => ( loading: () => (
@ -45,7 +45,6 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
setValue, setValue,
reset, reset,
formState: { errors }, formState: { errors },
setError,
} = useForm<IIssue>({ } = useForm<IIssue>({
defaultValues: { defaultValues: {
name: "", name: "",
@ -76,8 +75,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
handleFormSubmit({ handleFormSubmit({
name: formData.name ?? "", name: formData.name ?? "",
description: formData.description, description: formData.description ?? "",
description_html: formData.description_html, description_html: formData.description_html ?? "<p></p>",
}); });
}, },
[handleFormSubmit, setToastAlert] [handleFormSubmit, setToastAlert]
@ -106,19 +105,20 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
return ( return (
<div> <div>
<Input <TextArea
id="name" id="name"
placeholder="Enter issue name" placeholder="Enter issue name"
name="name" name="name"
value={watch("name")} value={watch("name")}
autoComplete="off"
onChange={(e) => { onChange={(e) => {
setValue("name", e.target.value); setValue("name", e.target.value);
debounceHandler(); debounceHandler();
}} }}
mode="transparent" required={true}
className="text-xl font-medium" className="block px-3 py-2 text-xl
disabled={isNotAllowed} w-full overflow-hidden resize-none min-h-10
rounded border-none bg-transparent ring-0 focus:ring-1 focus:ring-theme outline-none "
role="textbox "
/> />
<span>{errors.name ? errors.name.message : null}</span> <span>{errors.name ? errors.name.message : null}</span>
<RemirrorRichTextEditor <RemirrorRichTextEditor

View File

@ -16,8 +16,9 @@ import {
IssueStateSelect, IssueStateSelect,
} from "components/issues/select"; } from "components/issues/select";
import { CycleSelect as IssueCycleSelect } from "components/cycles/select"; import { CycleSelect as IssueCycleSelect } from "components/cycles/select";
import { CreateUpdateStateModal } from "components/states"; import { CreateStateModal } from "components/states";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal"; import { CreateUpdateCycleModal } from "components/cycles";
import { CreateLabelModal } from "components/labels";
// ui // ui
import { Button, CustomDatePicker, CustomMenu, Input, Loader } from "components/ui"; import { Button, CustomDatePicker, CustomMenu, Input, Loader } from "components/ui";
// icons // icons
@ -48,7 +49,7 @@ const defaultValues: Partial<IIssue> = {
}; };
export interface IssueFormProps { export interface IssueFormProps {
handleFormSubmit: (values: Partial<IIssue>) => void; handleFormSubmit: (values: Partial<IIssue>) => Promise<void>;
initialData?: Partial<IIssue>; initialData?: Partial<IIssue>;
issues: IIssue[]; issues: IIssue[];
projectId: string; projectId: string;
@ -74,6 +75,7 @@ export const IssueForm: FC<IssueFormProps> = ({
const [mostSimilarIssue, setMostSimilarIssue] = useState<IIssue | undefined>(); const [mostSimilarIssue, setMostSimilarIssue] = useState<IIssue | undefined>();
const [cycleModal, setCycleModal] = useState(false); const [cycleModal, setCycleModal] = useState(false);
const [stateModal, setStateModal] = useState(false); const [stateModal, setStateModal] = useState(false);
const [labelModal, setLabelModal] = useState(false);
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
const router = useRouter(); const router = useRouter();
@ -105,30 +107,32 @@ export const IssueForm: FC<IssueFormProps> = ({
reset({ reset({
...defaultValues, ...defaultValues,
project: projectId, project: projectId,
description: "",
description_html: "<p></p>",
}); });
}; };
useEffect(() => { useEffect(() => {
reset({ reset({
...defaultValues, ...defaultValues,
...watch(),
project: projectId,
...initialData, ...initialData,
project: projectId,
}); });
}, [initialData, reset, watch, projectId]); }, [initialData, reset, projectId]);
return ( return (
<> <>
{projectId && ( {projectId && (
<> <>
<CreateUpdateStateModal <CreateStateModal
isOpen={stateModal} isOpen={stateModal}
handleClose={() => setStateModal(false)} handleClose={() => setStateModal(false)}
projectId={projectId} projectId={projectId}
/> />
<CreateUpdateCycleModal <CreateUpdateCycleModal isOpen={cycleModal} handleClose={() => setCycleModal(false)} />
isOpen={cycleModal} <CreateLabelModal
setIsOpen={setCycleModal} isOpen={labelModal}
handleClose={() => setLabelModal(false)}
projectId={projectId} projectId={projectId}
/> />
</> </>
@ -231,13 +235,11 @@ export const IssueForm: FC<IssueFormProps> = ({
<Controller <Controller
name="description" name="description"
control={control} control={control}
render={({ field: { value, onChange } }) => ( render={({ field: { value } }) => (
<RemirrorRichTextEditor <RemirrorRichTextEditor
value={value} value={value}
onBlur={(jsonValue, htmlValue) => { onJSONChange={(jsonValue) => setValue("description", jsonValue)}
setValue("description", jsonValue); onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
setValue("description_html", htmlValue);
}}
placeholder="Enter Your Text..." placeholder="Enter Your Text..."
/> />
)} )}
@ -272,16 +274,14 @@ export const IssueForm: FC<IssueFormProps> = ({
/> />
<Controller <Controller
control={control} control={control}
name="assignees_list" name="labels"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<IssueAssigneeSelect projectId={projectId} value={value} onChange={onChange} /> <IssueLabelSelect
)} setIsOpen={setLabelModal}
/> value={value}
<Controller onChange={onChange}
control={control} projectId={projectId}
name="labels_list" />
render={({ field: { value, onChange } }) => (
<IssueLabelSelect value={value} onChange={onChange} projectId={projectId} />
)} )}
/> />
<div> <div>
@ -297,6 +297,13 @@ export const IssueForm: FC<IssueFormProps> = ({
)} )}
/> />
</div> </div>
<Controller
control={control}
name="assignees"
render={({ field: { value, onChange } }) => (
<IssueAssigneeSelect projectId={projectId} value={value} onChange={onChange} />
)}
/>
<IssueParentSelect <IssueParentSelect
control={control} control={control}
isOpen={parentIssueListModalOpen} isOpen={parentIssueListModalOpen}

View File

@ -9,4 +9,3 @@ export * from "./my-issues-list-item";
export * from "./parent-issues-list-modal"; export * from "./parent-issues-list-modal";
export * from "./sidebar"; export * from "./sidebar";
export * from "./sub-issues-list"; export * from "./sub-issues-list";
export * from "./sub-issues-list-modal";

View File

@ -4,8 +4,6 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui // headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services // services
@ -18,7 +16,7 @@ import useToast from "hooks/use-toast";
// components // components
import { IssueForm } from "components/issues"; import { IssueForm } from "components/issues";
// types // types
import type { IIssue, IssueResponse } from "types"; import type { IIssue } from "types";
// fetch keys // fetch keys
import { import {
PROJECT_ISSUES_DETAILS, PROJECT_ISSUES_DETAILS,
@ -72,11 +70,6 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
workspaceSlug ? () => projectService.getProjects(workspaceSlug as string) : null workspaceSlug ? () => projectService.getProjects(workspaceSlug as string) : null
); );
const { setError } = useForm<IIssue>({
mode: "all",
reValidateMode: "onChange",
});
useEffect(() => { useEffect(() => {
if (projects && projects.length > 0) if (projects && projects.length > 0)
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null); setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
@ -98,15 +91,13 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
false false
); );
} else } else
mutate<IssueResponse>( mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? ""), PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? ""),
(prevData) => ({ (prevData) =>
...(prevData as IssueResponse), (prevData ?? []).map((i) => {
results: (prevData?.results ?? []).map((issue) => { if (i.id === res.id) return { ...i, sprints: cycleId };
if (issue.id === res.id) return { ...issue, sprints: cycleId }; return i;
return issue;
}), }),
}),
false false
); );
}) })
@ -133,7 +124,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
await issuesService await issuesService
.createIssues(workspaceSlug as string, activeProject ?? "", payload) .createIssues(workspaceSlug as string, activeProject ?? "", payload)
.then((res) => { .then((res) => {
mutate<IssueResponse>(PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? "")); mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? ""));
if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle); if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle);
if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module); if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module);
@ -141,30 +132,20 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
if (!createMore) handleClose(); if (!createMore) handleClose();
setToastAlert({ setToastAlert({
title: "Success",
type: "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.assignees_list?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE);
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
}) })
.catch((err) => { .catch(() => {
if (err.detail) { setToastAlert({
setToastAlert({ type: "error",
title: "Join the project.", title: "Error!",
type: "error", message: "Issue could not be created. Please try again.",
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,
});
}); });
}); });
}; };
@ -176,15 +157,13 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
if (isUpdatingSingleIssue) { if (isUpdatingSingleIssue) {
mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false); mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
} else { } else {
mutate<IssueResponse>( mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? ""), PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? ""),
(prevData) => ({ (prevData) =>
...(prevData as IssueResponse), (prevData ?? []).map((i) => {
results: (prevData?.results ?? []).map((issue) => { if (i.id === res.id) return { ...i, ...res };
if (issue.id === res.id) return { ...issue, ...res }; return i;
return issue; })
}),
})
); );
} }
@ -194,14 +173,16 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
if (!createMore) handleClose(); if (!createMore) handleClose();
setToastAlert({ setToastAlert({
title: "Success",
type: "success", type: "success",
message: "Issue updated successfully", title: "Success!",
message: "Issue updated successfully.",
}); });
}) })
.catch((err) => { .catch(() => {
Object.keys(err).map((key) => { setToastAlert({
setError(key as keyof IIssue, { message: err[key].join(", ") }); type: "error",
title: "Error!",
message: "Issue could not be updated. Please try again.",
}); });
}); });
}; };
@ -211,8 +192,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const payload: Partial<IIssue> = { const payload: Partial<IIssue> = {
...formData, ...formData,
description: formData.description ? formData.description : "", assignees_list: formData.assignees,
description_html: formData.description_html ? formData.description_html : "<p></p>", labels_list: formData.labels,
description: formData.description ?? "",
description_html: formData.description_html ?? "<p></p>",
}; };
if (!data) await createIssue(payload); if (!data) await createIssue(payload);
@ -221,7 +204,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
return ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}> <Dialog as="div" className="relative z-20" onClose={() => {}}>
<Transition.Child <Transition.Child
as={React.Fragment} as={React.Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
@ -247,7 +230,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
> >
<Dialog.Panel className="relative transform rounded-lg bg-white p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl"> <Dialog.Panel className="relative transform rounded-lg bg-white p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
<IssueForm <IssueForm
issues={issues?.results ?? []} issues={issues ?? []}
handleFormSubmit={handleFormSubmit} handleFormSubmit={handleFormSubmit}
initialData={prePopulateData} initialData={prePopulateData}
createMore={createMore} createMore={createMore}

View File

@ -1,18 +1,15 @@
import { useState, FC, Fragment } from "react"; import { useState, FC, Fragment } from "react";
import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// headless ui // headless ui
import { Transition, Combobox } from "@headlessui/react"; import { Transition, Combobox } from "@headlessui/react";
// icons // services
import { UserIcon } from "@heroicons/react/24/outline";
// service
import projectServices from "services/project.service"; import projectServices from "services/project.service";
// types // ui
import type { IProjectMember } from "types"; import { AssigneesList, Avatar } from "components/ui";
// fetch keys // fetch keys
import { PROJECT_MEMBERS } from "constants/fetch-keys"; import { PROJECT_MEMBERS } from "constants/fetch-keys";
@ -22,35 +19,6 @@ export type IssueAssigneeSelectProps = {
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
}; };
type AssigneeAvatarProps = {
user: IProjectMember | undefined;
};
export const AssigneeAvatar: FC<AssigneeAvatarProps> = ({ user }) => {
if (!user) return <></>;
if (user.member.avatar && user.member.avatar !== "") {
return (
<div className="relative h-4 w-4">
<Image
src={user.member.avatar}
alt="avatar"
className="rounded-full"
layout="fill"
objectFit="cover"
/>
</div>
);
} else
return (
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 capitalize text-white">
{user.member.first_name && user.member.first_name !== ""
? user.member.first_name.charAt(0)
: user.member.email.charAt(0)}
</div>
);
};
export const IssueAssigneeSelect: FC<IssueAssigneeSelectProps> = ({ export const IssueAssigneeSelect: FC<IssueAssigneeSelectProps> = ({
projectId, projectId,
value = [], value = [],
@ -93,22 +61,10 @@ export const IssueAssigneeSelect: FC<IssueAssigneeSelectProps> = ({
> >
{({ open }: any) => ( {({ open }: any) => (
<> <>
<Combobox.Label className="sr-only">Assignees</Combobox.Label> <Combobox.Button className="flex items-center cursor-pointer gap-1 rounded-md">
<Combobox.Button <div className="flex items-center gap-1 text-xs">
className={`flex cursor-pointer items-center gap-1 rounded-md border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500`} {value && Array.isArray(value) ? <AssigneesList userIds={value} length={10} /> : null}
> </div>
<UserIcon className="h-3 w-3 text-gray-500" />
<span
className={`hidden truncate sm:block ${
value === null || value === undefined ? "" : "text-gray-900"
}`}
>
{Array.isArray(value)
? value
.map((v) => options?.find((option) => option.value === v)?.display)
.join(", ") || "Assignees"
: options?.find((option) => option.value === value)?.display || "Assignees"}
</span>
</Combobox.Button> </Combobox.Button>
<Transition <Transition
@ -136,14 +92,14 @@ export const IssueAssigneeSelect: FC<IssueAssigneeSelectProps> = ({
className={({ active, selected }) => className={({ active, selected }) =>
`${active ? "bg-indigo-50" : ""} ${ `${active ? "bg-indigo-50" : ""} ${
selected ? "bg-indigo-50 font-medium" : "" 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} value={option.value}
> >
{people && ( {people && (
<> <>
<AssigneeAvatar <Avatar
user={people?.find((p) => p.member.id === option.value)} user={people?.find((p) => p.member.id === option.value)?.member}
/> />
{option.display} {option.display}
</> </>

View File

@ -1,15 +1,13 @@
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui // headless ui
import { Combobox, Transition } from "@headlessui/react"; import { Combobox, Transition } from "@headlessui/react";
// icons // icons
import { TagIcon } from "@heroicons/react/24/outline"; import { PlusIcon, RectangleGroupIcon, TagIcon } from "@heroicons/react/24/outline";
// services // services
import issuesServices from "services/issues.service"; import issuesServices from "services/issues.service";
// types // types
@ -18,67 +16,30 @@ import type { IIssueLabels } from "types";
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
type Props = { type Props = {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
value: string[]; value: string[];
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
projectId: string; projectId: string;
}; };
const defaultValues: Partial<IIssueLabels> = { export const IssueLabelSelect: React.FC<Props> = ({ setIsOpen, value, onChange, projectId }) => {
name: "",
};
export const IssueLabelSelect: React.FC<Props> = ({ value, onChange, projectId }) => {
// states // states
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const [isOpen, setIsOpen] = useState(false); const { data: issueLabels } = useSWR<IIssueLabels[]>(
const { data: issueLabels, mutate: issueLabelsMutate } = useSWR<IIssueLabels[]>(
projectId ? PROJECT_ISSUE_LABELS(projectId) : null, projectId ? PROJECT_ISSUE_LABELS(projectId) : null,
workspaceSlug && projectId workspaceSlug && projectId
? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId) ? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId)
: null : 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<IIssueLabels>({ defaultValues });
useEffect(() => {
isOpen && setFocus("name");
}, [isOpen, setFocus]);
const options = issueLabels?.map((label) => ({
value: label.id,
display: label.name,
color: label.color,
}));
const filteredOptions = const filteredOptions =
query === "" query === ""
? options ? issueLabels
: options?.filter((option) => option.display.toLowerCase().includes(query.toLowerCase())); : issueLabels?.filter((l) => l.name.toLowerCase().includes(query.toLowerCase()));
return ( return (
<> <>
@ -98,10 +59,9 @@ export const IssueLabelSelect: React.FC<Props> = ({ value, onChange, projectId }
<TagIcon className="h-3 w-3 text-gray-500" /> <TagIcon className="h-3 w-3 text-gray-500" />
<span className={`flex items-center gap-2 ${!value ? "" : "text-gray-900"}`}> <span className={`flex items-center gap-2 ${!value ? "" : "text-gray-900"}`}>
{Array.isArray(value) {Array.isArray(value)
? value ? value.map((v) => issueLabels?.find((l) => l.id === v)?.name).join(", ") ||
.map((v) => options?.find((option) => option.value === v)?.display) "Labels"
.join(", ") || "Labels" : issueLabels?.find((l) => l.id === value)?.name || "Labels"}
: options?.find((option) => option.value === value)?.display || "Labels"}
</span> </span>
</Combobox.Button> </Combobox.Button>
@ -122,79 +82,77 @@ export const IssueLabelSelect: React.FC<Props> = ({ value, onChange, projectId }
displayValue={(assigned: any) => assigned?.name} displayValue={(assigned: any) => assigned?.name}
/> />
<div className="py-1"> <div className="py-1">
{filteredOptions ? ( {issueLabels && filteredOptions ? (
filteredOptions.length > 0 ? ( filteredOptions.length > 0 ? (
filteredOptions.map((option) => ( filteredOptions.map((label) => {
<Combobox.Option const children = issueLabels?.filter((l) => l.parent === label.id);
key={option.value}
className={({ active, selected }) => if (children.length === 0) {
`${active ? "bg-indigo-50" : ""} ${ if (!label.parent)
selected ? "bg-indigo-50 font-medium" : "" return (
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` <Combobox.Option
} key={label.id}
value={option.value} className={({ active, selected }) =>
> `${active ? "bg-indigo-50" : ""} ${
{issueLabels && ( selected ? "bg-indigo-50 font-medium" : ""
<> } flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
<span }
className="h-1.5 w-1.5 flex-shrink-0 rounded-full" value={label.id}
style={{ >
backgroundColor: option.color, <span
}} className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
/> style={{
{option.display} backgroundColor:
</> label.color && label.color !== "" ? label.color : "#000",
)} }}
</Combobox.Option> />
)) {label.name}
</Combobox.Option>
);
} else
return (
<div className="bg-gray-50 border-y border-gray-400">
<div className="flex select-none font-medium items-center gap-2 truncate p-2 text-gray-900">
<RectangleGroupIcon className="h-3 w-3" /> {label.name}
</div>
<div>
{children.map((child) => (
<Combobox.Option
key={child.id}
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`
}
value={child.id}
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: child?.color ?? "black",
}}
/>
{child.name}
</Combobox.Option>
))}
</div>
</div>
);
})
) : ( ) : (
<p className="text-xs text-gray-500 px-2">No labels found</p> <p className="text-xs text-gray-500 px-2">No labels found</p>
) )
) : ( ) : (
<p className="text-xs text-gray-500 px-2">Loading...</p> <p className="text-xs text-gray-500 px-2">Loading...</p>
)} )}
{/* <div className="cursor-default select-none p-2 hover:bg-indigo-50 hover:text-gray-900"> <button
{isOpen ? ( type="button"
<div className="flex items-center gap-x-1"> className="flex select-none w-full items-center gap-2 p-2 text-gray-400 outline-none hover:bg-indigo-50 hover:text-gray-900"
<Input onClick={() => setIsOpen(true)}
id="name" >
name="name" <PlusIcon className="h-3 w-3 text-gray-400" aria-hidden="true" />
type="text" <span className="text-xs whitespace-nowrap">Create label</span>
placeholder="Title" </button>
className="w-full"
autoComplete="off"
register={register}
validations={{
required: true,
}}
/>
<button
type="button"
className="grid place-items-center text-green-600"
disabled={isSubmitting}
onClick={handleSubmit(onSubmit)}
>
<PlusIcon className="h-4 w-4" />
</button>
<button
type="button"
className="grid place-items-center text-red-600"
onClick={() => setIsOpen(false)}
>
<XMarkIcon className="h-4 w-4" aria-hidden="true" />
</button>
</div>
) : (
<button
type="button"
className="flex items-center gap-2 w-full"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-4 w-4 text-gray-400" aria-hidden="true" />
<span className="text-xs whitespace-nowrap">Create label</span>
</button>
)}
</div> */}
</div> </div>
</Combobox.Options> </Combobox.Options>
</Transition> </Transition>

View File

@ -66,17 +66,11 @@ export const SidebarAssigneeSelect: React.FC<Props> = ({ control, submitChanges,
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer" isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 text-xs`} } items-center gap-1 text-xs`}
> >
<span <div className="flex items-center gap-1 text-xs">
className={`hidden truncate text-left sm:block ${ {value && Array.isArray(value) ? (
value ? "" : "text-gray-900" <AssigneesList userIds={value} length={10} />
}`} ) : null}
> </div>
<div className="flex items-center gap-1 text-xs">
{value && Array.isArray(value) ? (
<AssigneesList userIds={value} length={10} />
) : null}
</div>
</span>
</Listbox.Button> </Listbox.Button>
<Transition <Transition
@ -97,8 +91,8 @@ export const SidebarAssigneeSelect: React.FC<Props> = ({ control, submitChanges,
<Listbox.Option <Listbox.Option
key={option.member.id} key={option.member.id}
className={({ active, selected }) => className={({ active, selected }) =>
`${ `${active || selected ? "bg-indigo-50" : ""} ${
active || selected ? "bg-indigo-50" : "" selected ? "font-medium" : ""
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` } flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
} }
value={option.member.id} value={option.member.id}

View File

@ -127,14 +127,14 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
> >
<Link <Link
href={`/${workspaceSlug}/projects/${projectId}/issues/${ href={`/${workspaceSlug}/projects/${projectId}/issues/${
issues?.results.find((i) => i.id === issue)?.id issues?.find((i) => i.id === issue)?.id
}`} }`}
> >
<a className="flex items-center gap-1"> <a className="flex items-center gap-1">
<BlockedIcon height={10} width={10} /> <BlockedIcon height={10} width={10} />
{`${ {`${issues?.find((i) => i.id === issue)?.project_detail?.identifier}-${
issues?.results.find((i) => i.id === issue)?.project_detail?.identifier issues?.find((i) => i.id === issue)?.sequence_id
}-${issues?.results.find((i) => i.id === issue)?.sequence_id}`} }`}
</a> </a>
</Link> </Link>
<span className="opacity-0 duration-300 group-hover:opacity-100"> <span className="opacity-0 duration-300 group-hover:opacity-100">
@ -243,8 +243,8 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
/> />
<span className="flex-shrink-0 text-xs text-gray-500"> <span className="flex-shrink-0 text-xs text-gray-500">
{ {
issues?.results.find((i) => i.id === issue.id) issues?.find((i) => i.id === issue.id)?.project_detail
?.project_detail?.identifier ?.identifier
} }
-{issue.sequence_id} -{issue.sequence_id}
</span> </span>

View File

@ -119,14 +119,14 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
> >
<Link <Link
href={`/${workspaceSlug}/projects/${projectId}/issues/${ href={`/${workspaceSlug}/projects/${projectId}/issues/${
issues?.results.find((i) => i.id === issue)?.id issues?.find((i) => i.id === issue)?.id
}`} }`}
> >
<a className="flex items-center gap-1"> <a className="flex items-center gap-1">
<BlockerIcon height={10} width={10} /> <BlockerIcon height={10} width={10} />
{`${ {`${issues?.find((i) => i.id === issue)?.project_detail?.identifier}-${
issues?.results.find((i) => i.id === issue)?.project_detail?.identifier issues?.find((i) => i.id === issue)?.sequence_id
}-${issues?.results.find((i) => i.id === issue)?.sequence_id}`} }`}
</a> </a>
</Link> </Link>
<span <span
@ -244,8 +244,8 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
/> />
<span className="flex-shrink-0 text-xs text-gray-500"> <span className="flex-shrink-0 text-xs text-gray-500">
{ {
issues?.results.find((i) => i.id === issue.id) issues?.find((i) => i.id === issue.id)?.project_detail
?.project_detail?.identifier ?.identifier
} }
-{issue.sequence_id} -{issue.sequence_id}
</span> </span>

View File

@ -82,14 +82,14 @@ export const SidebarCycleSelect: React.FC<Props> = ({
{cycles ? ( {cycles ? (
cycles.length > 0 ? ( cycles.length > 0 ? (
<> <>
<CustomSelect.Option value={null} className="capitalize">
None
</CustomSelect.Option>
{cycles.map((option) => ( {cycles.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}> <CustomSelect.Option key={option.id} value={option.id}>
{option.name} {option.name}
</CustomSelect.Option> </CustomSelect.Option>
))} ))}
<CustomSelect.Option value={null} className="capitalize">
None
</CustomSelect.Option>
</> </>
) : ( ) : (
<div className="text-center">No cycles found</div> <div className="text-center">No cycles found</div>

View File

@ -81,14 +81,14 @@ export const SidebarModuleSelect: React.FC<Props> = ({
{modules ? ( {modules ? (
modules.length > 0 ? ( modules.length > 0 ? (
<> <>
<CustomSelect.Option value={null} className="capitalize">
None
</CustomSelect.Option>
{modules.map((option) => ( {modules.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}> <CustomSelect.Option key={option.id} value={option.id}>
{option.name} {option.name}
</CustomSelect.Option> </CustomSelect.Option>
))} ))}
<CustomSelect.Option value={null} className="capitalize">
None
</CustomSelect.Option>
</> </>
) : ( ) : (
<div className="text-center">No modules found</div> <div className="text-center">No modules found</div>

View File

@ -84,9 +84,9 @@ export const SidebarParentSelect: React.FC<Props> = ({
disabled={isNotAllowed} disabled={isNotAllowed}
> >
{watch("parent") && watch("parent") !== "" {watch("parent") && watch("parent") !== ""
? `${ ? `${issues?.find((i) => i.id === watch("parent"))?.project_detail?.identifier}-${
issues?.results.find((i) => i.id === watch("parent"))?.project_detail?.identifier issues?.find((i) => i.id === watch("parent"))?.sequence_id
}-${issues?.results.find((i) => i.id === watch("parent"))?.sequence_id}` }`
: "Select issue"} : "Select issue"}
</button> </button>
</div> </div>

View File

@ -1,4 +1,4 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -13,9 +13,10 @@ import { Popover, Listbox, Transition } from "@headlessui/react";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// services // services
import issuesServices from "services/issues.service"; import issuesService from "services/issues.service";
import modulesService from "services/modules.service"; import modulesService from "services/modules.service";
// components // components
import { LinkModal, LinksList } from "components/core";
import { import {
DeleteIssueModal, DeleteIssueModal,
SidebarAssigneeSelect, SidebarAssigneeSelect,
@ -38,11 +39,12 @@ import {
TrashIcon, TrashIcon,
PlusIcon, PlusIcon,
XMarkIcon, XMarkIcon,
RectangleGroupIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// helpers // helpers
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import type { ICycle, IIssue, IIssueLabels, IModule, UserAuth } from "types"; import type { ICycle, IIssue, IIssueLabels, IIssueLink, IModule, UserAuth } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST, ISSUE_DETAILS } from "constants/fetch-keys"; import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
@ -68,6 +70,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
}) => { }) => {
const [createLabelForm, setCreateLabelForm] = useState(false); const [createLabelForm, setCreateLabelForm] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [linkModal, setLinkModal] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query; const { workspaceSlug, projectId, issueId } = router.query;
@ -79,14 +82,14 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null, : null,
workspaceSlug && projectId workspaceSlug && projectId
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string) ? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
: null : null
); );
const { data: issueLabels, mutate: issueLabelMutate } = useSWR<IIssueLabels[]>( const { data: issueLabels, mutate: issueLabelMutate } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId workspaceSlug && projectId
? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId as string) ? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
: null : null
); );
@ -103,7 +106,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
const handleNewLabel = (formData: any) => { const handleNewLabel = (formData: any) => {
if (!workspaceSlug || !projectId || isSubmitting) return; if (!workspaceSlug || !projectId || isSubmitting) return;
issuesServices issuesService
.createIssueLabel(workspaceSlug as string, projectId as string, formData) .createIssueLabel(workspaceSlug as string, projectId as string, formData)
.then((res) => { .then((res) => {
reset(defaultValues); reset(defaultValues);
@ -117,7 +120,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
(cycleDetail: ICycle) => { (cycleDetail: ICycle) => {
if (!workspaceSlug || !projectId || !issueDetail) return; if (!workspaceSlug || !projectId || !issueDetail) return;
issuesServices issuesService
.addIssueToCycle(workspaceSlug as string, projectId as string, cycleDetail.id, { .addIssueToCycle(workspaceSlug as string, projectId as string, cycleDetail.id, {
issues: [issueDetail.id], issues: [issueDetail.id],
}) })
@ -143,10 +146,63 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
[workspaceSlug, projectId, issueId, issueDetail] [workspaceSlug, projectId, issueId, issueDetail]
); );
const handleCreateLink = async (formData: IIssueLink) => {
if (!workspaceSlug || !projectId || !issueDetail) return;
const previousLinks = issueDetail?.issue_link.map((l) => ({ title: l.title, url: l.url }));
const payload: Partial<IIssue> = {
links_list: [...(previousLinks ?? []), formData],
};
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueDetail.id, payload)
.then((res) => {
mutate(ISSUE_DETAILS(issueDetail.id as string));
})
.catch((err) => {
console.log(err);
});
};
const handleDeleteLink = async (linkId: string) => {
if (!workspaceSlug || !projectId || !issueDetail) return;
const updatedLinks = issueDetail.issue_link.filter((l) => l.id !== linkId);
mutate<IIssue>(
ISSUE_DETAILS(issueDetail.id as string),
(prevData) => ({ ...(prevData as IIssue), issue_link: updatedLinks }),
false
);
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueDetail.id, {
links_list: updatedLinks,
})
.then((res) => {
mutate(ISSUE_DETAILS(issueDetail.id as string));
})
.catch((err) => {
console.log(err);
});
};
useEffect(() => {
if (!createLabelForm) return;
reset();
}, [createLabelForm, reset]);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer; const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return ( return (
<> <>
<LinkModal
isOpen={linkModal}
handleClose={() => setLinkModal(false)}
onFormSubmit={handleCreateLink}
/>
<DeleteIssueModal <DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)} handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
@ -215,7 +271,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
control={control} control={control}
submitChanges={submitChanges} submitChanges={submitChanges}
issuesList={ issuesList={
issues?.results.filter( issues?.filter(
(i) => (i) =>
i.id !== issueDetail?.id && i.id !== issueDetail?.id &&
i.id !== issueDetail?.parent && i.id !== issueDetail?.parent &&
@ -243,13 +299,13 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
/> />
<SidebarBlockerSelect <SidebarBlockerSelect
submitChanges={submitChanges} submitChanges={submitChanges}
issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []} issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
watch={watchIssue} watch={watchIssue}
userAuth={userAuth} userAuth={userAuth}
/> />
<SidebarBlockedSelect <SidebarBlockedSelect
submitChanges={submitChanges} submitChanges={submitChanges}
issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []} issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
watch={watchIssue} watch={watchIssue}
userAuth={userAuth} userAuth={userAuth}
/> />
@ -290,7 +346,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
/> />
</div> </div>
</div> </div>
<div className="space-y-3 pt-3"> <div className="space-y-3 py-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex basis-1/2 items-center gap-x-2 text-sm"> <div className="flex basis-1/2 items-center gap-x-2 text-sm">
<TagIcon className="h-4 w-4" /> <TagIcon className="h-4 w-4" />
@ -298,30 +354,31 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
</div> </div>
<div className="basis-1/2"> <div className="basis-1/2">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{watchIssue("labels_list")?.map((label) => { {watchIssue("labels_list")?.map((labelId) => {
const singleLabel = issueLabels?.find((l) => l.id === label); const label = issueLabels?.find((l) => l.id === labelId);
if (!singleLabel) return null; if (label)
return (
return (
<span
key={singleLabel.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 !== label);
submitChanges({
labels_list: updatedLabels,
});
}}
>
<span <span
className="h-2 w-2 flex-shrink-0 rounded-full" key={label.id}
style={{ backgroundColor: singleLabel?.color ?? "green" }} 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={() => {
{singleLabel.name} const updatedLabels = watchIssue("labels_list")?.filter(
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" /> (l) => l !== labelId
</span> );
); submitChanges({
labels_list: updatedLabels,
});
}}
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: label?.color ?? "black" }}
/>
{label.name}
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
</span>
);
})} })}
<Controller <Controller
control={control} control={control}
@ -336,80 +393,121 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
disabled={isNotAllowed} disabled={isNotAllowed}
> >
{({ open }) => ( {({ open }) => (
<> <div className="relative">
<Listbox.Label className="sr-only">Label</Listbox.Label> <Listbox.Button
<div className="relative"> className={`flex ${
<Listbox.Button isNotAllowed
className={`flex ${ ? "cursor-not-allowed"
isNotAllowed : "cursor-pointer hover:bg-gray-100"
? "cursor-not-allowed" } items-center gap-2 rounded-2xl border px-2 py-0.5 text-xs`}
: "cursor-pointer hover:bg-gray-100" >
} items-center gap-2 rounded-2xl border px-2 py-0.5 text-xs`} Select Label
> </Listbox.Button>
Select Label
</Listbox.Button>
<Transition <Transition
show={open} show={open}
as={React.Fragment} as={React.Fragment}
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-28 w-40 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> <Listbox.Options className="absolute right-0 z-10 mt-1 max-h-28 w-40 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1"> <div className="py-1">
{issueLabels ? ( {issueLabels ? (
issueLabels.length > 0 ? ( issueLabels.length > 0 ? (
issueLabels.map((label: IIssueLabels) => ( issueLabels.map((label: IIssueLabels) => {
<Listbox.Option const children = issueLabels?.filter(
key={label.id} (l) => l.parent === label.id
className={({ active, selected }) => );
`${
active || selected ? "bg-indigo-50" : "" if (children.length === 0) {
} relative flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` if (!label.parent)
} return (
value={label.id} <Listbox.Option
> key={label.id}
<span className={({ active, selected }) =>
className="h-2 w-2 flex-shrink-0 rounded-full" `${active || selected ? "bg-indigo-50" : ""} ${
style={{ backgroundColor: label.color ?? "green" }} selected ? "font-medium" : ""
/> } flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
{label.name} }
</Listbox.Option> value={label.id}
)) >
) : ( <span
<div className="text-center">No labels found</div> className="h-2 w-2 flex-shrink-0 rounded-full"
) style={{
backgroundColor:
label.color && label.color !== ""
? label.color
: "#000",
}}
/>
{label.name}
</Listbox.Option>
);
} else
return (
<div className="bg-gray-50 border-y border-gray-400">
<div className="flex select-none font-medium items-center gap-2 truncate p-2 text-gray-900">
<RectangleGroupIcon className="h-3 w-3" />{" "}
{label.name}
</div>
<div>
{children.map((child) => (
<Listbox.Option
key={child.id}
className={({ active, selected }) =>
`${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}
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: child?.color ?? "black",
}}
/>
{child.name}
</Listbox.Option>
))}
</div>
</div>
);
})
) : ( ) : (
<Spinner /> <div className="text-center">No labels found</div>
)} )
</div> ) : (
</Listbox.Options> <Spinner />
</Transition> )}
</div> </div>
</> </Listbox.Options>
</Transition>
</div>
)} )}
</Listbox> </Listbox>
)} )}
/> />
<button {!isNotAllowed && (
type="button" <button
className={`flex ${ type="button"
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-gray-100" className={`flex ${
} items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs`} isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-gray-100"
onClick={() => setCreateLabelForm((prevData) => !prevData)} } items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs`}
disabled={isNotAllowed} onClick={() => setCreateLabelForm((prevData) => !prevData)}
> >
{createLabelForm ? ( {createLabelForm ? (
<> <>
<XMarkIcon className="h-3 w-3" /> Cancel <XMarkIcon className="h-3 w-3" /> Cancel
</> </>
) : ( ) : (
<> <>
<PlusIcon className="h-3 w-3" /> New <PlusIcon className="h-3 w-3" /> New
</> </>
)} )}
</button> </button>
)}
</div> </div>
</div> </div>
</div> </div>
@ -426,7 +524,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
<span <span
className="h-5 w-5 rounded" className="h-5 w-5 rounded"
style={{ style={{
backgroundColor: watch("color") ?? "green", backgroundColor: watch("color") ?? "black",
}} }}
/> />
)} )}
@ -478,6 +576,29 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
</form> </form>
)} )}
</div> </div>
<div className="py-1 text-xs">
<div className="flex items-center justify-between gap-2">
<h4>Links</h4>
{!isNotAllowed && (
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-100"
onClick={() => setLinkModal(true)}
>
<PlusIcon className="h-4 w-4" />
</button>
)}
</div>
<div className="mt-2 space-y-2">
{issueDetail?.issue_link && issueDetail.issue_link.length > 0 ? (
<LinksList
links={issueDetail.issue_link}
handleDeleteLink={handleDeleteLink}
userAuth={userAuth}
/>
) : null}
</div>
</div>
</div> </div>
</> </>
); );

View File

@ -1,205 +0,0 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// icons
import { RectangleStackIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// services
import issuesServices from "services/issues.service";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types
import { IIssue, IssueResponse } from "types";
// constants
import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
handleClose: () => void;
parent: IIssue | undefined;
};
export const SubIssuesListModal: React.FC<Props> = ({ isOpen, handleClose, parent }) => {
const [query, setQuery] = useState("");
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
: null
);
const filteredIssues: IIssue[] =
query === ""
? issues?.results ?? []
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[];
const handleModalClose = () => {
handleClose();
setQuery("");
};
const addAsSubIssue = (issue: IIssue) => {
if (!workspaceSlug || !projectId) return;
mutate<IIssue[]>(
SUB_ISSUES(parent?.id ?? ""),
(prevData) => {
let newSubIssues = [...(prevData as IIssue[])];
newSubIssues.push(issue);
newSubIssues = orderArrayBy(newSubIssues, "created_at", "descending");
return newSubIssues;
},
false
);
issuesServices
.patchIssue(workspaceSlug as string, projectId as string, issue.id, { parent: parent?.id })
.then((res) => {
mutate(SUB_ISSUES(parent?.id ?? ""));
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => ({
...(prevData as IssueResponse),
results: (prevData?.results ?? []).map((p) => {
if (p.id === res.id)
return {
...p,
...res,
};
return p;
}),
}),
false
);
})
.catch((e) => {
console.log(e);
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<Dialog as="div" className="relative z-20" onClose={handleModalClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
<Combobox>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 && (
<>
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Issues
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => {
if (
(issue.parent === "" || issue.parent === null) && // issue does not have any other parent
issue.id !== parent?.id && // issue is not itself
issue.id !== parent?.parent // issue is not it's parent
)
return (
<Combobox.Option
key={issue.id}
value={{
name: issue.name,
}}
className={({ active }) =>
`flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 ${
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
}`
}
onClick={() => {
addAsSubIssue(issue);
handleClose();
}}
>
<span
className="block flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail.identifier}-{issue.sequence_id}
</span>
{issue.name}
</Combobox.Option>
);
})}
</ul>
</li>
</>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<RectangleStackIcon
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<p className="mt-4 text-sm text-gray-900">
We couldn{"'"}t find any issue with that term. Please try again.
</p>
</div>
)}
</Combobox>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -1,49 +1,146 @@
import { FC, useState } from "react"; import { FC, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// headless ui
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
import { ChevronRightIcon, PlusIcon } from "@heroicons/react/24/outline"; // services
import issuesService from "services/issues.service";
// components // components
import { ExistingIssuesListModal } from "components/core";
import { CreateUpdateIssueModal } from "components/issues";
// ui
import { CustomMenu } from "components/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 // types
import { IIssue, UserAuth } from "types"; import { IIssue, UserAuth } from "types";
// fetch-keys
import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys";
export interface SubIssueListProps { type Props = {
issues: IIssue[];
projectId: string;
workspaceSlug: string;
parentIssue: IIssue; parentIssue: IIssue;
handleSubIssueRemove: (subIssueId: string) => void;
userAuth: UserAuth; userAuth: UserAuth;
} };
export const SubIssuesList: FC<SubIssueListProps> = ({ export const SubIssuesList: FC<Props> = ({ parentIssue, userAuth }) => {
issues = [],
handleSubIssueRemove,
parentIssue,
workspaceSlug,
projectId,
userAuth,
}) => {
// states // states
const [isIssueModalActive, setIssueModalActive] = useState(false); const [createIssueModal, setCreateIssueModal] = useState(false);
const [subIssuesListModal, setSubIssuesListModal] = useState(false); const [subIssuesListModal, setSubIssuesListModal] = useState(false);
const [preloadedData, setPreloadedData] = useState<Partial<IIssue> | null>(null); const [preloadedData, setPreloadedData] = useState<Partial<IIssue> | null>(null);
const openIssueModal = () => { const router = useRouter();
setIssueModalActive(true); const { workspaceSlug, projectId, issueId } = router.query;
const { data: subIssues } = useSWR<IIssue[] | undefined>(
workspaceSlug && projectId && issueId ? SUB_ISSUES(issueId as string) : null,
workspaceSlug && projectId && issueId
? () =>
issuesService.subIssues(workspaceSlug as string, projectId as string, issueId as string)
: null
);
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
: null
);
const addAsSubIssue = async (data: { issues: string[] }) => {
if (!workspaceSlug || !projectId) return;
await issuesService
.addSubIssues(workspaceSlug as string, projectId as string, parentIssue?.id ?? "", {
sub_issue_ids: data.issues,
})
.then((res) => {
mutate<IIssue[]>(
SUB_ISSUES(parentIssue?.id ?? ""),
(prevData) => {
let newSubIssues = [...(prevData as IIssue[])];
data.issues.forEach((issueId: string) => {
const issue = issues?.find((i) => i.id === issueId);
if (issue) newSubIssues.push(issue);
});
newSubIssues = orderArrayBy(newSubIssues, "created_at", "descending");
return newSubIssues;
},
false
);
mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) =>
(prevData ?? []).map((p) => {
if (data.issues.includes(p.id))
return {
...p,
parent: parentIssue.id,
};
return p;
}),
false
);
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
})
.catch((err) => {
console.log(err);
});
}; };
const closeIssueModal = () => { const handleSubIssueRemove = (issueId: string) => {
setIssueModalActive(false); if (!workspaceSlug || !projectId) return;
mutate<IIssue[]>(
SUB_ISSUES(parentIssue.id ?? ""),
(prevData) => prevData?.filter((i) => i.id !== issueId),
false
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId, { parent: null })
.then((res) => {
mutate(SUB_ISSUES(parentIssue.id ?? ""));
mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === res.id)
return {
...p,
...res,
};
return p;
}),
false
);
})
.catch((e) => {
console.error(e);
});
}; };
const openSubIssueModal = () => { const handleCreateIssueModal = () => {
setSubIssuesListModal(true); setCreateIssueModal(true);
}; setPreloadedData({
parent: parentIssue.id,
const closeSubIssueModal = () => { });
setSubIssuesListModal(false);
}; };
const isNotAllowed = userAuth.isGuest || userAuth.isViewer; const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
@ -51,95 +148,114 @@ export const SubIssuesList: FC<SubIssueListProps> = ({
return ( return (
<> <>
<CreateUpdateIssueModal <CreateUpdateIssueModal
isOpen={isIssueModalActive} isOpen={createIssueModal}
prePopulateData={{ ...preloadedData }} prePopulateData={{ ...preloadedData }}
handleClose={closeIssueModal} handleClose={() => setCreateIssueModal(false)}
/> />
<SubIssuesListModal <ExistingIssuesListModal
isOpen={subIssuesListModal} isOpen={subIssuesListModal}
handleClose={() => setSubIssuesListModal(false)} handleClose={() => setSubIssuesListModal(false)}
parent={parentIssue} issues={
issues?.filter(
(i) =>
(i.parent === "" || i.parent === null) &&
i.id !== parentIssue?.id &&
i.id !== parentIssue?.parent
) ?? []
}
handleOnSubmit={addAsSubIssue}
/> />
<Disclosure defaultOpen={true}> {subIssues && subIssues.length > 0 ? (
{({ open }) => ( <Disclosure defaultOpen={true}>
<> {({ open }) => (
<div className="flex items-center justify-between"> <>
<Disclosure.Button className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100"> <div className="flex items-center justify-between">
<ChevronRightIcon className={`h-3 w-3 ${open ? "rotate-90" : ""}`} /> <Disclosure.Button className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100">
Sub-issues <span className="ml-1 text-gray-600">{issues.length}</span> <ChevronRightIcon className={`h-3 w-3 ${open ? "rotate-90" : ""}`} />
</Disclosure.Button> Sub-issues <span className="ml-1 text-gray-600">{subIssues.length}</span>
{open && !isNotAllowed ? ( </Disclosure.Button>
<div className="flex items-center"> {open && !isNotAllowed ? (
<button <div className="flex items-center">
type="button" <button
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100" type="button"
onClick={() => { className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100"
openIssueModal(); onClick={handleCreateIssueModal}
setPreloadedData({
parent: parentIssue.id,
});
}}
>
<PlusIcon className="h-3 w-3" />
Create new
</button>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
setSubIssuesListModal(true);
}}
> >
Add an existing issue <PlusIcon className="h-3 w-3" />
</CustomMenu.MenuItem> Create new
</CustomMenu> </button>
</div>
) : null} <CustomMenu ellipsis>
</div> <CustomMenu.MenuItem onClick={() => setSubIssuesListModal(true)}>
<Transition Add an existing issue
enter="transition duration-100 ease-out" </CustomMenu.MenuItem>
enterFrom="transform scale-95 opacity-0" </CustomMenu>
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel className="mt-3 flex flex-col gap-y-1">
{issues.map((issue) => (
<div
key={issue.id}
className="group flex items-center justify-between gap-2 rounded p-2 hover:bg-gray-100"
>
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}>
<a className="flex items-center gap-2 rounded text-xs">
<span
className="block flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-gray-600">
{issue.project_detail.identifier}-{issue.sequence_id}
</span>
<span className="max-w-sm break-all font-medium">{issue.name}</span>
</a>
</Link>
{!isNotAllowed && (
<div className="opacity-0 group-hover:opacity-100">
<CustomMenu ellipsis>
<CustomMenu.MenuItem onClick={() => handleSubIssueRemove(issue.id)}>
Remove as sub-issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
)}
</div> </div>
))} ) : null}
</Disclosure.Panel> </div>
</Transition> <Transition
</> enter="transition duration-100 ease-out"
)} enterFrom="transform scale-95 opacity-0"
</Disclosure> enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel className="mt-3 flex flex-col gap-y-1">
{subIssues.map((issue) => (
<div
key={issue.id}
className="group flex items-center justify-between gap-2 rounded p-2 hover:bg-gray-100"
>
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}>
<a className="flex items-center gap-2 rounded text-xs">
<span
className="block flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-gray-600">
{issue.project_detail.identifier}-{issue.sequence_id}
</span>
<span className="max-w-sm break-all font-medium">{issue.name}</span>
</a>
</Link>
{!isNotAllowed && (
<button
type="button"
className="opacity-0 group-hover:opacity-100 cursor-pointer"
onClick={() => handleSubIssueRemove(issue.id)}
>
<XMarkIcon className="h-4 w-4 text-gray-500 hover:text-gray-900" />
</button>
)}
</div>
))}
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
) : (
!isNotAllowed && (
<CustomMenu
label={
<>
<PlusIcon className="h-3 w-3" />
Add sub-issue
</>
}
optionsPosition="left"
noBorder
>
<CustomMenu.MenuItem onClick={handleCreateIssueModal}>Create new</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => setSubIssuesListModal(true)}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
)
)}
</> </>
); );
}; };

View File

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
// ui // ui
import { Listbox, Transition } from "@headlessui/react"; import { CustomSelect } from "components/ui";
// icons // icons
import { getPriorityIcon } from "components/icons/priority-icon"; import { getPriorityIcon } from "components/icons/priority-icon";
// types // types
@ -22,67 +22,43 @@ export const ViewPrioritySelect: React.FC<Props> = ({
position = "right", position = "right",
isNotAllowed, isNotAllowed,
}) => ( }) => (
<Listbox <CustomSelect
as="div" label={
value={issue.priority} <span>
{getPriorityIcon(
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
"text-sm"
)}
</span>
}
value={issue.state}
onChange={(data: string) => { onChange={(data: string) => {
partialUpdateIssue({ priority: data }); 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} disabled={isNotAllowed}
> >
{({ open }) => ( {PRIORITIES?.map((priority) => (
<div> <CustomSelect.Option key={priority} value={priority} className="capitalize">
<Listbox.Button <>
className={`flex ${ {getPriorityIcon(priority, "text-sm")}
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer" {priority ?? "None"}
} 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" </CustomSelect.Option>
? "bg-red-100 text-red-600" ))}
: issue.priority === "high" </CustomSelect>
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{getPriorityIcon(
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
"text-sm"
)}
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
className={`absolute z-10 mt-1 max-h-48 w-36 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none ${
position === "left" ? "left-0" : "right-0"
}`}
>
{PRIORITIES?.map((priority) => (
<Listbox.Option
key={priority}
className={({ active }) =>
`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"}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
); );

View File

@ -24,15 +24,17 @@ type Props = {
export const ViewStateSelect: React.FC<Props> = ({ export const ViewStateSelect: React.FC<Props> = ({
issue, issue,
partialUpdateIssue, partialUpdateIssue,
position, position = "right",
isNotAllowed, isNotAllowed,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug } = router.query;
const { data: stateGroups } = useSWR( const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATE_LIST(issue.project) : null, workspaceSlug && issue ? STATE_LIST(issue.project) : null,
workspaceSlug ? () => stateService.getStates(workspaceSlug as string, issue.project) : null workspaceSlug && issue
? () => stateService.getStates(workspaceSlug as string, issue.project)
: null
); );
const states = getStatesList(stateGroups ?? {}); const states = getStatesList(stateGroups ?? {});

View File

@ -0,0 +1,189 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// react-color
import { TwitterPicker } from "react-color";
// headless ui
import { Dialog, Popover, Transition } from "@headlessui/react";
// services
import issuesService from "services/issues.service";
// ui
import { Button, Input } from "components/ui";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
// types
import type { IIssueLabels, IState } from "types";
// constants
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
// types
type Props = {
isOpen: boolean;
projectId: string;
handleClose: () => void;
};
const defaultValues: Partial<IState> = {
name: "",
color: "#000000",
};
export const CreateLabelModal: React.FC<Props> = ({ isOpen, projectId, handleClose }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
watch,
control,
reset,
setError,
} = useForm<IIssueLabels>({
defaultValues,
});
const onClose = () => {
handleClose();
reset(defaultValues);
};
const onSubmit = async (formData: IIssueLabels) => {
if (!workspaceSlug) return;
await issuesService
.createIssueLabel(workspaceSlug as string, projectId as string, formData)
.then((res) => {
mutate<IIssueLabels[]>(
PROJECT_ISSUE_LABELS(projectId),
(prevData) => [res, ...(prevData ?? [])],
false
);
onClose();
})
.catch((error) => {
console.log(error);
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Create Label
</Dialog.Title>
<div className="mt-8 flex items-center gap-2">
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group inline-flex items-center rounded-sm bg-white text-base font-medium hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
open ? "text-gray-900" : "text-gray-500"
}`}
>
<span>Color</span>
{watch("color") && watch("color") !== "" && (
<span
className="ml-2 h-4 w-4 rounded"
style={{
backgroundColor: watch("color") ?? "black",
}}
/>
)}
<ChevronDownIcon
className={`ml-2 h-5 w-5 group-hover:text-gray-500 ${
open ? "text-gray-600" : "text-gray-400"
}`}
aria-hidden="true"
/>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="fixed left-5 z-50 mt-3 w-screen max-w-xs transform px-2 sm:px-0">
<Controller
name="color"
control={control}
render={({ field: { value, onChange } }) => (
<TwitterPicker
color={value}
onChange={(value) => onChange(value.hex)}
/>
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<Input
type="text"
id="name"
name="name"
placeholder="Enter name"
autoComplete="off"
error={errors.name}
register={register}
width="full"
validations={{
required: "Name is required",
}}
/>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button theme="secondary" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating Label..." : "Create Label"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -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<React.SetStateAction<boolean>>;
isUpdating: boolean;
labelToUpdate: IIssueLabels | null;
};
const defaultValues: Partial<IIssueLabels> = {
name: "",
color: "#ff0000",
};
export const CreateUpdateLabelInline: React.FC<Props> = ({
labelForm,
setLabelForm,
isUpdating,
labelToUpdate,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
handleSubmit,
control,
register,
reset,
formState: { errors, isSubmitting },
watch,
setValue,
} = useForm<IIssueLabels>({
defaultValues,
});
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
await issuesService
.createIssueLabel(workspaceSlug as string, projectId as string, formData)
.then((res) => {
mutate<IIssueLabels[]>(
PROJECT_ISSUE_LABELS(projectId as string),
(prevData) => [res, ...(prevData ?? [])],
false
);
reset(defaultValues);
setLabelForm(false);
});
};
const handleLabelUpdate: SubmitHandler<IIssueLabels> = 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<IIssueLabels[]>(
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 (
<div
className={`flex items-center gap-2 rounded-md border p-3 md:w-2/3 ${
labelForm ? "" : "hidden"
}`}
>
<div className="h-8 w-8 flex-shrink-0">
<Popover className="relative z-10 flex h-full w-full items-center justify-center rounded-xl bg-gray-200">
{({ open }) => (
<>
<Popover.Button
className={`group inline-flex items-center text-base font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
open ? "text-gray-900" : "text-gray-500"
}`}
>
{watch("color") && watch("color") !== "" && (
<span
className="h-4 w-4 rounded"
style={{
backgroundColor: watch("color") ?? "#000",
}}
/>
)}
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-full left-0 z-20 mt-3 w-screen max-w-xs px-2 sm:px-0">
<Controller
name="color"
control={control}
render={({ field: { value, onChange } }) => (
<TwitterPicker color={value} onChange={(value) => onChange(value.hex)} />
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<div className="flex w-full flex-col justify-center">
<Input
type="text"
id="labelName"
name="name"
register={register}
placeholder="Label title"
validations={{
required: "Label title is required",
}}
error={errors.name}
/>
</div>
<Button
type="button"
theme="secondary"
onClick={() => {
reset();
setLabelForm(false);
}}
>
Cancel
</Button>
{isUpdating ? (
<Button type="button" onClick={handleSubmit(handleLabelUpdate)} disabled={isSubmitting}>
{isSubmitting ? "Updating" : "Update"}
</Button>
) : (
<Button type="button" onClick={handleSubmit(handleLabelCreate)} disabled={isSubmitting}>
{isSubmitting ? "Adding" : "Add"}
</Button>
)}
</div>
);
};

View File

@ -1,2 +1,5 @@
export * from "./create-label-modal";
export * from "./create-update-label-inline";
export * from "./labels-list-modal"; export * from "./labels-list-modal";
export * from "./single-label-group";
export * from "./single-label"; export * from "./single-label";

View File

@ -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<Props> = ({
label,
labelChildren,
addLabelToGroup,
editLabel,
handleLabelDelete,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const removeFromGroup = (label: IIssueLabels) => {
if (!workspaceSlug || !projectId) return;
mutate<IIssueLabels[]>(
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 (
<Disclosure as="div" className="rounded-md border p-3 text-gray-900 md:w-2/3" defaultOpen>
{({ open }) => (
<>
<div className="flex items-center justify-between gap-2 cursor-pointer">
<Disclosure.Button>
<div className="flex items-center gap-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${!open ? "-rotate-90 transform" : ""}`}
/>
</span>
<span>
<RectangleGroupIcon className="h-4 w-4" />
</span>
<h6 className="text-sm">{label.name}</h6>
</div>
</Disclosure.Button>
<CustomMenu ellipsis>
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
Add more labels
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => editLabel(label)}>Edit</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleLabelDelete(label.id)}>
Delete
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="mt-2 ml-4">
{labelChildren.map((child) => (
<div
key={child.id}
className="group pl-4 py-1 flex items-center justify-between rounded text-sm hover:bg-gray-100"
>
<h5 className="flex items-center gap-2">
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: child.color,
}}
/>
{child.name}
</h5>
<div className="opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
<CustomMenu ellipsis>
<CustomMenu.MenuItem onClick={() => removeFromGroup(child)}>
Remove from group
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => editLabel(child)}>
Edit
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleLabelDelete(child.id)}>
Delete
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
))}
</div>
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
);
};

View File

@ -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 // ui
import { CustomMenu } from "components/ui"; import { CustomMenu } from "components/ui";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
// types // types
import { IIssueLabels } from "types"; import { IIssueLabels } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
type Props = { type Props = {
label: IIssueLabels; label: IIssueLabels;
issueLabels: IIssueLabels[]; addLabelToGroup: (parentLabel: IIssueLabels) => void;
editLabel: (label: IIssueLabels) => void; editLabel: (label: IIssueLabels) => void;
handleLabelDelete: (labelId: string) => void; handleLabelDelete: (labelId: string) => void;
}; };
export const SingleLabel: React.FC<Props> = ({ export const SingleLabel: React.FC<Props> = ({
label, label,
issueLabels, addLabelToGroup,
editLabel, editLabel,
handleLabelDelete, handleLabelDelete,
}) => { }) => (
const [labelsListModal, setLabelsListModal] = useState(false); <div className="gap-2 space-y-3 divide-y rounded-md border p-3 md:w-2/3">
<div className="flex items-center justify-between">
const router = useRouter(); <div className="flex items-center gap-2">
const { workspaceSlug, projectId } = router.query; <span
className="h-3 w-3 flex-shrink-0 rounded-full"
const children = issueLabels?.filter((l) => l.parent === label.id); style={{
backgroundColor: label.color && label.color !== "" ? label.color : "#000",
const removeFromGroup = (label: IIssueLabels) => { }}
if (!workspaceSlug || !projectId) return; />
<h6 className="text-sm">{label.name}</h6>
mutate<IIssueLabels[]>( </div>
PROJECT_ISSUE_LABELS(projectId as string), <CustomMenu ellipsis>
(prevData) => <CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
prevData?.map((l) => { Convert to group
if (l.id === label.id) return { ...l, parent: null }; </CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => editLabel(label)}>Edit</CustomMenu.MenuItem>
return l; <CustomMenu.MenuItem onClick={() => handleLabelDelete(label.id)}>
}), Delete
false </CustomMenu.MenuItem>
); </CustomMenu>
</div>
issuesService </div>
.patchIssueLabel(workspaceSlug as string, projectId as string, label.id, { );
parent: null,
})
.then((res) => {
mutate(PROJECT_ISSUE_LABELS(projectId as string));
});
};
return (
<>
<LabelsListModal
isOpen={labelsListModal}
handleClose={() => setLabelsListModal(false)}
parent={label}
/>
{children && children.length === 0 ? (
label.parent === "" || !label.parent ? (
<div className="gap-2 space-y-3 divide-y rounded-md border p-3 md:w-2/3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="h-3 w-3 flex-shrink-0 rounded-full"
style={{
backgroundColor: label.color,
}}
/>
<h6 className="text-sm">{label.name}</h6>
</div>
<CustomMenu ellipsis>
<CustomMenu.MenuItem onClick={() => setLabelsListModal(true)}>
Convert to group
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => editLabel(label)}>Edit</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleLabelDelete(label.id)}>
Delete
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
) : null
) : (
<Disclosure as="div" className="relative z-20 rounded-md border p-3 text-gray-900 md:w-2/3">
{({ open }) => (
<>
<div className="flex items-center justify-between gap-2 cursor-pointer">
<Disclosure.Button>
<div className="flex items-center gap-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${!open ? "-rotate-90 transform" : ""}`}
/>
</span>
<h6 className="text-sm">{label.name}</h6>
</div>
</Disclosure.Button>
<CustomMenu ellipsis>
<CustomMenu.MenuItem onClick={() => setLabelsListModal(true)}>
Add more labels
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => editLabel(label)}>Edit</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleLabelDelete(label.id)}>
Delete
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="mt-2 ml-4">
{children.map((child) => (
<div
key={child.id}
className="group pl-4 py-1 flex items-center justify-between rounded text-sm hover:bg-gray-100"
>
<h5 className="flex items-center gap-2">
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: child.color,
}}
/>
{child.name}
</h5>
<div className="opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
<CustomMenu ellipsis>
<CustomMenu.MenuItem onClick={() => removeFromGroup(child)}>
Remove from group
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => editLabel(child)}>
Edit
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleLabelDelete(child.id)}>
Delete
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
))}
</div>
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
)}
</>
);
};

View File

@ -65,10 +65,6 @@ export const DeleteModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data })
}); });
}; };
useEffect(() => {
data && setIsOpen(true);
}, [data, setIsOpen]);
return ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog <Dialog

View File

@ -1,3 +1,5 @@
import { useEffect } from "react";
// react-hook-form // react-hook-form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// components // components
@ -8,9 +10,10 @@ import { Button, CustomDatePicker, Input, TextArea } from "components/ui";
import { IModule } from "types"; import { IModule } from "types";
type Props = { type Props = {
handleFormSubmit: (values: Partial<IModule>) => void; handleFormSubmit: (values: Partial<IModule>) => Promise<void>;
handleClose: () => void; handleClose: () => void;
status: boolean; status: boolean;
data?: IModule;
}; };
const defaultValues: Partial<IModule> = { const defaultValues: Partial<IModule> = {
@ -21,7 +24,7 @@ const defaultValues: Partial<IModule> = {
members_list: [], members_list: [],
}; };
export const ModuleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status }) => { export const ModuleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status, data }) => {
const { const {
register, register,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
@ -40,6 +43,13 @@ export const ModuleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, sta
}); });
}; };
useEffect(() => {
reset({
...defaultValues,
...data,
});
}, [data, reset]);
return ( return (
<form onSubmit={handleSubmit(handleCreateUpdateModule)}> <form onSubmit={handleSubmit(handleCreateUpdateModule)}>
<div className="space-y-5"> <div className="space-y-5">

View File

@ -3,6 +3,5 @@ export * from "./sidebar-select";
export * from "./delete-module-modal"; export * from "./delete-module-modal";
export * from "./form"; export * from "./form";
export * from "./modal"; export * from "./modal";
export * from "./module-link-modal";
export * from "./sidebar"; export * from "./sidebar";
export * from "./single-module-card"; export * from "./single-module-card";

View File

@ -14,8 +14,6 @@ import { ModuleForm } from "components/modules";
import modulesService from "services/modules.service"; import modulesService from "services/modules.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// helpers
import { renderDateFormat } from "helpers/date-time.helper";
// types // types
import type { IModule } from "types"; import type { IModule } from "types";
// fetch-keys // fetch-keys
@ -46,7 +44,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, da
reset(defaultValues); reset(defaultValues);
}; };
const { reset, setError } = useForm<IModule>({ const { reset } = useForm<IModule>({
defaultValues, defaultValues,
}); });
@ -58,16 +56,16 @@ export const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, da
handleClose(); handleClose();
setToastAlert({ setToastAlert({
title: "Success",
type: "success", type: "success",
message: "Module created successfully", title: "Success!",
message: "Module created successfully.",
}); });
}) })
.catch((err) => { .catch(() => {
Object.keys(err).map((key) => { setToastAlert({
setError(key as keyof typeof defaultValues, { type: "error",
message: err[key].join(", "), title: "Error!",
}); message: "Module could not be created. Please try again.",
}); });
}); });
}; };
@ -92,16 +90,16 @@ export const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, da
handleClose(); handleClose();
setToastAlert({ setToastAlert({
title: "Success",
type: "success", type: "success",
message: "Module updated successfully", title: "Success!",
message: "Module updated successfully.",
}); });
}) })
.catch((err) => { .catch(() => {
Object.keys(err).map((key) => { setToastAlert({
setError(key as keyof typeof defaultValues, { type: "error",
message: err[key].join(", "), title: "Error!",
}); message: "Module could not be updated. Please try again.",
}); });
}); });
}; };
@ -117,15 +115,6 @@ export const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, da
else await updateModule(payload); else await updateModule(payload);
}; };
useEffect(() => {
if (data) {
setIsOpen(true);
reset(data);
} else {
reset(defaultValues);
}
}, [data, setIsOpen, reset]);
return ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}> <Dialog as="div" className="relative z-20" onClose={handleClose}>
@ -157,6 +146,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, da
handleFormSubmit={handleFormSubmit} handleFormSubmit={handleFormSubmit}
handleClose={handleClose} handleClose={handleClose}
status={data ? true : false} status={data ? true : false}
data={data}
/> />
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -13,34 +13,35 @@ import {
ChartPieIcon, ChartPieIcon,
LinkIcon, LinkIcon,
PlusIcon, PlusIcon,
Squares2X2Icon,
TrashIcon, TrashIcon,
} from "@heroicons/react/24/outline"; } 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 // services
import modulesService from "services/modules.service"; import modulesService from "services/modules.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { import { LinkModal, LinksList, SidebarProgressStats } from "components/core";
ModuleLinkModal, import { DeleteModuleModal, SidebarLeadSelect, SidebarMembersSelect } from "components/modules";
SidebarLeadSelect, import ProgressChart from "components/core/sidebar/progress-chart";
SidebarMembersSelect,
SidebarStatusSelect,
} from "components/modules";
import "react-circular-progressbar/dist/styles.css"; // components
// ui // ui
import { CustomDatePicker, Loader } from "components/ui"; import { CustomSelect, Loader, ProgressBar } from "components/ui";
// helpers // helpers
import { timeAgo } from "helpers/date-time.helper"; import { renderDateFormat, renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
import { groupBy } from "helpers/array.helper"; import { groupBy } from "helpers/array.helper";
// types // types
import { IIssue, IModule, ModuleIssueResponse } from "types"; import { IIssue, IModule, ModuleIssueResponse, ModuleLink, UserAuth } from "types";
// fetch-keys // fetch-keys
import { MODULE_DETAILS } from "constants/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<IModule> = { const defaultValues: Partial<IModule> = {
lead: "", lead: "",
@ -55,7 +56,7 @@ type Props = {
module?: IModule; module?: IModule;
isOpen: boolean; isOpen: boolean;
moduleIssues: ModuleIssueResponse[] | undefined; moduleIssues: ModuleIssueResponse[] | undefined;
handleDeleteModule: () => void; userAuth: UserAuth;
}; };
export const ModuleDetailsSidebar: React.FC<Props> = ({ export const ModuleDetailsSidebar: React.FC<Props> = ({
@ -63,9 +64,12 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
module, module,
isOpen, isOpen,
moduleIssues, moduleIssues,
handleDeleteModule, userAuth,
}) => { }) => {
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
const [moduleLinkModal, setModuleLinkModal] = useState(false); const [moduleLinkModal, setModuleLinkModal] = useState(false);
const [startDateRange, setStartDateRange] = useState<Date | null>(new Date());
const [endDateRange, setEndDateRange] = useState<Date | null>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query; const { workspaceSlug, projectId, moduleId } = router.query;
@ -108,6 +112,36 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
}); });
}; };
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<IModule> = {
links_list: [...(previousLinks ?? []), formData],
};
await modulesService
.patchModule(workspaceSlug as string, projectId as string, moduleId as string, payload)
.then((res) => {
mutate(MODULE_DETAILS(moduleId as string));
})
.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(() => { useEffect(() => {
if (module) if (module)
reset({ reset({
@ -116,12 +150,20 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
}); });
}, [module, reset]); }, [module, reset]);
const isStartValid = new Date(`${module?.start_date}`) <= new Date();
const isEndValid = new Date(`${module?.target_date}`) >= new Date(`${module?.start_date}`);
return ( return (
<> <>
<ModuleLinkModal <LinkModal
isOpen={moduleLinkModal} isOpen={moduleLinkModal}
handleClose={() => setModuleLinkModal(false)} handleClose={() => setModuleLinkModal(false)}
module={module} onFormSubmit={handleCreateLink}
/>
<DeleteModuleModal
isOpen={moduleDeleteModal}
setIsOpen={setModuleDeleteModal}
data={module}
/> />
<div <div
className={`fixed top-0 ${ className={`fixed top-0 ${
@ -130,6 +172,123 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
> >
{module ? ( {module ? (
<> <>
<div className="flex gap-1 text-sm my-2">
<div className="flex items-center ">
<Controller
control={control}
name="status"
render={({ field: { value } }) => (
<CustomSelect
label={
<span
className={`flex items-center gap-1 text-left capitalize p-1 text-xs h-full w-full text-gray-900`}
>
<Squares2X2Icon className="h-4 w-4 flex-shrink-0" />
{watch("status")}
</span>
}
value={value}
onChange={(value: any) => {
submitChanges({ status: value });
}}
>
{MODULE_STATUS.map((option) => (
<CustomSelect.Option key={option.value} value={option.value}>
<span className="text-xs">{option.label}</span>
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex justify-center items-center gap-2 rounded-md border bg-transparent h-full p-2 px-4 text-xs font-medium text-gray-900 hover:bg-gray-100 hover:text-gray-900 focus:outline-none">
<Popover className="flex justify-center items-center relative rounded-lg">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center ${open ? "bg-gray-100" : ""}`}
>
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0 mr-2" />
<span>
{renderShortNumericDateFormat(`${module?.start_date}`)
? renderShortNumericDateFormat(`${module?.start_date}`)
: "N/A"}
</span>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -left-10 z-20 transform overflow-hidden">
<DatePicker
selected={startDateRange}
onChange={(date) => {
submitChanges({
start_date: renderDateFormat(date),
});
setStartDateRange(date);
}}
selectsStart
startDate={startDateRange}
endDate={endDateRange}
inline
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<Popover className="flex justify-center items-center relative rounded-lg">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center ${open ? "bg-gray-100" : ""}`}
>
<span>
-{" "}
{renderShortNumericDateFormat(`${module?.target_date}`)
? renderShortNumericDateFormat(`${module?.target_date}`)
: "N/A"}
</span>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -right-20 z-20 transform overflow-hidden">
<DatePicker
selected={endDateRange}
onChange={(date) => {
submitChanges({
target_date: renderDateFormat(date),
});
setEndDateRange(date);
}}
selectsEnd
startDate={startDateRange}
endDate={endDateRange}
minDate={startDateRange}
inline
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
</div>
<div className="flex items-center justify-between pb-3"> <div className="flex items-center justify-between pb-3">
<h4 className="text-sm font-medium">{module.name}</h4> <h4 className="text-sm font-medium">{module.name}</h4>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -159,7 +318,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
<button <button
type="button" type="button"
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-50 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-50 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
onClick={() => handleDeleteModule()} onClick={() => setModuleDeleteModal(true)}
> >
<TrashIcon className="h-3.5 w-3.5" /> <TrashIcon className="h-3.5 w-3.5" />
</button> </button>
@ -181,10 +340,9 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
<div className="flex items-center gap-2 sm:basis-1/2"> <div className="flex items-center gap-2 sm:basis-1/2">
<div className="grid flex-shrink-0 place-items-center"> <div className="grid flex-shrink-0 place-items-center">
<span className="h-4 w-4"> <span className="h-4 w-4">
<CircularProgressbar <ProgressBar
value={groupedIssues.completed.length} value={groupedIssues.completed.length}
maxValue={moduleIssues?.length} maxValue={moduleIssues?.length}
strokeWidth={10}
/> />
</span> </span>
</div> </div>
@ -192,59 +350,6 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
</div> </div>
</div> </div>
</div> </div>
<div className="py-1">
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0" />
<p>Start date</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="start_date"
render={({ field: { value } }) => (
<CustomDatePicker
value={value}
onChange={(val) =>
submitChanges({
start_date: val,
})
}
/>
)}
/>
</div>
</div>
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0" />
<p>End date</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="target_date"
render={({ field: { value } }) => (
<CustomDatePicker
value={value}
onChange={(val) =>
submitChanges({
target_date: val,
})
}
/>
)}
/>
</div>
</div>
</div>
<div className="py-1">
<SidebarStatusSelect
control={control}
submitChanges={submitChanges}
watch={watch}
/>
</div>
<div className="py-1"> <div className="py-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h4>Links</h4> <h4>Links</h4>
@ -257,45 +362,31 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
</button> </button>
</div> </div>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{module.link_module && module.link_module.length > 0 {module.link_module && module.link_module.length > 0 ? (
? module.link_module.map((link) => ( <LinksList
<div key={link.id} className="group relative"> links={module.link_module}
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover:opacity-100"> handleDeleteLink={handleDeleteLink}
<button userAuth={userAuth}
type="button" />
className="grid h-7 w-7 place-items-center rounded bg-gray-100 p-1 text-red-500 outline-none duration-300 hover:bg-red-50" ) : null}
onClick={() => {
const updatedLinks = module.link_module.filter(
(l) => l.id !== link.id
);
submitChanges({ links_list: updatedLinks });
}}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
<Link href={link.url} target="_blank">
<a className="group relative flex gap-2 rounded-md border bg-gray-100 p-2">
<div className="mt-0.5">
<LinkIcon className="h-3.5 w-3.5" />
</div>
<div>
<h5>{link.title}</h5>
<p className="mt-0.5 text-gray-500">
Added {timeAgo(link.created_at)} ago by{" "}
{link.created_by_detail.email}
</p>
</div>
</a>
</Link>
</div>
))
: null}
</div> </div>
</div> </div>
</div> </div>
<div className="w-full"> <div className="flex flex-col items-center justify-center w-full gap-2 ">
<SidebarProgressStats issues={issues} groupedIssues={groupedIssues} /> {isStartValid && isEndValid ? (
<ProgressChart
issues={issues}
start={module?.start_date ?? ""}
end={module?.target_date ?? ""}
/>
) : (
""
)}
{issues.length > 0 ? (
<SidebarProgressStats issues={issues} groupedIssues={groupedIssues} />
) : (
""
)}
</div> </div>
</> </>
) : ( ) : (

View File

@ -1,152 +1,115 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// components // components
import { DeleteModuleModal } from "components/modules"; import { DeleteModuleModal } from "components/modules";
// ui
import { AssigneesList, Avatar, CustomMenu } from "components/ui";
// icons // icons
import { CalendarDaysIcon, TrashIcon } from "@heroicons/react/24/outline"; import { CalendarDaysIcon, TrashIcon } from "@heroicons/react/24/outline";
import User from "public/user.png";
// helpers // helpers
import { renderShortNumericDateFormat } from "helpers/date-time.helper"; import { renderShortNumericDateFormat } from "helpers/date-time.helper";
// types // types
import { IModule, SelectModuleType } from "types"; import { IModule } from "types";
// common // common
import { MODULE_STATUS } from "constants/module"; import { MODULE_STATUS } from "constants/module";
import useToast from "hooks/use-toast";
import { copyTextToClipboard } from "helpers/string.helper";
type Props = { type Props = {
module: IModule; module: IModule;
handleEditModule: () => void;
}; };
export const SingleModuleCard: React.FC<Props> = ({ module }) => { export const SingleModuleCard: React.FC<Props> = ({ module, handleEditModule }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
const [selectedModuleForDelete, setSelectedModuleForDelete] = useState<SelectModuleType>();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const handleDeleteModule = () => { const handleDeleteModule = () => {
if (!module) return; if (!module) return;
setSelectedModuleForDelete({ ...module, actionType: "delete" });
setModuleDeleteModal(true); 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 ( return (
<div className="group/card h-full w-full relative select-none p-2"> <>
<div className="absolute top-4 right-4 z-50 bg-red-200 opacity-0 group-hover/card:opacity-100">
<button
type="button"
className="grid h-7 w-7 place-items-center bg-white p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
onClick={() => handleDeleteModule()}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
<DeleteModuleModal <DeleteModuleModal
isOpen={ isOpen={moduleDeleteModal}
moduleDeleteModal &&
!!selectedModuleForDelete &&
selectedModuleForDelete.actionType === "delete"
}
setIsOpen={setModuleDeleteModal} setIsOpen={setModuleDeleteModal}
data={selectedModuleForDelete} data={module}
/> />
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}> <div className="group/card h-full w-full relative select-none p-2">
<a className="flex flex-col cursor-pointer rounded-md border bg-white p-3 "> <div className="absolute top-4 right-4 ">
<span className="w-3/4 text-ellipsis overflow-hidden">{module.name}</span> <CustomMenu width="auto" ellipsis>
<div className="mt-4 grid grid-cols-2 gap-2 text-xs md:grid-cols-4"> <CustomMenu.MenuItem onClick={handleCopyText}>Copy module link</CustomMenu.MenuItem>
<div className="space-y-2"> <CustomMenu.MenuItem onClick={handleEditModule}>Edit module</CustomMenu.MenuItem>
<h6 className="text-gray-500">LEAD</h6> <CustomMenu.MenuItem onClick={handleDeleteModule}>
<div> Delete module permanently
{module.lead ? ( </CustomMenu.MenuItem>
module.lead_detail?.avatar && module.lead_detail.avatar !== "" ? ( </CustomMenu>
<div className="h-5 w-5 rounded-full border-2 border-white"> </div>
<Image <Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
src={module.lead_detail.avatar} <a className="flex flex-col justify-between h-full cursor-pointer rounded-md border bg-white p-3 ">
height="100%" <span className="w-3/4 text-ellipsis overflow-hidden">{module.name}</span>
width="100%" <div className="mt-4 grid grid-cols-2 gap-2 text-xs md:grid-cols-4">
className="rounded-full" <div className="space-y-2">
alt={module.lead_detail.first_name} <h6 className="text-gray-500">LEAD</h6>
/> <div>
</div> <Avatar user={module.lead_detail} />
) : ( </div>
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white"> </div>
{module.lead_detail?.first_name && module.lead_detail.first_name !== "" <div className="space-y-2">
? module.lead_detail.first_name.charAt(0) <h6 className="text-gray-500">MEMBERS</h6>
: module.lead_detail?.email.charAt(0)} <div className="flex items-center gap-1 text-xs">
</div> <AssigneesList users={module.members_detail} />
) </div>
) : ( </div>
"N/A" <div className="space-y-2">
)} <h6 className="text-gray-500">END DATE</h6>
<div className="flex w-min cursor-pointer items-center gap-1 whitespace-nowrap rounded border px-1.5 py-0.5 text-xs shadow-sm">
<CalendarDaysIcon className="h-3 w-3" />
{module.target_date ? renderShortNumericDateFormat(module?.target_date) : "N/A"}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">STATUS</h6>
<div className="flex items-center gap-2 capitalize">
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: MODULE_STATUS.find((s) => s.value === module.status)?.color,
}}
/>
{module.status}
</div>
</div> </div>
</div> </div>
<div className="space-y-2"> </a>
<h6 className="text-gray-500">MEMBERS</h6> </Link>
<div className="flex items-center gap-1 text-xs"> </div>
{module.members && module.members.length > 0 ? ( </>
module?.members_detail?.map((member, index: number) => (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${
index !== 0 ? "-ml-2.5" : ""
}`}
>
{member?.avatar && member.avatar !== "" ? (
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={member.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={member?.first_name}
/>
</div>
) : (
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
{member?.first_name && member.first_name !== ""
? member.first_name.charAt(0)
: member?.email?.charAt(0)}
</div>
)}
</div>
))
) : (
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">END DATE</h6>
<div className="flex w-min cursor-pointer items-center gap-1 whitespace-nowrap rounded border px-1.5 py-0.5 text-xs shadow-sm">
<CalendarDaysIcon className="h-3 w-3" />
{renderShortNumericDateFormat(module.target_date ?? "")}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">STATUS</h6>
<div className="flex items-center gap-2 capitalize">
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: MODULE_STATUS.find((s) => s.value === module.status)?.color,
}}
/>
{module.status}
</div>
</div>
</div>
</a>
</Link>
</div>
); );
}; };

View File

@ -4,8 +4,7 @@ import useToast from "hooks/use-toast";
import workspaceService from "services/workspace.service"; import workspaceService from "services/workspace.service";
import { IUser } from "types"; import { IUser } from "types";
// ui components // ui components
import MultiInput from "components/ui/multi-input"; import { MultiInput, OutlineButton } from "components/ui";
import OutlineButton from "components/ui/outline-button";
type Props = { type Props = {
setStep: React.Dispatch<React.SetStateAction<number>>; setStep: React.Dispatch<React.SetStateAction<number>>;

View File

@ -12,13 +12,14 @@ import {
ClipboardDocumentListIcon, ClipboardDocumentListIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// types // types
import type { IProject } from "types";
// ui // ui
import { Button } from "components/ui"; import { Button } from "components/ui";
// hooks // hooks
import useProjectMembers from "hooks/use-project-members"; import useProjectMembers from "hooks/use-project-members";
// helpers // helpers
import { renderShortNumericDateFormat } from "helpers/date-time.helper"; import { renderShortNumericDateFormat } from "helpers/date-time.helper";
// types
import type { IProject } from "types";
export type ProjectCardProps = { export type ProjectCardProps = {
workspaceSlug: string; workspaceSlug: string;
@ -85,6 +86,14 @@ export const ProjectCard: React.FC<ProjectCardProps> = (props) => {
</div> </div>
<div className="mt-3 flex h-full items-end justify-between"> <div className="mt-3 flex h-full items-end justify-between">
<div className="flex gap-2"> <div className="flex gap-2">
<Button
theme="secondary"
className="flex items-center gap-1"
onClick={() => router.push(`/${workspaceSlug}/projects/${project.id}/issues`)}
>
<ClipboardDocumentListIcon className="h-3 w-3" />
Open Project
</Button>
{!isMember ? ( {!isMember ? (
<button <button
type="button" type="button"
@ -97,19 +106,11 @@ export const ProjectCard: React.FC<ProjectCardProps> = (props) => {
<span>Select to Join</span> <span>Select to Join</span>
</button> </button>
) : ( ) : (
<Button theme="secondary" className="flex items-center gap-1" disabled> <div className="flex items-center gap-1 text-xs">
<CheckIcon className="h-3 w-3" /> <CheckIcon className="h-3 w-3" />
Member Member
</Button> </div>
)} )}
<Button
theme="secondary"
className="flex items-center gap-1"
onClick={() => router.push(`/${workspaceSlug}/projects/${project.id}/issues`)}
>
<ClipboardDocumentListIcon className="h-3 w-3" />
Open Project
</Button>
</div> </div>
<div className="mb-1 flex items-center gap-1 text-xs"> <div className="mb-1 flex items-center gap-1 text-xs">
<CalendarDaysIcon className="h-4 w-4" /> <CalendarDaysIcon className="h-4 w-4" />

View File

@ -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<React.SetStateAction<boolean>>;
projectId: string;
data?: ICycle;
};
const defaultValues: Partial<ICycle> = {
name: "",
description: "",
status: "draft",
start_date: null,
end_date: null,
};
const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, projectId }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
setError,
} = useForm<ICycle>({
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<ICycle[]>(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 (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
{data ? "Update" : "Create"} Cycle
</Dialog.Title>
<div className="space-y-3">
<div>
<Input
id="name"
label="Name"
name="name"
type="name"
placeholder="Enter name"
autoComplete="off"
error={errors.name}
register={register}
validations={{
required: "Name is required",
maxLength: {
value: 255,
message: "Name should be less than 255 characters",
},
}}
/>
</div>
<div>
<TextArea
id="description"
name="description"
label="Description"
placeholder="Enter description"
error={errors.description}
register={register}
/>
</div>
<div>
<h6 className="text-gray-500">Status</h6>
<Controller
name="status"
control={control}
render={({ field }) => (
<CustomSelect
{...field}
label={
<span className="capitalize">{field.value ?? "Select Status"}</span>
}
input
>
{[
{ label: "Draft", value: "draft" },
{ label: "Started", value: "started" },
{ label: "Completed", value: "completed" },
].map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex gap-x-2">
<div className="w-full">
<h6 className="text-gray-500">Start Date</h6>
<div className="w-full">
<Controller
control={control}
name="start_date"
rules={{ required: "Start date is required" }}
render={({ field: { value, onChange } }) => (
<CustomDatePicker
renderAs="input"
value={value}
onChange={onChange}
error={errors.start_date ? true : false}
/>
)}
/>
{errors.start_date && (
<h6 className="text-sm text-red-500">{errors.start_date.message}</h6>
)}
</div>
</div>
<div className="w-full">
<h6 className="text-gray-500">End Date</h6>
<div className="w-full">
<Controller
control={control}
name="end_date"
rules={{ required: "End date is required" }}
render={({ field: { value, onChange } }) => (
<CustomDatePicker
renderAs="input"
value={value}
onChange={onChange}
error={errors.end_date ? true : false}
/>
)}
/>
{errors.end_date && (
<h6 className="text-sm text-red-500">{errors.end_date.message}</h6>
)}
</div>
</div>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{data
? isSubmitting
? "Updating Cycle..."
: "Update Cycle"
: isSubmitting
? "Creating Cycle..."
: "Create Cycle"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default CreateUpdateCycleModal;

View File

@ -1,246 +0,0 @@
import { useEffect } 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";
// react-circular-progressbar
import { CircularProgressbar } from "react-circular-progressbar";
import "react-circular-progressbar/dist/styles.css";
// ui
import { Loader, CustomDatePicker } from "components/ui";
// hooks
import useToast from "hooks/use-toast";
// services
import cyclesService from "services/cycles.service";
// components
import SidebarProgressStats from "components/core/sidebar/sidebar-progress-stats";
// icons
import { CalendarDaysIcon, ChartPieIcon, LinkIcon, UserIcon } from "@heroicons/react/24/outline";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
import { groupBy } from "helpers/array.helper";
// types
import { CycleIssueResponse, ICycle, IIssue } from "types";
// fetch-keys
import { CYCLE_DETAILS } from "constants/fetch-keys";
type Props = {
issues: IIssue[];
cycle: ICycle | undefined;
isOpen: boolean;
cycleIssues: CycleIssueResponse[];
};
const defaultValues: Partial<ICycle> = {
start_date: new Date().toString(),
end_date: new Date().toString(),
};
const CycleDetailSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cycleIssues }) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const { setToastAlert } = useToast();
const { reset, control } = useForm({
defaultValues,
});
const groupedIssues = {
backlog: [],
unstarted: [],
started: [],
cancelled: [],
completed: [],
...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"),
};
const submitChanges = (data: Partial<ICycle>) => {
if (!workspaceSlug || !projectId || !cycleId) return;
mutate<ICycle>(
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]);
return (
<div
className={`fixed top-0 ${
isOpen ? "right-0" : "-right-[24rem]"
} z-20 h-full w-[24rem] overflow-y-auto border-l bg-gray-50 p-5 duration-300`}
>
{cycle ? (
<>
<div className="flex items-center justify-between pb-3">
<h4 className="text-sm font-medium">{cycle.name}</h4>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="rounded-md border p-2 shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`
)
.then(() => {
setToastAlert({
type: "success",
title: "Cycle link copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<LinkIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="divide-y-2 divide-gray-100 text-xs">
<div className="py-1">
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserIcon className="h-4 w-4 flex-shrink-0" />
<p>Owned by</p>
</div>
<div className="sm:basis-1/2 flex items-center gap-1">
{cycle.owned_by &&
(cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<div className="h-5 w-5 rounded-full border-2 border-transparent">
<Image
src={cycle.owned_by.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={cycle.owned_by?.first_name}
/>
</div>
) : (
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
{cycle.owned_by?.first_name && cycle.owned_by.first_name !== ""
? cycle.owned_by.first_name.charAt(0)
: cycle.owned_by?.email.charAt(0)}
</div>
))}
{cycle.owned_by.first_name !== ""
? cycle.owned_by.first_name
: cycle.owned_by.email}
</div>
</div>
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<ChartPieIcon className="h-4 w-4 flex-shrink-0" />
<p>Progress</p>
</div>
<div className="flex items-center gap-2 sm:basis-1/2">
<div className="grid flex-shrink-0 place-items-center">
<span className="h-4 w-4">
<CircularProgressbar
value={groupedIssues.completed.length}
maxValue={cycleIssues?.length}
strokeWidth={10}
/>
</span>
</div>
{groupedIssues.completed.length}/{cycleIssues?.length}
</div>
</div>
</div>
<div className="py-1">
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0" />
<p>Start date</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="start_date"
render={({ field: { value } }) => (
<CustomDatePicker
value={value}
onChange={(val) =>
submitChanges({
start_date: val,
})
}
isClearable={false}
/>
)}
/>
</div>
</div>
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0" />
<p>End date</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="end_date"
render={({ field: { value } }) => (
<CustomDatePicker
value={value}
onChange={(val) =>
submitChanges({
end_date: val,
})
}
isClearable={false}
/>
)}
/>
</div>
</div>
</div>
<div className="py-1" />
</div>
<div className="w-full">
<SidebarProgressStats issues={issues} groupedIssues={groupedIssues} />
</div>
</>
) : (
<Loader>
<div className="space-y-2">
<Loader.Item height="15px" width="50%" />
<Loader.Item height="15px" width="30%" />
</div>
<div className="mt-8 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
)}
</div>
);
};
export default CycleDetailSidebar;

View File

@ -72,7 +72,7 @@ const SingleLabel: React.FC<Props> = ({ label, issueLabels, editLabel, handleLab
<span <span
className="h-4 w-4 rounded" className="h-4 w-4 rounded"
style={{ style={{
backgroundColor: watch("color") ?? "green", backgroundColor: watch("color") ?? "black",
}} }}
/> />
)} )}

View File

@ -1,27 +0,0 @@
import { useCommands, useActive } from "@remirror/react";
export const BoldButton = () => {
const { toggleBold, focus } = useCommands();
const active = useActive();
return (
<button
type="button"
onClick={() => {
toggleBold();
focus();
}}
className={`${active.bold() ? "bg-gray-200" : "hover:bg-gray-100"} rounded p-1`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
height="18"
width="18"
viewBox="0 0 48 48"
fill="black"
>
<path d="M14 36V8h11.4q3.3 0 5.725 2.1t2.425 5.3q0 1.9-1.05 3.5t-2.8 2.45v.3q2.15.7 3.475 2.5 1.325 1.8 1.325 4.05 0 3.4-2.625 5.6Q29.25 36 25.75 36Zm4.3-16.15h6.8q1.75 0 3.025-1.15t1.275-2.9q0-1.75-1.275-2.925Q26.85 11.7 25.1 11.7h-6.8Zm0 12.35h7.2q1.9 0 3.3-1.25t1.4-3.15q0-1.85-1.4-3.1t-3.3-1.25h-7.2Z" />
</svg>
</button>
);
};

View File

@ -1,11 +1,12 @@
// history // buttons
import { RedoButton } from "./redo"; import {
import { UndoButton } from "./undo"; ToggleBoldButton,
// formats ToggleItalicButton,
import { BoldButton } from "./bold"; ToggleUnderlineButton,
import { ItalicButton } from "./italic"; ToggleStrikeButton,
import { UnderlineButton } from "./underline"; RedoButton,
import { StrikeButton } from "./strike"; UndoButton,
} from "@remirror/react";
// headings // headings
import HeadingControls from "./heading-controls"; import HeadingControls from "./heading-controls";
// list // list
@ -15,17 +16,17 @@ import { UnorderedListButton } from "./unordered-list";
export const RichTextToolbar: React.FC = () => ( export const RichTextToolbar: React.FC = () => (
<div className="flex items-center gap-y-2 divide-x"> <div className="flex items-center gap-y-2 divide-x">
<div className="flex items-center gap-x-1 px-2"> <div className="flex items-center gap-x-1 px-2">
<UndoButton />
<RedoButton /> <RedoButton />
<UndoButton />
</div> </div>
<div className="px-2"> <div className="px-2">
<HeadingControls /> <HeadingControls />
</div> </div>
<div className="flex items-center gap-x-1 px-2"> <div className="flex items-center gap-x-1 px-2">
<BoldButton /> <ToggleBoldButton />
<ItalicButton /> <ToggleItalicButton />
<UnderlineButton /> <ToggleUnderlineButton />
<StrikeButton /> <ToggleStrikeButton />
</div> </div>
<div className="flex items-center gap-x-1 px-2"> <div className="flex items-center gap-x-1 px-2">
<OrderedListButton /> <OrderedListButton />

View File

@ -1,28 +0,0 @@
import { useCommands, useActive } from "@remirror/react";
export const ItalicButton = () => {
const { toggleItalic, focus } = useCommands();
const active = useActive();
return (
<button
type="button"
onClick={() => {
toggleItalic();
focus();
}}
className={`${active.italic() ? "bg-gray-200" : "hover:bg-gray-100"} rounded p-1`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
height="18"
width="18"
viewBox="0 0 48 48"
fill="black"
>
<path d="M10 40v-5h6.85l8.9-22H18V8h20v5h-6.85l-8.9 22H30v5Z" />
</svg>
</button>
);
};

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