forked from github/plane
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:
commit
1b94c7b640
@ -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
7
.gitignore
vendored
@ -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
116
Dockerfile
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
FROM node:18-alpine AS builder
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
RUN apk update
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN yarn global add turbo
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN turbo prune --scope=app --docker
|
||||||
|
|
||||||
|
# Add lockfile and package.json's of isolated subworkspace
|
||||||
|
FROM node:18-alpine AS installer
|
||||||
|
|
||||||
|
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
RUN apk update
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# First install the dependencies (as they change less often)
|
||||||
|
COPY .gitignore .gitignore
|
||||||
|
COPY --from=builder /app/out/json/ .
|
||||||
|
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||||
|
RUN yarn install
|
||||||
|
|
||||||
|
# Build the project
|
||||||
|
COPY --from=builder /app/out/full/ .
|
||||||
|
COPY turbo.json turbo.json
|
||||||
|
|
||||||
|
RUN yarn turbo run build --filter=app
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.11.1-alpine3.17 AS backend
|
||||||
|
|
||||||
|
# set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE 1
|
||||||
|
ENV PYTHONUNBUFFERED 1
|
||||||
|
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
WORKDIR /code
|
||||||
|
|
||||||
|
RUN apk --update --no-cache add \
|
||||||
|
"libpq~=15" \
|
||||||
|
"libxslt~=1.1" \
|
||||||
|
"nodejs-current~=19" \
|
||||||
|
"xmlsec~=1.2" \
|
||||||
|
"nginx" \
|
||||||
|
"nodejs" \
|
||||||
|
"npm" \
|
||||||
|
"supervisor"
|
||||||
|
|
||||||
|
COPY apiserver/requirements.txt ./
|
||||||
|
COPY apiserver/requirements ./requirements
|
||||||
|
RUN apk add libffi-dev
|
||||||
|
RUN apk --update --no-cache --virtual .build-deps add \
|
||||||
|
"bash~=5.2" \
|
||||||
|
"g++~=12.2" \
|
||||||
|
"gcc~=12.2" \
|
||||||
|
"cargo~=1.64" \
|
||||||
|
"git~=2" \
|
||||||
|
"make~=4.3" \
|
||||||
|
"postgresql13-dev~=13" \
|
||||||
|
"libc-dev" \
|
||||||
|
"linux-headers" \
|
||||||
|
&& \
|
||||||
|
pip install -r requirements.txt --compile --no-cache-dir \
|
||||||
|
&& \
|
||||||
|
apk del .build-deps
|
||||||
|
|
||||||
|
# Add in Django deps and generate Django's static files
|
||||||
|
COPY apiserver/manage.py manage.py
|
||||||
|
COPY apiserver/plane plane/
|
||||||
|
COPY apiserver/templates templates/
|
||||||
|
|
||||||
|
COPY apiserver/gunicorn.config.py ./
|
||||||
|
RUN apk --update --no-cache add "bash~=5.2"
|
||||||
|
COPY apiserver/bin ./bin/
|
||||||
|
|
||||||
|
RUN chmod +x ./bin/takeoff ./bin/worker
|
||||||
|
RUN chmod -R 777 /code
|
||||||
|
|
||||||
|
# Expose container port and run entry point script
|
||||||
|
EXPOSE 8000
|
||||||
|
EXPOSE 3000
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Don't run production as root
|
||||||
|
RUN addgroup --system --gid 1001 plane
|
||||||
|
RUN adduser --system --uid 1001 captain
|
||||||
|
|
||||||
|
COPY --from=installer /app/apps/app/next.config.js .
|
||||||
|
COPY --from=installer /app/apps/app/package.json .
|
||||||
|
|
||||||
|
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
|
||||||
|
|
||||||
|
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
#######################################################################
|
||||||
|
COPY nginx/nginx-single-docker-image.conf /etc/nginx/http.d/default.conf
|
||||||
|
#######################################################################
|
||||||
|
|
||||||
|
COPY nginx/supervisor.conf /code/supervisor.conf
|
||||||
|
|
||||||
|
|
||||||
|
CMD ["supervisord","-c","/code/supervisor.conf"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
|||||||
# Backend
|
|
||||||
SECRET_KEY="<-- django secret -->"
|
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
|
||||||
|
@ -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" ]
|
||||||
|
|
||||||
|
@ -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 -
|
||||||
|
28
apiserver/bin/user_script.py
Normal file
28
apiserver/bin/user_script.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import os, sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
sys.path.append("/code")
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
|
||||||
|
import django
|
||||||
|
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from plane.db.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def populate():
|
||||||
|
default_email = os.environ.get("DEFAULT_EMAIL", "captain@plane.so")
|
||||||
|
default_password = os.environ.get("DEFAULT_PASSWORD", "password123")
|
||||||
|
|
||||||
|
if not User.objects.filter(email=default_email).exists():
|
||||||
|
user = User.objects.create(email=default_email, username=uuid.uuid4().hex)
|
||||||
|
user.set_password(default_password)
|
||||||
|
user.save()
|
||||||
|
print("User created")
|
||||||
|
|
||||||
|
print("Success")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
populate()
|
@ -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:
|
||||||
|
@ -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(
|
||||||
|
@ -64,7 +64,6 @@ from .auth_extended import (
|
|||||||
|
|
||||||
|
|
||||||
from .authentication import (
|
from .authentication import (
|
||||||
SignUpEndpoint,
|
|
||||||
SignInEndpoint,
|
SignInEndpoint,
|
||||||
SignOutEndpoint,
|
SignOutEndpoint,
|
||||||
MagicSignInEndpoint,
|
MagicSignInEndpoint,
|
||||||
|
@ -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(
|
||||||
|
@ -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(
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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)
|
||||||
|
69
apiserver/plane/db/migrations/0020_auto_20230214_0118.py
Normal file
69
apiserver/plane/db/migrations/0020_auto_20230214_0118.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -23,6 +23,7 @@ from .issue import (
|
|||||||
IssueAssignee,
|
IssueAssignee,
|
||||||
Label,
|
Label,
|
||||||
IssueBlocker,
|
IssueBlocker,
|
||||||
|
IssueLink,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .asset import FileAsset
|
from .asset import FileAsset
|
||||||
|
@ -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"
|
||||||
|
@ -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"""
|
||||||
|
@ -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
|
||||||
|
@ -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")
|
||||||
|
@ -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")
|
||||||
|
@ -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
|
@ -1,3 +1,3 @@
|
|||||||
-r base.txt
|
-r base.txt
|
||||||
|
|
||||||
django-debug-toolbar==3.2.4
|
django-debug-toolbar==3.8.1
|
@ -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
|
@ -1 +1 @@
|
|||||||
python-3.11.1
|
python-3.11.2
|
@ -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
6
apps/app/.env.example
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
NEXT_PUBLIC_API_BASE_URL = "localhost/"
|
||||||
|
NEXT_PUBLIC_GOOGLE_CLIENTID="<-- google client id -->"
|
||||||
|
NEXT_PUBLIC_GITHUB_ID="<-- github client id -->"
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN="<-- sentry dns -->"
|
||||||
|
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||||
|
NEXT_PUBLIC_ENABLE_SENTRY=0
|
@ -1 +1,4 @@
|
|||||||
module.exports = require("config/.eslintrc");
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
extends: ["custom"],
|
||||||
|
};
|
||||||
|
12
apps/app/Dockerfile.dev
Normal file
12
apps/app/Dockerfile.dev
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
RUN apk update
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN yarn global add turbo
|
||||||
|
RUN yarn install
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["yarn","dev"]
|
@ -4,33 +4,14 @@ RUN apk update
|
|||||||
# Set working directory
|
# 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
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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;
|
|
2
apps/app/components/command-palette/index.ts
Normal file
2
apps/app/components/command-palette/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./command-pallette";
|
||||||
|
export * from "./shortcuts-modal";
|
@ -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;
|
|
@ -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>
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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();
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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";
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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 = () => {
|
@ -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>
|
||||||
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
3
apps/app/components/core/sidebar/index.ts
Normal file
3
apps/app/components/core/sidebar/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./links-list";
|
||||||
|
export * from "./sidebar-progress-stats";
|
||||||
|
export * from "./single-progress-stats";
|
58
apps/app/components/core/sidebar/links-list.tsx
Normal file
58
apps/app/components/core/sidebar/links-list.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
// icons
|
||||||
|
import { LinkIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||||
|
// helpers
|
||||||
|
import { timeAgo } from "helpers/date-time.helper";
|
||||||
|
// types
|
||||||
|
import { IUserLite, UserAuth } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
links: {
|
||||||
|
id: string;
|
||||||
|
created_at: Date;
|
||||||
|
created_by: string;
|
||||||
|
created_by_detail: IUserLite;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
|
handleDeleteLink: (linkId: string) => void;
|
||||||
|
userAuth: UserAuth;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }) => {
|
||||||
|
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{links.map((link) => (
|
||||||
|
<div key={link.id} className="group relative">
|
||||||
|
{!isNotAllowed && (
|
||||||
|
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="grid h-7 w-7 place-items-center rounded bg-gray-100 p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
|
||||||
|
onClick={() => handleDeleteLink(link.id)}
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Link href={link.url} target="_blank">
|
||||||
|
<a className="group relative flex gap-2 rounded-md border bg-gray-100 p-2">
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<LinkIcon className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5>{link.title}</h5>
|
||||||
|
{/* <p className="mt-0.5 text-gray-500">
|
||||||
|
Added {timeAgo(link.created_at)} ago by {link.created_by_detail.email}
|
||||||
|
</p> */}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
97
apps/app/components/core/sidebar/progress-chart.tsx
Normal file
97
apps/app/components/core/sidebar/progress-chart.tsx
Normal 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;
|
@ -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;
|
|
||||||
|
@ -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;
|
|
||||||
|
@ -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;
|
|
@ -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;
|
|
@ -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"
|
||||||
|
@ -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";
|
||||||
|
@ -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>
|
||||||
|
@ -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 }) => (
|
||||||
<>
|
<>
|
||||||
|
357
apps/app/components/cycles/sidebar.tsx
Normal file
357
apps/app/components/cycles/sidebar.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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;
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -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) => {
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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}
|
||||||
|
@ -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";
|
|
||||||
|
@ -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}
|
||||||
|
@ -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}
|
||||||
</>
|
</>
|
||||||
|
@ -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>
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -1,205 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
import useSWR, { mutate } from "swr";
|
|
||||||
|
|
||||||
// headless ui
|
|
||||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
|
||||||
// icons
|
|
||||||
import { RectangleStackIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
|
||||||
// services
|
|
||||||
import issuesServices from "services/issues.service";
|
|
||||||
// helpers
|
|
||||||
import { orderArrayBy } from "helpers/array.helper";
|
|
||||||
// types
|
|
||||||
import { IIssue, IssueResponse } from "types";
|
|
||||||
// constants
|
|
||||||
import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isOpen: boolean;
|
|
||||||
handleClose: () => void;
|
|
||||||
parent: IIssue | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SubIssuesListModal: React.FC<Props> = ({ isOpen, handleClose, parent }) => {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug, projectId } = router.query;
|
|
||||||
|
|
||||||
const { data: issues } = useSWR(
|
|
||||||
workspaceSlug && projectId
|
|
||||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
|
||||||
: null,
|
|
||||||
workspaceSlug && projectId
|
|
||||||
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredIssues: IIssue[] =
|
|
||||||
query === ""
|
|
||||||
? issues?.results ?? []
|
|
||||||
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
|
|
||||||
[];
|
|
||||||
|
|
||||||
const handleModalClose = () => {
|
|
||||||
handleClose();
|
|
||||||
setQuery("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const addAsSubIssue = (issue: IIssue) => {
|
|
||||||
if (!workspaceSlug || !projectId) return;
|
|
||||||
|
|
||||||
mutate<IIssue[]>(
|
|
||||||
SUB_ISSUES(parent?.id ?? ""),
|
|
||||||
(prevData) => {
|
|
||||||
let newSubIssues = [...(prevData as IIssue[])];
|
|
||||||
newSubIssues.push(issue);
|
|
||||||
|
|
||||||
newSubIssues = orderArrayBy(newSubIssues, "created_at", "descending");
|
|
||||||
|
|
||||||
return newSubIssues;
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
issuesServices
|
|
||||||
.patchIssue(workspaceSlug as string, projectId as string, issue.id, { parent: parent?.id })
|
|
||||||
.then((res) => {
|
|
||||||
mutate(SUB_ISSUES(parent?.id ?? ""));
|
|
||||||
mutate<IssueResponse>(
|
|
||||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
|
||||||
(prevData) => ({
|
|
||||||
...(prevData as IssueResponse),
|
|
||||||
results: (prevData?.results ?? []).map((p) => {
|
|
||||||
if (p.id === res.id)
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
...res,
|
|
||||||
};
|
|
||||||
|
|
||||||
return p;
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.log(e);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
|
||||||
<Dialog as="div" className="relative z-20" onClose={handleModalClose}>
|
|
||||||
<Transition.Child
|
|
||||||
as={React.Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
|
|
||||||
<Transition.Child
|
|
||||||
as={React.Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
|
|
||||||
<Combobox>
|
|
||||||
<div className="relative m-1">
|
|
||||||
<MagnifyingGlassIcon
|
|
||||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<Combobox.Input
|
|
||||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
|
||||||
placeholder="Search..."
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Combobox.Options
|
|
||||||
static
|
|
||||||
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
|
|
||||||
>
|
|
||||||
{filteredIssues.length > 0 && (
|
|
||||||
<>
|
|
||||||
<li className="p-2">
|
|
||||||
{query === "" && (
|
|
||||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
|
||||||
Issues
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
<ul className="text-sm text-gray-700">
|
|
||||||
{filteredIssues.map((issue) => {
|
|
||||||
if (
|
|
||||||
(issue.parent === "" || issue.parent === null) && // issue does not have any other parent
|
|
||||||
issue.id !== parent?.id && // issue is not itself
|
|
||||||
issue.id !== parent?.parent // issue is not it's parent
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<Combobox.Option
|
|
||||||
key={issue.id}
|
|
||||||
value={{
|
|
||||||
name: issue.name,
|
|
||||||
}}
|
|
||||||
className={({ active }) =>
|
|
||||||
`flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 ${
|
|
||||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
addAsSubIssue(issue);
|
|
||||||
handleClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="block flex-shrink-0 h-1.5 w-1.5 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: issue.state_detail.color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
|
||||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
|
||||||
</span>
|
|
||||||
{issue.name}
|
|
||||||
</Combobox.Option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Combobox.Options>
|
|
||||||
|
|
||||||
{query !== "" && filteredIssues.length === 0 && (
|
|
||||||
<div className="py-14 px-6 text-center sm:px-14">
|
|
||||||
<RectangleStackIcon
|
|
||||||
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<p className="mt-4 text-sm text-gray-900">
|
|
||||||
We couldn{"'"}t find any issue with that term. Please try again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Combobox>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition.Root>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,49 +1,146 @@
|
|||||||
import { FC, useState } from "react";
|
import { 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>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
|
||||||
);
|
);
|
||||||
|
@ -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 ?? {});
|
||||||
|
|
||||||
|
189
apps/app/components/labels/create-label-modal.tsx
Normal file
189
apps/app/components/labels/create-label-modal.tsx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import { mutate } from "swr";
|
||||||
|
|
||||||
|
// react-hook-form
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
// react-color
|
||||||
|
import { TwitterPicker } from "react-color";
|
||||||
|
// headless ui
|
||||||
|
import { Dialog, Popover, Transition } from "@headlessui/react";
|
||||||
|
// services
|
||||||
|
import issuesService from "services/issues.service";
|
||||||
|
// ui
|
||||||
|
import { Button, Input } from "components/ui";
|
||||||
|
// icons
|
||||||
|
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||||
|
// types
|
||||||
|
import type { IIssueLabels, IState } from "types";
|
||||||
|
// constants
|
||||||
|
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||||
|
|
||||||
|
// types
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
projectId: string;
|
||||||
|
handleClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultValues: Partial<IState> = {
|
||||||
|
name: "",
|
||||||
|
color: "#000000",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreateLabelModal: React.FC<Props> = ({ isOpen, projectId, handleClose }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
setError,
|
||||||
|
} = useForm<IIssueLabels>({
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
handleClose();
|
||||||
|
reset(defaultValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (formData: IIssueLabels) => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
|
||||||
|
await issuesService
|
||||||
|
.createIssueLabel(workspaceSlug as string, projectId as string, formData)
|
||||||
|
.then((res) => {
|
||||||
|
mutate<IIssueLabels[]>(
|
||||||
|
PROJECT_ISSUE_LABELS(projectId),
|
||||||
|
(prevData) => [res, ...(prevData ?? [])],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log(error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-30" onClose={onClose}>
|
||||||
|
<Transition.Child
|
||||||
|
as={React.Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||||
|
<Transition.Child
|
||||||
|
as={React.Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="relative transform rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div>
|
||||||
|
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||||
|
Create Label
|
||||||
|
</Dialog.Title>
|
||||||
|
<div className="mt-8 flex items-center gap-2">
|
||||||
|
<Popover className="relative">
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
<Popover.Button
|
||||||
|
className={`group inline-flex items-center rounded-sm bg-white text-base font-medium hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
|
||||||
|
open ? "text-gray-900" : "text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Color</span>
|
||||||
|
{watch("color") && watch("color") !== "" && (
|
||||||
|
<span
|
||||||
|
className="ml-2 h-4 w-4 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: watch("color") ?? "black",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={`ml-2 h-5 w-5 group-hover:text-gray-500 ${
|
||||||
|
open ? "text-gray-600" : "text-gray-400"
|
||||||
|
}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Popover.Button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
as={React.Fragment}
|
||||||
|
enter="transition ease-out duration-200"
|
||||||
|
enterFrom="opacity-0 translate-y-1"
|
||||||
|
enterTo="opacity-100 translate-y-0"
|
||||||
|
leave="transition ease-in duration-150"
|
||||||
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
|
leaveTo="opacity-0 translate-y-1"
|
||||||
|
>
|
||||||
|
<Popover.Panel className="fixed left-5 z-50 mt-3 w-screen max-w-xs transform px-2 sm:px-0">
|
||||||
|
<Controller
|
||||||
|
name="color"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<TwitterPicker
|
||||||
|
color={value}
|
||||||
|
onChange={(value) => onChange(value.hex)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Popover.Panel>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
placeholder="Enter name"
|
||||||
|
autoComplete="off"
|
||||||
|
error={errors.name}
|
||||||
|
register={register}
|
||||||
|
width="full"
|
||||||
|
validations={{
|
||||||
|
required: "Name is required",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<Button theme="secondary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Creating Label..." : "Create Label"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
};
|
192
apps/app/components/labels/create-update-label-inline.tsx
Normal file
192
apps/app/components/labels/create-update-label-inline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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";
|
||||||
|
136
apps/app/components/labels/single-label-group.tsx
Normal file
136
apps/app/components/labels/single-label-group.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
@ -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
|
||||||
|
@ -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">
|
||||||
|
@ -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";
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>>;
|
||||||
|
@ -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" />
|
||||||
|
@ -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;
|
|
@ -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;
|
|
@ -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",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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 />
|
||||||
|
@ -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
Loading…
Reference in New Issue
Block a user