dev: promote to production v0.3 (#337)

* chore: update all backend dependencies to the latest version

* feat: manual ordering for issues in kanban

* refactor: issues folder structure

* refactor: modules and states folder structure

* refactor: datepicker code

* fix: create issue modal bug

* feat: custom progress bar added

* refactor: created global component for kanban board

* refactor: update cycle and module issue create

* refactor: return modules created

* refactor: integrated global kanban view everywhere

* refactor: integrated global list view everywhere

* refactor: removed unnecessary api calls

* refactor: update nomenclature for consistency

* refactor: global select component for issue view

* refactor: track cycles and modules for issue

* fix: tracking new cycles and modules in activities

* feat: segregate api token workspace

* fix: workpsace id during token creation

* refactor: update model association to cascade on delete

* feat: sentry integrated (#235)

* feat: sentry integrated

* fix: removed unnecessary env variable

* fix: update remirror description to save empty string and empty paragraph (#237)

* Update README.md

* fix: description and comment_json default value to remove warnings

* feat: link option in remirror (#240)

* feat: link option in remirror

* fix: removed link import from remirror toolbar

* feat: module and cycle settings under project

* fix:  module issue assignment

* fix: module issue updation and activity logging

* fix: typo while creating module issues

* fix: string comparison for update operation

* fix: ui fixes (#246)

* style: shortcut command label bg color change

* sidebar shortcut ui fix

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia1001@gmail.com>

* fix: update empty passwords to hashed string and add hashing for magic sign in

* refactor: remove print logs from back migrations

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>

* feat: cycles and modules toggle in settings, refactor: folder structure (#247)

* feat: link option in remirror

* fix: removed link import from remirror toolbar

* refactor: constants folder

* refactor: layouts folder structure

* fix: issue view context

* feat: cycles and modules toggle in settings

* release: Stage Release (#251)

* feat: manual ordering for issues in kanban

* refactor: issues folder structure

* refactor: modules and states folder structure

* refactor: datepicker code

* fix: create issue modal bug

* feat: custom progress bar added

* refactor: created global component for kanban board

* refactor: update cycle and module issue create

* refactor: return modules created

* refactor: integrated global kanban view everywhere

* refactor: integrated global list view everywhere

* refactor: removed unnecessary api calls

* refactor: update nomenclature for consistency

* refactor: global select component for issue view

* refactor: track cycles and modules for issue

* fix: tracking new cycles and modules in activities

* feat: segregate api token workspace

* fix: workpsace id during token creation

* refactor: update model association to cascade on delete

* feat: sentry integrated (#235)

* feat: sentry integrated

* fix: removed unnecessary env variable

* fix: update remirror description to save empty string and empty paragraph (#237)

* Update README.md

* fix: description and comment_json default value to remove warnings

* feat: link option in remirror (#240)

* feat: link option in remirror

* fix: removed link import from remirror toolbar

* feat: module and cycle settings under project

* fix:  module issue assignment

* fix: module issue updation and activity logging

* fix: typo while creating module issues

* fix: string comparison for update operation

* fix: ui fixes (#246)

* style: shortcut command label bg color change

* sidebar shortcut ui fix

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia1001@gmail.com>

* fix: update empty passwords to hashed string and add hashing for magic sign in

* refactor: remove print logs from back migrations

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>

* feat: cycles and modules toggle in settings, refactor: folder structure (#247)

* feat: link option in remirror

* fix: removed link import from remirror toolbar

* refactor: constants folder

* refactor: layouts folder structure

* fix: issue view context

* feat: cycles and modules toggle in settings

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia1001@gmail.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: sphynxux <122926002+sphynxux@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: sidebar progress (#252)

* feat: cycle assignees and labels progress added

* fix: build fix

* feat: sidebar progress stats added and refactor

* refactor: progress stats and cycle sidebar

* feat: module sidebar progress added

* feat: sidebar progress no assignee added

* feat: states tab added

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia1001@gmail.com>

* feat: label grouping, fix: new states response (#254)

* sentry changes (#255)

* fix: mutation of states (#256)

* feat: label grouping, fix: new states response

* fix: mutation of states

* fix: create issue modal bugs (#257)

* fix: github auth login (#250)

* fix: added PROJECT_ISSUES_LIST on the imports (#221)

* fix: github signin by parsing email

* refactor: changed variable names

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: Vamsi Kurama <vamsi.kurama@gmail.com>

* 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

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia1001@gmail.com>

* 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: assign multiple sub issues

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

* fix: error validation for empty length

* 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: state ordering in group

* 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)

* feat: updated issue grouping and filtering

* feat: back migration script to populate random sort_order values

* feat: sort order during create

* feat: improved grouper with grouping function

* fix: typo in model aggregation key

* 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

* feat: created_by details for links (#313)

* env fixes (#316)

* feat: issues tooltip , fix: ui improvement (#317)

* fix: ellipsis added to issue title

* feat: toolttip added

* feat: assignees tooltip added

* fix: build fix

* fix : tooltip fix (#318)

* fix: ellipsis added to issue title

* feat: toolttip added

* feat: assignees tooltip added

* fix: build fix

* fix: build fix

* fix: build error

---------

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

* fix: redirection after login (#320)

* feat: github integration (#315)

* feat: initiate integrations

* feat: initiate github integration create models for the same

* feat: github integration views

* fix: update workspace integration view to create bot users

* refactor: rename repository model

* refactor: update github repo sync endpoint to create repo and sync in one go

* refactor: update issue activities to post the updates to segway hook

* refactor: update endpoints to get project id and add actor as a member of project in repo sync

* fix: make is bot as a read only field

* fix: remove github repo imports

* fix: url mapping

* feat: repo views

* refactor: update webhook request endpoint

* refactor: rename repositories table to github_repositories

* fix: workpace integration actor

* feat: label for github integration

* refactor: issue activity on create issue

* refactor: repo create endpoint and add db constraints for repo sync and issues

* feat: create api token on workpsace integration and avatar_url for integrations

* refactor: add uuid primary key for Audit model

* refactor: remove id from auditfield to maintain integrity and make avatar blank if none supplied

* feat: track comments on an issue

* feat: comment syncing from plane to github

* fix: prevent activities created by bot to be sent to webhook

* feat: github app installation id retrieve

* feat: github app installation id saved into db

* feat: installation_id for the github integragation and unique provider and project base integration for repo

* refactor: remove actor logic from activity task

* feat: saving github metadata using installation id in workspace integration table

* feat: github repositories endpoint

* feat: github and project repos synchronisation

* feat: delete issue and delete comment activity

* refactor: remove print logs

* FIX: reading env names for github app while installation

* refactor: update bot user firstname with title

* fix: add is_bot value in field

---------

Co-authored-by: venplane <venkatesh@plane.so>

* feat: assignee and label details in cycle and module issues (#319)

* dev: added new migrations

* fix: minor bugs and ux improvements (#322)

* fix: ellipsis added to issue title

* feat: toolttip added

* feat: assignees tooltip added

* fix: build fix

* fix: build fix

* fix: build error

* fix: minor bugs and ux improvements

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@caravel.tech>

* feat: made new multi-level select listbox (#326)

* fix: ui improvements  (#327)

* fix: kanban board header scroll fix

* style: enable scrollbar style added

* fix: emoji picker overflow

* fix: delete project modal text overflow

* fix: cycle card ellipsis

* fix: tooltip position updated and custom class added

* fix: assignees tooltip overflow

* fix: module card

* fix: my issue page  tooltip and responsive title  added

* fix: home page tooltip and responsiveness

* style: added direction for multi-level drop-down (#328)

* feat: made new multi-level select listbox

* refractor: changeds Multi-level-select component and added direction props

* style: added direction for multi-level drop-down

* refractor: added proper types to getServerSideProps context (#321)

* fix: redirection after login

* refractor: added proper types to getServerSideProps context

* fix: issue view not updating order_by value (#324)

* environmental example variables fixes (#330)

* style: github integration ui (#329)

* fix: ellipsis added to issue title

* feat: toolttip added

* feat: assignees tooltip added

* fix: build fix

* fix: build fix

* fix: build error

* fix: minor bugs and ux improvements

* style: github integration ui

* chore: updated .env.example file

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@caravel.tech>

* fix: ui fix (#331)

* fix: project card id removed

* feat: my issue page copy issue option

* fix: kanban assignees tooltip (#332)

* feat: sidebar select option truncate (#334)

* fix: create issue modal close on escape click (#333)

* style: kanban dropdowns, github integration loaders

* fix: add filter for workspace integrations (#325)

* fix: add filter for workspace integrations

* fix: update url for delete

* fix: remove github installation when deleted

* fix: delete old repos

* fix: add filter on repository endpoints

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia1001@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: sphynxux <122926002+sphynxux@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: venplane <118932524+venplane@users.noreply.github.com>
Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com>
Co-authored-by: Narayana <narayana.vadapalli1996@gmail.com>
Co-authored-by: venplane <venkatesh@plane.so>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@caravel.tech>
This commit is contained in:
Vamsi Kurama 2023-02-24 00:10:11 +05:30 committed by GitHub
parent 8118974a37
commit 68dfef51f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
340 changed files with 21992 additions and 19815 deletions

View File

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

8
.gitignore vendored
View File

@ -62,3 +62,11 @@ yarn-error.log
*.sln
package-lock.json
.vscode
# Sentry
.sentryclirc
# lock files
package-lock.json
pnpm-lock.yaml
pnpm-workspace.yaml

116
Dockerfile Normal file
View File

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

View File

@ -7,7 +7,7 @@
</p>
<p align="center">
<a href="https://discord.com/invite/29tPNhaV">
<a href="https://discord.com/invite/A92xrEGCge">
<img alt="Discord" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
</a>
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
@ -48,4 +48,4 @@ Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CON
## Security
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email security@plane.so to disclose any security vulnerabilities.
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email security@plane.so to disclose any security vulnerabilities.

View File

@ -1,18 +1,22 @@
# Backend
SECRET_KEY="<-- django secret -->"
DJANGO_SETTINGS_MODULE="plane.settings.production"
# Database
DATABASE_URL=postgres://plane:plane@plane-db-1:5432/plane
# Cache
REDIS_URL=redis://redis:6379/
# SMPT
EMAIL_HOST="<-- email smtp -->"
EMAIL_HOST_USER="<-- email host user -->"
EMAIL_HOST_PASSWORD="<-- email host password -->"
# AWS
AWS_REGION="<-- aws region -->"
AWS_ACCESS_KEY_ID="<-- aws access key -->"
AWS_SECRET_ACCESS_KEY="<-- aws secret acess key -->"
AWS_S3_BUCKET_NAME="<-- aws s3 bucket name -->"
SENTRY_DSN="<-- sentry dsn -->"
WEB_URL="<-- frontend web url -->"
# FE
WEB_URL="localhost/"
# OAUTH
GITHUB_CLIENT_SECRET="<-- github secret -->"
# Flags
DISABLE_COLLECTSTATIC=1
DOCKERIZED=0 //True if running docker compose else 0
DOCKERIZED=1

View File

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

View File

@ -1,11 +1,14 @@
# All the python scripts that are used for back migrations
import uuid
import random
from django.contrib.auth.hashers import make_password
from plane.db.models import ProjectIdentifier
from plane.db.models import Issue, IssueComment
from plane.db.models import Issue, IssueComment, User
# Update description and description html values for old descriptions
def update_description():
try:
issues = Issue.objects.all()
updated_issues = []
@ -25,7 +28,6 @@ def update_description():
def update_comments():
try:
issue_comments = IssueComment.objects.all()
updated_issue_comments = []
@ -44,9 +46,11 @@ def update_comments():
def update_project_identifiers():
try:
project_identifiers = ProjectIdentifier.objects.filter(workspace_id=None).select_related("project", "project__workspace")
project_identifiers = ProjectIdentifier.objects.filter(
workspace_id=None
).select_related("project", "project__workspace")
updated_identifiers = []
for identifier in project_identifiers:
identifier.workspace_id = identifier.project.workspace_id
updated_identifiers.append(identifier)
@ -58,3 +62,37 @@ def update_project_identifiers():
except Exception as e:
print(e)
print("Failed")
def update_user_empty_password():
try:
users = User.objects.filter(password="")
updated_users = []
for user in users:
user.password = make_password(uuid.uuid4().hex)
user.is_password_autoset = True
updated_users.append(user)
User.objects.bulk_update(updated_users, ["password"], batch_size=50)
print("Success")
except Exception as e:
print(e)
print("Failed")
def updated_issue_sort_order():
try:
issues = Issue.objects.all()
updated_issues = []
for issue in issues:
issue.sort_order = issue.sequence_id * random.randint(100, 500)
updated_issues.append(issue)
Issue.objects.bulk_update(updated_issues, ["sort_order"], batch_size=100)
print("Success")
except Exception as e:
print(e)
print("Failed")

View File

@ -2,4 +2,8 @@
set -e
python manage.py wait_for_db
python manage.py migrate
# Create a Default User
python bin/user_script.py
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 1200 --max-requests-jitter 1000 --access-logfile -

View File

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

View File

@ -40,4 +40,13 @@ from .issue import (
from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer
from .api_token import APITokenSerializer
from .api_token import APITokenSerializer
from .integration import (
IntegrationSerializer,
WorkspaceIntegrationSerializer,
GithubIssueSyncSerializer,
GithubRepositorySerializer,
GithubRepositorySyncSerializer,
GithubCommentSyncSerializer,
)

View File

@ -0,0 +1,7 @@
from .base import IntegrationSerializer, WorkspaceIntegrationSerializer
from .github import (
GithubRepositorySerializer,
GithubRepositorySyncSerializer,
GithubIssueSyncSerializer,
GithubCommentSyncSerializer,
)

View File

@ -0,0 +1,20 @@
# Module imports
from plane.api.serializers import BaseSerializer
from plane.db.models import Integration, WorkspaceIntegration
class IntegrationSerializer(BaseSerializer):
class Meta:
model = Integration
fields = "__all__"
read_only_fields = [
"verified",
]
class WorkspaceIntegrationSerializer(BaseSerializer):
integration_detail = IntegrationSerializer(read_only=True, source="integration")
class Meta:
model = WorkspaceIntegration
fields = "__all__"

View File

@ -0,0 +1,45 @@
# Module imports
from plane.api.serializers import BaseSerializer
from plane.db.models import (
GithubIssueSync,
GithubRepository,
GithubRepositorySync,
GithubCommentSync,
)
class GithubRepositorySerializer(BaseSerializer):
class Meta:
model = GithubRepository
fields = "__all__"
class GithubRepositorySyncSerializer(BaseSerializer):
repo_detail = GithubRepositorySerializer(source="repository")
class Meta:
model = GithubRepositorySync
fields = "__all__"
class GithubIssueSyncSerializer(BaseSerializer):
class Meta:
model = GithubIssueSync
fields = "__all__"
read_only_fields = [
"project",
"workspace",
"repository_sync",
]
class GithubCommentSyncSerializer(BaseSerializer):
class Meta:
model = GithubCommentSync
fields = "__all__"
read_only_fields = [
"project",
"workspace",
"repository_sync",
"issue_sync",
]

View File

@ -24,9 +24,15 @@ from plane.db.models import (
Cycle,
Module,
ModuleIssue,
IssueLink,
)
class IssueLinkCreateSerializer(serializers.Serializer):
url = serializers.CharField(required=True)
title = serializers.CharField(required=False)
class IssueFlatSerializer(BaseSerializer):
## Contain only flat fields
@ -40,24 +46,13 @@ class IssueFlatSerializer(BaseSerializer):
"start_date",
"target_date",
"sequence_id",
"sort_order",
]
# Issue Serializer with state details
class IssueStateSerializer(BaseSerializer):
state_detail = StateSerializer(read_only=True, source="state")
project_detail = ProjectSerializer(read_only=True, source="project")
class Meta:
model = Issue
fields = "__all__"
##TODO: Find a better way to write this serializer
## Find a better approach to save manytomany?
class IssueCreateSerializer(BaseSerializer):
state_detail = StateSerializer(read_only=True, source="state")
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
project_detail = ProjectSerializer(read_only=True, source="project")
@ -87,6 +82,11 @@ class IssueCreateSerializer(BaseSerializer):
write_only=True,
required=False,
)
links_list = serializers.ListField(
child=IssueLinkCreateSerializer(),
write_only=True,
required=False,
)
class Meta:
model = Issue
@ -105,6 +105,7 @@ class IssueCreateSerializer(BaseSerializer):
assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
links = validated_data.pop("links_list", None)
project = self.context["project"]
issue = Issue.objects.create(**validated_data, project=project)
@ -173,14 +174,32 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10,
)
if links is not None:
IssueLink.objects.bulk_create(
[
IssueLink(
issue=issue,
project=project,
workspace=project.workspace,
created_by=issue.created_by,
updated_by=issue.updated_by,
title=link.get("title", None),
url=link.get("url", None),
)
for link in links
],
batch_size=10,
ignore_conflicts=True,
)
return issue
def update(self, instance, validated_data):
blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
links = validated_data.pop("links_list", None)
if blockers is not None:
IssueBlocker.objects.filter(block=instance).delete()
@ -250,11 +269,29 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10,
)
if links is not None:
IssueLink.objects.filter(issue=instance).delete()
IssueLink.objects.bulk_create(
[
IssueLink(
issue=instance,
project=instance.project,
workspace=instance.project.workspace,
created_by=instance.created_by,
updated_by=instance.updated_by,
title=link.get("title", None),
url=link.get("url", None),
)
for link in links
],
batch_size=10,
ignore_conflicts=True,
)
return super().update(instance, validated_data)
class IssueActivitySerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
@ -263,7 +300,6 @@ class IssueActivitySerializer(BaseSerializer):
class IssueCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectSerializer(read_only=True, source="project")
@ -319,7 +355,6 @@ class LabelSerializer(BaseSerializer):
class IssueLabelSerializer(BaseSerializer):
# label_details = LabelSerializer(read_only=True, source="label")
class Meta:
@ -332,7 +367,6 @@ class IssueLabelSerializer(BaseSerializer):
class BlockedIssueSerializer(BaseSerializer):
blocked_issue_detail = IssueFlatSerializer(source="block", read_only=True)
class Meta:
@ -341,7 +375,6 @@ class BlockedIssueSerializer(BaseSerializer):
class BlockerIssueSerializer(BaseSerializer):
blocker_issue_detail = IssueFlatSerializer(source="blocked_by", read_only=True)
class Meta:
@ -350,7 +383,6 @@ class BlockerIssueSerializer(BaseSerializer):
class IssueAssigneeSerializer(BaseSerializer):
assignee_details = UserLiteSerializer(read_only=True, source="assignee")
class Meta:
@ -373,7 +405,6 @@ class CycleBaseSerializer(BaseSerializer):
class IssueCycleDetailSerializer(BaseSerializer):
cycle_detail = CycleBaseSerializer(read_only=True, source="cycle")
class Meta:
@ -404,7 +435,6 @@ class ModuleBaseSerializer(BaseSerializer):
class IssueModuleDetailSerializer(BaseSerializer):
module_detail = ModuleBaseSerializer(read_only=True, source="module")
class Meta:
@ -420,6 +450,26 @@ class IssueModuleDetailSerializer(BaseSerializer):
]
class IssueLinkSerializer(BaseSerializer):
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
class Meta:
model = IssueLink
fields = "__all__"
# Issue Serializer with state details
class IssueStateSerializer(BaseSerializer):
state_detail = StateSerializer(read_only=True, source="state")
project_detail = ProjectSerializer(read_only=True, source="project")
label_details = LabelSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
class Meta:
model = Issue
fields = "__all__"
class IssueSerializer(BaseSerializer):
project_detail = ProjectSerializer(read_only=True, source="project")
state_detail = StateSerializer(read_only=True, source="state")
@ -432,6 +482,7 @@ class IssueSerializer(BaseSerializer):
blocker_issues = BlockerIssueSerializer(read_only=True, many=True)
issue_cycle = IssueCycleDetailSerializer(read_only=True)
issue_module = IssueModuleDetailSerializer(read_only=True)
issue_link = IssueLinkSerializer(read_only=True, many=True)
sub_issues_count = serializers.IntegerField(read_only=True)
class Meta:

View File

@ -21,6 +21,7 @@ class UserSerializer(BaseSerializer):
"last_login_uagent",
"token_updated_at",
"is_onboarded",
"is_bot",
]
extra_kwargs = {"password": {"write_only": True}}
@ -34,7 +35,9 @@ class UserLiteSerializer(BaseSerializer):
"last_name",
"email",
"avatar",
"is_bot",
]
read_only_fields = [
"id",
"is_bot",
]

View File

@ -5,7 +5,6 @@ from django.urls import path
from plane.api.views import (
# Authentication
SignUpEndpoint,
SignInEndpoint,
SignOutEndpoint,
MagicSignInEndpoint,
@ -87,6 +86,14 @@ from plane.api.views import (
# Api Tokens
ApiTokenEndpoint,
## End Api Tokens
# Integrations
IntegrationViewSet,
WorkspaceIntegrationViewSet,
GithubRepositoriesEndpoint,
GithubRepositorySyncViewSet,
GithubIssueSyncViewSet,
GithubCommentSyncViewSet,
## End Integrations
)
@ -95,7 +102,6 @@ urlpatterns = [
path("social-auth/", OauthEndpoint.as_view(), name="oauth"),
# Auth
path("sign-in/", SignInEndpoint.as_view(), name="sign-in"),
path("sign-up/", SignUpEndpoint.as_view(), name="sign-up"),
path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"),
# Magic Sign In/Up
path(
@ -683,7 +689,118 @@ urlpatterns = [
),
## End Modules
# API Tokens
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-token"),
path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-token"),
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-tokens"),
## End API Tokens
# Integrations
path(
"integrations/",
IntegrationViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="integrations",
),
path(
"integrations/<uuid:pk>/",
IntegrationViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="integrations",
),
path(
"workspaces/<str:slug>/workspace-integrations/",
WorkspaceIntegrationViewSet.as_view(
{
"get": "list",
}
),
name="workspace-integrations",
),
path(
"workspaces/<str:slug>/workspace-integrations/<str:provider>/",
WorkspaceIntegrationViewSet.as_view(
{
"post": "create",
}
),
name="workspace-integrations",
),
path(
"workspaces/<str:slug>/workspace-integrations/<uuid:pk>/provider/",
WorkspaceIntegrationViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
name="workspace-integrations",
),
# Github Integrations
path(
"workspaces/<str:slug>/workspace-integrations/<uuid:workspace_integration_id>/github-repositories/",
GithubRepositoriesEndpoint.as_view(),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/",
GithubRepositorySyncViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/<uuid:pk>/",
GithubRepositorySyncViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/",
GithubIssueSyncViewSet.as_view(
{
"post": "create",
"get": "list",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:pk>/",
GithubIssueSyncViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/",
GithubCommentSyncViewSet.as_view(
{
"post": "create",
"get": "list",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/<uuid:pk>/",
GithubCommentSyncViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
),
## End Github Integrations
## End Integrations
]

View File

@ -64,7 +64,6 @@ from .auth_extended import (
from .authentication import (
SignUpEndpoint,
SignInEndpoint,
SignOutEndpoint,
MagicSignInEndpoint,
@ -73,4 +72,13 @@ from .authentication import (
from .module import ModuleViewSet, ModuleIssueViewSet
from .api_token import ApiTokenEndpoint
from .api_token import ApiTokenEndpoint
from .integration import (
WorkspaceIntegrationViewSet,
IntegrationViewSet,
GithubIssueSyncViewSet,
GithubRepositorySyncViewSet,
GithubCommentSyncViewSet,
GithubRepositoriesEndpoint,
)

View File

@ -15,12 +15,16 @@ from plane.api.serializers import APITokenSerializer
class ApiTokenEndpoint(BaseAPIView):
def post(self, request):
try:
label = request.data.get("label", str(uuid4().hex))
workspace = request.data.get("workspace", False)
if not workspace:
return Response(
{"error": "Workspace is required"}, status=status.HTTP_200_OK
)
api_token = APIToken.objects.create(
label=label,
user=request.user,
label=label, user=request.user, workspace_id=workspace
)
serializer = APITokenSerializer(api_token)

View File

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

View File

@ -9,6 +9,7 @@ from django.utils import timezone
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.conf import settings
from django.contrib.auth.hashers import make_password
# Third party imports
from rest_framework.response import Response
@ -34,74 +35,6 @@ def get_tokens_for_user(user):
)
class SignUpEndpoint(BaseAPIView):
permission_classes = (AllowAny,)
def post(self, request):
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.filter(email=email).first()
if user is not None:
return Response(
{"error": "Email ID is already taken"},
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.create(email=email)
user.set_password(password)
# settings last actives for the user
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
serialized_user = UserSerializer(user).data
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
"user": serialized_user,
}
return Response(data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{
"error": "Something went wrong. Please try again later or contact the support team."
},
status=status.HTTP_400_BAD_REQUEST,
)
class SignInEndpoint(BaseAPIView):
permission_classes = (AllowAny,)
@ -127,50 +60,69 @@ class SignInEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.get(email=email)
user = User.objects.filter(email=email).first()
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,
)
# Sign up Process
if user is None:
user = User.objects.create(email=email, username=uuid.uuid4().hex)
user.set_password(password)
serialized_user = UserSerializer(user).data
# settings last actives for the user
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
# 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()
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 = {
"access_token": access_token,
"refresh_token": refresh_token,
"user": serialized_user,
}
return Response(data, status=status.HTTP_200_OK)
return Response(data, status=status.HTTP_200_OK)
# Sign in Process
else:
if not user.check_password(password):
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
serialized_user = UserSerializer(user).data
# 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:
capture_exception(e)
return Response(
@ -216,14 +168,12 @@ class SignOutEndpoint(BaseAPIView):
class MagicSignInGenerateEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
try:
email = request.data.get("email", False)
if not email:
@ -269,7 +219,6 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
ri.set(key, json.dumps(value), ex=expiry)
else:
value = {"current_attempt": 0, "email": email, "token": token}
expiry = 600
@ -293,14 +242,12 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
class MagicSignInEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
try:
user_token = request.data.get("token", "").strip().lower()
key = request.data.get("key", False)
@ -313,19 +260,20 @@ class MagicSignInEndpoint(BaseAPIView):
ri = redis_instance()
if ri.exists(key):
data = json.loads(ri.get(key))
token = data["token"]
email = data["email"]
if str(token) == str(user_token):
if User.objects.filter(email=email).exists():
user = User.objects.get(email=email)
else:
user = User.objects.create(
email=email, username=uuid.uuid4().hex
email=email,
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
user.last_active = timezone.now()

View File

@ -1,5 +1,9 @@
# Python imports
import json
# Django imports
from django.db.models import OuterRef, Func, F
from django.core import serializers
# Third party imports
from rest_framework.response import Response
@ -11,10 +15,10 @@ from . import BaseViewSet
from plane.api.serializers import CycleSerializer, CycleIssueSerializer
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Cycle, CycleIssue, Issue
from plane.bgtasks.issue_activites_task import issue_activity
class CycleViewSet(BaseViewSet):
serializer_class = CycleSerializer
model = Cycle
permission_classes = [
@ -41,7 +45,6 @@ class CycleViewSet(BaseViewSet):
class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
model = CycleIssue
@ -79,7 +82,6 @@ class CycleIssueViewSet(BaseViewSet):
def create(self, request, slug, project_id, cycle_id):
try:
issues = request.data.get("issues", [])
if not len(issues):
@ -91,29 +93,77 @@ class CycleIssueViewSet(BaseViewSet):
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
issues = Issue.objects.filter(
pk__in=issues, workspace__slug=slug, project_id=project_id
)
# Get all CycleIssues already created
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
records_to_update = []
update_cycle_issue_activity = []
record_to_create = []
# Delete old records in order to maintain the database integrity
CycleIssue.objects.filter(issue_id__in=issues).delete()
for issue in issues:
cycle_issue = [
cycle_issue
for cycle_issue in cycle_issues
if str(cycle_issue.issue_id) in issues
]
# Update only when cycle changes
if len(cycle_issue):
if cycle_issue[0].cycle_id != cycle_id:
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_issue[0].cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue[0].issue_id),
}
)
cycle_issue[0].cycle_id = cycle_id
records_to_update.append(cycle_issue[0])
else:
record_to_create.append(
CycleIssue(
project_id=project_id,
workspace=cycle.workspace,
created_by=request.user,
updated_by=request.user,
cycle=cycle,
issue_id=issue,
)
)
CycleIssue.objects.bulk_create(
[
CycleIssue(
project_id=project_id,
workspace=cycle.workspace,
created_by=request.user,
updated_by=request.user,
cycle=cycle,
issue=issue,
)
for issue in issues
],
record_to_create,
batch_size=10,
ignore_conflicts=True,
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
CycleIssue.objects.bulk_update(
records_to_update,
["cycle"],
batch_size=10,
)
# Capture Issue Activity
issue_activity.delay(
{
"type": "issue.activity",
"requested_data": json.dumps({"cycles_list": issues}),
"actor_id": str(self.request.user.id),
"issue_id": str(self.kwargs.get("pk", None)),
"project_id": str(self.kwargs.get("project_id", None)),
"current_instance": json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize(
"json", record_to_create
),
}
),
},
)
# Return all Cycle Issues
return Response(
CycleIssueSerializer(self.get_queryset(), many=True).data,
status=status.HTTP_200_OK,
)
except Cycle.DoesNotExist:
return Response(

View File

@ -0,0 +1,7 @@
from .base import IntegrationViewSet, WorkspaceIntegrationViewSet
from .github import (
GithubRepositorySyncViewSet,
GithubIssueSyncViewSet,
GithubCommentSyncViewSet,
GithubRepositoriesEndpoint,
)

View File

@ -0,0 +1,198 @@
# Python improts
import uuid
# Django imports
from django.db import IntegrityError
from django.contrib.auth.hashers import make_password
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from plane.api.views import BaseViewSet
from plane.db.models import (
Integration,
WorkspaceIntegration,
Workspace,
User,
WorkspaceMember,
APIToken,
)
from plane.api.serializers import IntegrationSerializer, WorkspaceIntegrationSerializer
from plane.utils.integrations.github import (
get_github_metadata,
delete_github_installation,
)
class IntegrationViewSet(BaseViewSet):
serializer_class = IntegrationSerializer
model = Integration
def create(self, request):
try:
serializer = IntegrationSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, pk):
try:
integration = Integration.objects.get(pk=pk)
if integration.verified:
return Response(
{"error": "Verified integrations cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IntegrationSerializer(
integration, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Integration.DoesNotExist:
return Response(
{"error": "Integration Does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceIntegrationViewSet(BaseViewSet):
serializer_class = WorkspaceIntegrationSerializer
model = WorkspaceIntegration
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("integration")
)
def create(self, request, slug, provider):
try:
installation_id = request.data.get("installation_id", None)
if not installation_id:
return Response(
{"error": "Installation ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
integration = Integration.objects.get(provider=provider)
config = {}
if provider == "github":
metadata = get_github_metadata(installation_id)
config = {"installation_id": installation_id}
# Create a bot user
bot_user = User.objects.create(
email=f"{uuid.uuid4().hex}@plane.so",
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
is_bot=True,
first_name=integration.title,
avatar=integration.avatar_url
if integration.avatar_url is not None
else "",
)
# Create an API Token for the bot user
api_token = APIToken.objects.create(
user=bot_user,
user_type=1, # bot user
workspace=workspace,
)
workspace_integration = WorkspaceIntegration.objects.create(
workspace=workspace,
integration=integration,
actor=bot_user,
api_token=api_token,
metadata=metadata,
config=config,
)
# Add bot user as a member of workspace
_ = WorkspaceMember.objects.create(
workspace=workspace_integration.workspace,
member=bot_user,
role=20,
)
return Response(
WorkspaceIntegrationSerializer(workspace_integration).data,
status=status.HTTP_201_CREATED,
)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"error": "Integration is already active in the workspace"},
status=status.HTTP_410_GONE,
)
else:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
except (Workspace.DoesNotExist, Integration.DoesNotExist) as e:
capture_exception(e)
return Response(
{"error": "Workspace or Integration not found"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, pk):
try:
workspace_integration = WorkspaceIntegration.objects.get(
pk=pk, workspace__slug=slug
)
if workspace_integration.integration.provider == "github":
installation_id = workspace_integration.config.get(
"installation_id", False
)
if installation_id:
delete_github_installation(installation_id=installation_id)
workspace_integration.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except WorkspaceIntegration.DoesNotExist:
return Response(
{"error": "Workspace Integration Does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -0,0 +1,165 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Module imports
from plane.api.views import BaseViewSet, BaseAPIView
from plane.db.models import (
GithubIssueSync,
GithubRepositorySync,
GithubRepository,
WorkspaceIntegration,
ProjectMember,
Label,
GithubCommentSync,
)
from plane.api.serializers import (
GithubIssueSyncSerializer,
GithubRepositorySyncSerializer,
GithubCommentSyncSerializer,
)
from plane.utils.integrations.github import get_github_repos
class GithubRepositoriesEndpoint(BaseAPIView):
def get(self, request, slug, workspace_integration_id):
try:
workspace_integration = WorkspaceIntegration.objects.get(
workspace__slug=slug, pk=workspace_integration_id
)
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
repositories_url = workspace_integration.metadata["repositories_url"]
repositories = get_github_repos(access_tokens_url, repositories_url)
return Response(repositories, status=status.HTTP_200_OK)
except WorkspaceIntegration.DoesNotExist:
return Response(
{"error": "Workspace Integration Does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
class GithubRepositorySyncViewSet(BaseViewSet):
serializer_class = GithubRepositorySyncSerializer
model = GithubRepositorySync
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
)
def create(self, request, slug, project_id, workspace_integration_id):
try:
name = request.data.get("name", False)
url = request.data.get("url", False)
config = request.data.get("config", {})
repository_id = request.data.get("repository_id", False)
owner = request.data.get("owner", False)
if not name or not url or not repository_id or not owner:
return Response(
{"error": "Name, url, repository_id and owner are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the workspace integration
workspace_integration = WorkspaceIntegration.objects.get(
pk=workspace_integration_id
)
# Delete the old repository object
GithubRepositorySync.objects.filter(
project_id=project_id, workspace__slug=slug
).delete()
GithubRepository.objects.filter(
project_id=project_id, workspace__slug=slug
).delete()
# Project member delete
ProjectMember.objects.filter(
member=workspace_integration.actor, role=20, project_id=project_id
).delete()
# Create repository
repo = GithubRepository.objects.create(
name=name,
url=url,
config=config,
repository_id=repository_id,
owner=owner,
project_id=project_id,
)
# Create a Label for github
label = Label.objects.filter(
name="GitHub",
project_id=project_id,
).first()
if label is None:
label = Label.objects.create(
name="GitHub",
project_id=project_id,
description="Label to sync Plane issues with GitHub issues",
color="#003773",
)
# Create repo sync
repo_sync = GithubRepositorySync.objects.create(
repository=repo,
workspace_integration=workspace_integration,
actor=workspace_integration.actor,
credentials=request.data.get("credentials", {}),
project_id=project_id,
label=label,
)
# Add bot as a member in the project
_ = ProjectMember.objects.create(
member=workspace_integration.actor, role=20, project_id=project_id
)
# Return Response
return Response(
GithubRepositorySyncSerializer(repo_sync).data,
status=status.HTTP_201_CREATED,
)
except WorkspaceIntegration.DoesNotExist:
return Response(
{"error": "Workspace Integration does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class GithubIssueSyncViewSet(BaseViewSet):
serializer_class = GithubIssueSyncSerializer
model = GithubIssueSync
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
repository_sync_id=self.kwargs.get("repo_sync_id"),
)
class GithubCommentSyncViewSet(BaseViewSet):
serializer_class = GithubCommentSyncSerializer
model = GithubCommentSync
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
issue_sync_id=self.kwargs.get("issue_sync_id"),
)

View File

@ -3,7 +3,7 @@ import json
from itertools import groupby, chain
# Django imports
from django.db.models import Prefetch, OuterRef, Func, F
from django.db.models import Prefetch, OuterRef, Func, F, Q
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
@ -22,6 +22,7 @@ from plane.api.serializers import (
LabelSerializer,
IssueSerializer,
LabelSerializer,
IssueFlatSerializer,
)
from plane.api.permissions import (
ProjectEntityPermission,
@ -39,8 +40,10 @@ from plane.db.models import (
IssueBlocker,
CycleIssue,
ModuleIssue,
IssueLink,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
class IssueViewSet(BaseViewSet):
@ -75,10 +78,9 @@ class IssueViewSet(BaseViewSet):
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None:
issue_activity.delay(
{
"type": "issue.activity",
"type": "issue.activity.updated",
"requested_data": requested_data,
"actor_id": str(self.request.user.id),
"issue_id": str(self.kwargs.get("pk", None)),
@ -91,8 +93,28 @@ class IssueViewSet(BaseViewSet):
return super().perform_update(serializer)
def get_queryset(self):
def perform_destroy(self, instance):
current_instance = (
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None:
issue_activity.delay(
{
"type": "issue.activity.deleted",
"requested_data": json.dumps(
{"issue_id": str(self.kwargs.get("pk", None))}
),
"actor_id": str(self.request.user.id),
"issue_id": str(self.kwargs.get("pk", None)),
"project_id": str(self.kwargs.get("project_id", None)),
"current_instance": json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
},
)
return super().perform_destroy(instance)
def get_queryset(self):
return (
super()
.get_queryset()
@ -136,52 +158,42 @@ class IssueViewSet(BaseViewSet):
).prefetch_related("module__members"),
),
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("issue").select_related(
"created_by"
),
)
)
)
def grouper(self, issue, group_by):
group_by = issue.get(group_by, "")
if isinstance(group_by, list):
if len(group_by):
return group_by[0]
else:
return ""
else:
return group_by
def list(self, request, slug, project_id):
try:
issue_queryset = self.get_queryset()
# Issue State groups
type = request.GET.get("type", "all")
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
if type == "backlog":
group = ["backlog"]
if type == "active":
group = ["unstarted", "started"]
issue_queryset = (
self.get_queryset()
.order_by(request.GET.get("order_by", "created_at"))
.filter(state__group__in=group)
)
issues = IssueSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
# TODO: Move this group by from ittertools to ORM for better performance - nk
if group_by:
issue_dict = dict()
return Response(
group_results(issues, group_by), status=status.HTTP_200_OK
)
issues = IssueSerializer(issue_queryset, many=True).data
for key, value in groupby(
issues, lambda issue: self.grouper(issue, group_by)
):
issue_dict[str(key)] = list(value)
return Response(issue_dict, status=status.HTTP_200_OK)
return Response(
{
"next_cursor": str(0),
"prev_cursor": str(0),
"next_page_results": False,
"prev_page_results": False,
"count": issue_queryset.count(),
"total_pages": 1,
"extra_stats": {},
"results": IssueSerializer(issue_queryset, many=True).data,
},
status=status.HTTP_200_OK,
)
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
print(e)
@ -202,15 +214,18 @@ class IssueViewSet(BaseViewSet):
serializer.save()
# Track the issue
IssueActivity.objects.create(
issue_id=serializer.data["id"],
project_id=project_id,
workspace_id=serializer["workspace"],
comment=f"{request.user.email} created the issue",
verb="created",
actor=request.user,
issue_activity.delay(
{
"type": "issue.activity.created",
"requested_data": json.dumps(
self.request.data, cls=DjangoJSONEncoder
),
"actor_id": str(request.user.id),
"issue_id": str(serializer.data.get("id", None)),
"project_id": str(project_id),
"current_instance": None,
},
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -265,6 +280,14 @@ class UserWorkSpaceIssues(BaseAPIView):
queryset=ModuleIssue.objects.select_related("module", "issue"),
),
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related(
"issue"
).select_related("created_by"),
)
)
)
serializer = IssueSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -277,7 +300,6 @@ class UserWorkSpaceIssues(BaseAPIView):
class WorkSpaceIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
@ -298,7 +320,6 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
class IssueActivityEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@ -307,7 +328,10 @@ class IssueActivityEndpoint(BaseAPIView):
try:
issue_activities = (
IssueActivity.objects.filter(issue_id=issue_id)
.filter(project__project_projectmember__member=self.request.user)
.filter(
~Q(field="comment"),
project__project_projectmember__member=self.request.user,
)
.select_related("actor")
).order_by("created_by")
issue_comments = (
@ -333,7 +357,6 @@ class IssueActivityEndpoint(BaseAPIView):
class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
permission_classes = [
@ -351,6 +374,60 @@ class IssueCommentViewSet(BaseViewSet):
issue_id=self.kwargs.get("issue_id"),
actor=self.request.user if self.request.user is not None else None,
)
issue_activity.delay(
{
"type": "comment.activity.created",
"requested_data": json.dumps(serializer.data, cls=DjangoJSONEncoder),
"actor_id": str(self.request.user.id),
"issue_id": str(self.kwargs.get("issue_id")),
"project_id": str(self.kwargs.get("project_id")),
"current_instance": None,
},
)
def perform_update(self, serializer):
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
current_instance = (
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None:
issue_activity.delay(
{
"type": "comment.activity.updated",
"requested_data": requested_data,
"actor_id": str(self.request.user.id),
"issue_id": str(self.kwargs.get("issue_id", None)),
"project_id": str(self.kwargs.get("project_id", None)),
"current_instance": json.dumps(
IssueCommentSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
},
)
return super().perform_update(serializer)
def perform_destroy(self, instance):
current_instance = (
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None:
issue_activity.delay(
{
"type": "comment.activity.deleted",
"requested_data": json.dumps(
{"comment_id": str(self.kwargs.get("pk", None))}
),
"actor_id": str(self.request.user.id),
"issue_id": str(self.kwargs.get("issue_id", None)),
"project_id": str(self.kwargs.get("project_id", None)),
"current_instance": json.dumps(
IssueCommentSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
},
)
return super().perform_destroy(instance)
def get_queryset(self):
return self.filter_queryset(
@ -436,7 +513,6 @@ class IssuePropertyViewSet(BaseViewSet):
def create(self, request, slug, project_id):
try:
issue_property, created = IssueProperty.objects.get_or_create(
user=request.user,
project_id=project_id,
@ -463,7 +539,6 @@ class IssuePropertyViewSet(BaseViewSet):
class LabelViewSet(BaseViewSet):
serializer_class = LabelSerializer
model = Label
permission_classes = [
@ -490,14 +565,12 @@ class LabelViewSet(BaseViewSet):
class BulkDeleteIssuesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def delete(self, request, slug, project_id):
try:
issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids):
@ -527,14 +600,12 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
class SubIssuesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, issue_id):
try:
sub_issues = (
Issue.objects.filter(
parent_id=issue_id, workspace__slug=slug, project_id=project_id
@ -583,3 +654,39 @@ class SubIssuesEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
# Assign multiple sub issues
def post(self, request, slug, project_id, issue_id):
try:
parent_issue = Issue.objects.get(pk=issue_id)
sub_issue_ids = request.data.get("sub_issue_ids", [])
if not len(sub_issue_ids):
return Response(
{"error": "Sub Issue IDs are required"},
status=status.HTTP_400_BAD_REQUEST,
)
sub_issues = Issue.objects.filter(id__in=sub_issue_ids)
for sub_issue in sub_issues:
sub_issue.parent = parent_issue
_ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10)
updated_sub_issues = Issue.objects.filter(id__in=sub_issue_ids)
return Response(
IssueFlatSerializer(updated_sub_issues, many=True).data,
status=status.HTTP_200_OK,
)
except Issue.DoesNotExist:
return Response(
{"Parent Issue does not exists"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -1,6 +1,10 @@
# Python imports
import json
# Django Imports
from django.db import IntegrityError
from django.db.models import Prefetch, F, OuterRef, Func
from django.core import serializers
# Third party imports
from rest_framework.response import Response
@ -22,10 +26,10 @@ from plane.db.models import (
Issue,
ModuleLink,
)
from plane.bgtasks.issue_activites_task import issue_activity
class ModuleViewSet(BaseViewSet):
model = Module
permission_classes = [
ProjectEntityPermission,
@ -95,7 +99,6 @@ class ModuleViewSet(BaseViewSet):
class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
@ -148,29 +151,77 @@ class ModuleIssueViewSet(BaseViewSet):
workspace__slug=slug, project_id=project_id, pk=module_id
)
issues = Issue.objects.filter(
pk__in=issues, workspace__slug=slug, project_id=project_id
)
module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues))
# Delete old records in order to maintain the database integrity
ModuleIssue.objects.filter(issue_id__in=issues).delete()
update_module_issue_activity = []
records_to_update = []
record_to_create = []
for issue in issues:
module_issue = [
module_issue
for module_issue in module_issues
if str(module_issue.issue_id) in issues
]
if len(module_issue):
if module_issue[0].module_id != module_id:
update_module_issue_activity.append(
{
"old_module_id": str(module_issue[0].module_id),
"new_module_id": str(module_id),
"issue_id": str(module_issue[0].issue_id),
}
)
module_issue[0].module_id = module_id
records_to_update.append(module_issue[0])
else:
record_to_create.append(
ModuleIssue(
module=module,
issue_id=issue,
project_id=project_id,
workspace=module.workspace,
created_by=request.user,
updated_by=request.user,
)
)
ModuleIssue.objects.bulk_create(
[
ModuleIssue(
module=module,
issue=issue,
project_id=project_id,
workspace=module.workspace,
created_by=request.user,
updated_by=request.user,
)
for issue in issues
],
record_to_create,
batch_size=10,
ignore_conflicts=True,
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
ModuleIssue.objects.bulk_update(
records_to_update,
["module"],
batch_size=10,
)
# Capture Issue Activity
issue_activity.delay(
{
"type": "issue.activity",
"requested_data": json.dumps({"modules_list": issues}),
"actor_id": str(self.request.user.id),
"issue_id": str(self.kwargs.get("pk", None)),
"project_id": str(self.kwargs.get("project_id", None)),
"current_instance": json.dumps(
{
"updated_module_issues": update_module_issue_activity,
"created_module_issues": serializers.serialize(
"json", record_to_create
),
}
),
},
)
return Response(
ModuleIssueSerializer(self.get_queryset(), many=True).data,
status=status.HTTP_200_OK,
)
except Module.DoesNotExist:
return Response(
{"error": "Module Does not exists"}, status=status.HTTP_400_BAD_REQUEST

View File

@ -34,7 +34,6 @@ def get_tokens_for_user(user):
def validate_google_token(token, client_id):
try:
id_info = id_token.verify_oauth2_token(
token, google_auth_request.Request(), client_id
)
@ -106,9 +105,19 @@ def get_user_data(access_token: str) -> dict:
resp = requests.get(url=url, headers=headers)
userData = resp.json()
user_data = resp.json()
return userData
response = requests.get(
url="https://api.github.com/user/emails", headers=headers
).json()
[
user_data.update({"email": item.get("email")})
for item in response
if item.get("primary") is True
]
return user_data
class OauthEndpoint(BaseAPIView):
@ -116,7 +125,6 @@ class OauthEndpoint(BaseAPIView):
def post(self, request):
try:
medium = request.data.get("medium", False)
id_token = request.data.get("credential", False)
client_id = request.data.get("clientId", False)
@ -138,7 +146,6 @@ class OauthEndpoint(BaseAPIView):
email = data.get("email", None)
if email == None:
return Response(
{
"error": "Something went wrong. Please try again later or contact the support team."
@ -153,7 +160,6 @@ class OauthEndpoint(BaseAPIView):
mobile_number = uuid.uuid4().hex
email_verified = True
else:
return Response(
{
"error": "Something went wrong. Please try again later or contact the support team."

View File

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

View File

@ -1,3 +1,12 @@
# Python imports
from itertools import groupby
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from . import BaseViewSet
from plane.api.serializers import StateSerializer
@ -6,7 +15,6 @@ from plane.db.models import State
class StateViewSet(BaseViewSet):
serializer_class = StateSerializer
model = State
permission_classes = [
@ -27,3 +35,38 @@ class StateViewSet(BaseViewSet):
.select_related("workspace")
.distinct()
)
def list(self, request, slug, project_id):
try:
state_dict = dict()
states = StateSerializer(self.get_queryset(), many=True).data
for key, value in groupby(
sorted(states, key=lambda state: state["group"]),
lambda state: state.get("group"),
):
state_dict[str(key)] = list(value)
return Response(state_dict, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, pk):
try:
state = State.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
if state.default:
return Response(
{"error": "Default state cannot be deleted"}, status=False
)
state.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except State.DoesNotExist:
return Response({"error": "State does not exists"}, status=status.HTTP_404)

View File

@ -1,12 +1,27 @@
# Python imports
import json
import requests
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from django_rq import job
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import User, Issue, Project, Label, IssueActivity, State
from plane.db.models import (
User,
Issue,
Project,
Label,
IssueActivity,
State,
Cycle,
Module,
)
from plane.api.serializers import IssueActivitySerializer
# Track Chnages in name
@ -44,7 +59,6 @@ def track_parent(
issue_activities,
):
if current_instance.get("parent") != requested_data.get("parent"):
if requested_data.get("parent") == None:
old_parent = Issue.objects.get(pk=current_instance.get("parent"))
issue_activities.append(
@ -134,7 +148,6 @@ def track_state(
issue_activities,
):
if current_instance.get("state") != requested_data.get("state"):
new_state = State.objects.get(pk=requested_data.get("state", None))
old_state = State.objects.get(pk=current_instance.get("state", None))
@ -167,7 +180,6 @@ def track_description(
if current_instance.get("description_html") != requested_data.get(
"description_html"
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
@ -274,7 +286,6 @@ def track_labels(
):
# Label Addition
if len(requested_data.get("labels_list")) > len(current_instance.get("labels")):
for label in requested_data.get("labels_list"):
if label not in current_instance.get("labels"):
label = Label.objects.get(pk=label)
@ -296,7 +307,6 @@ def track_labels(
# Label Removal
if len(requested_data.get("labels_list")) < len(current_instance.get("labels")):
for label in current_instance.get("labels"):
if label not in requested_data.get("labels_list"):
label = Label.objects.get(pk=label)
@ -326,12 +336,10 @@ def track_assignees(
actor,
issue_activities,
):
# Assignee Addition
if len(requested_data.get("assignees_list")) > len(
current_instance.get("assignees")
):
for assignee in requested_data.get("assignees_list"):
if assignee not in current_instance.get("assignees"):
assignee = User.objects.get(pk=assignee)
@ -354,7 +362,6 @@ def track_assignees(
if len(requested_data.get("assignees_list")) < len(
current_instance.get("assignees")
):
for assignee in current_instance.get("assignees"):
if assignee not in requested_data.get("assignees_list"):
assignee = User.objects.get(pk=assignee)
@ -386,7 +393,6 @@ def track_blocks(
if len(requested_data.get("blocks_list")) > len(
current_instance.get("blocked_issues")
):
for block in requested_data.get("blocks_list"):
if (
len(
@ -418,7 +424,6 @@ def track_blocks(
if len(requested_data.get("blocks_list")) < len(
current_instance.get("blocked_issues")
):
for blocked in current_instance.get("blocked_issues"):
if blocked.get("block") not in requested_data.get("blocks_list"):
issue = Issue.objects.get(pk=blocked.get("block"))
@ -450,7 +455,6 @@ def track_blockings(
if len(requested_data.get("blockers_list")) > len(
current_instance.get("blocker_issues")
):
for block in requested_data.get("blockers_list"):
if (
len(
@ -482,7 +486,6 @@ def track_blockings(
if len(requested_data.get("blockers_list")) < len(
current_instance.get("blocker_issues")
):
for blocked in current_instance.get("blocker_issues"):
if blocked.get("blocked_by") not in requested_data.get("blockers_list"):
issue = Issue.objects.get(pk=blocked.get("blocked_by"))
@ -502,15 +505,250 @@ def track_blockings(
)
def track_cycles(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
# Updated Records:
updated_records = current_instance.get("updated_cycle_issues", [])
created_records = json.loads(current_instance.get("created_cycle_issues", []))
for updated_record in updated_records:
old_cycle = Cycle.objects.filter(
pk=updated_record.get("old_cycle_id", None)
).first()
new_cycle = Cycle.objects.filter(
pk=updated_record.get("new_cycle_id", None)
).first()
issue_activities.append(
IssueActivity(
issue_id=updated_record.get("issue_id"),
actor=actor,
verb="updated",
old_value=old_cycle.name,
new_value=new_cycle.name,
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id,
new_identifier=new_cycle.id,
)
)
for created_record in created_records:
cycle = Cycle.objects.filter(
pk=created_record.get("fields").get("cycle")
).first()
issue_activities.append(
IssueActivity(
issue_id=created_record.get("fields").get("issue"),
actor=actor,
verb="created",
old_value="",
new_value=cycle.name,
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added cycle {cycle.name}",
new_identifier=cycle.id,
)
)
def track_modules(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
# Updated Records:
updated_records = current_instance.get("updated_module_issues", [])
created_records = json.loads(current_instance.get("created_module_issues", []))
for updated_record in updated_records:
old_module = Module.objects.filter(
pk=updated_record.get("old_module_id", None)
).first()
new_module = Module.objects.filter(
pk=updated_record.get("new_module_id", None)
).first()
issue_activities.append(
IssueActivity(
issue_id=updated_record.get("issue_id"),
actor=actor,
verb="updated",
old_value=old_module.name,
new_value=new_module.name,
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated module from {old_module.name} to {new_module.name}",
old_identifier=old_module.id,
new_identifier=new_module.id,
)
)
for created_record in created_records:
module = Module.objects.filter(
pk=created_record.get("fields").get("module")
).first()
issue_activities.append(
IssueActivity(
issue_id=created_record.get("fields").get("issue"),
actor=actor,
verb="created",
old_value="",
new_value=module.name,
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added module {module.name}",
new_identifier=module.id,
)
)
def create_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created the issue",
verb="created",
actor=actor,
)
)
def update_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
ISSUE_ACTIVITY_MAPPER = {
"name": track_name,
"parent": track_parent,
"priority": track_priority,
"state": track_state,
"description": track_description,
"target_date": track_target_date,
"start_date": track_start_date,
"labels_list": track_labels,
"assignees_list": track_assignees,
"blocks_list": track_blocks,
"blockers_list": track_blockings,
"cycles_list": track_cycles,
"modules_list": track_modules,
}
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
if func is not None:
func(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
)
def create_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created a comment",
verb="created",
actor=actor,
field="comment",
new_value=requested_data.get("comment_html"),
new_identifier=requested_data.get("id"),
issue_comment_id=requested_data.get("id", None),
)
)
def update_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
if current_instance.get("comment_html") != requested_data.get("comment_html"):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated a comment",
verb="updated",
actor=actor,
field="comment",
old_value=current_instance.get("comment_html"),
old_identifier=current_instance.get("id"),
new_value=requested_data.get("comment_html"),
new_identifier=current_instance.get("id"),
issue_comment_id=current_instance.get("id"),
)
)
def delete_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
issue_activities.append(
IssueActivity(
project=project,
workspace=project.workspace,
comment=f"{actor.email} deleted the issue",
verb="deleted",
actor=actor,
field="issue",
)
)
def delete_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} deleted the comment",
verb="deleted",
actor=actor,
field="comment",
)
)
# Receive message from room group
@job("default")
def issue_activity(event):
try:
issue_activities = []
type = event.get("type")
requested_data = json.loads(event.get("requested_data"))
current_instance = json.loads(event.get("current_instance"))
issue_id = event.get("issue_id")
current_instance = (
json.loads(event.get("current_instance"))
if event.get("current_instance") is not None
else None
)
issue_id = event.get("issue_id", None)
actor_id = event.get("actor_id")
project_id = event.get("project_id")
@ -518,35 +756,43 @@ def issue_activity(event):
project = Project.objects.get(pk=project_id)
ISSUE_ACTIVITY_MAPPER = {
"name": track_name,
"parent": track_parent,
"priority": track_priority,
"state": track_state,
"description": track_description,
"target_date": track_target_date,
"start_date": track_start_date,
"labels_list": track_labels,
"assignees_list": track_assignees,
"blocks_list": track_blocks,
"blockers_list": track_blockings,
ACTIVITY_MAPPER = {
"issue.activity.created": create_issue_activity,
"issue.activity.updated": update_issue_activity,
"issue.activity.deleted": delete_issue_activity,
"comment.activity.created": create_comment_activity,
"comment.activity.updated": update_comment_activity,
"comment.activity.deleted": delete_comment_activity,
}
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
if func is not None:
func(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
)
func = ACTIVITY_MAPPER.get(type)
if func is not None:
func(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
)
# Save all the values to database
_ = IssueActivity.objects.bulk_create(issue_activities)
issue_activities_created = IssueActivity.objects.bulk_create(issue_activities)
# Post the updates to segway for integrations and webhooks
if len(issue_activities_created):
# Don't send activities if the actor is a bot
if settings.PROXY_BASE_URL:
for issue_activity in issue_activities_created:
headers = {"Content-Type": "application/json"}
issue_activity_json = json.dumps(
IssueActivitySerializer(issue_activity).data,
cls=DjangoJSONEncoder,
)
_ = requests.post(
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/",
json=issue_activity_json,
headers=headers,
)
return
except Exception as e:
capture_exception(e)

View File

@ -0,0 +1,69 @@
# Generated by Django 3.2.16 on 2023-02-13 19:48
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('db', '0019_auto_20230131_0049'),
]
operations = [
migrations.RenameField(
model_name='label',
old_name='colour',
new_name='color',
),
migrations.AddField(
model_name='apitoken',
name='workspace',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to='db.workspace'),
),
migrations.AddField(
model_name='issue',
name='completed_at',
field=models.DateTimeField(null=True),
),
migrations.AddField(
model_name='issue',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name='project',
name='cycle_view',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='project',
name='module_view',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='state',
name='default',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='issue',
name='description',
field=models.JSONField(blank=True, default=dict),
),
migrations.AlterField(
model_name='issue',
name='description_html',
field=models.TextField(blank=True, default='<p></p>'),
),
migrations.AlterField(
model_name='issuecomment',
name='comment_html',
field=models.TextField(blank=True, default='<p></p>'),
),
migrations.AlterField(
model_name='issuecomment',
name='comment_json',
field=models.JSONField(blank=True, default=dict),
),
]

View File

@ -0,0 +1,185 @@
# Generated by Django 3.2.16 on 2023-02-22 19:34
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0020_auto_20230214_0118'),
]
operations = [
migrations.CreateModel(
name='GithubRepository',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=500)),
('url', models.URLField(null=True)),
('config', models.JSONField(default=dict)),
('repository_id', models.BigIntegerField()),
('owner', models.CharField(max_length=500)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepository_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubrepository', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepository_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_githubrepository', to='db.workspace')),
],
options={
'verbose_name': 'Repository',
'verbose_name_plural': 'Repositories',
'db_table': 'github_repositories',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='Integration',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('title', models.CharField(max_length=400)),
('provider', models.CharField(max_length=400, unique=True)),
('network', models.PositiveIntegerField(choices=[(1, 'Private'), (2, 'Public')], default=1)),
('description', models.JSONField(default=dict)),
('author', models.CharField(blank=True, max_length=400)),
('webhook_url', models.TextField(blank=True)),
('webhook_secret', models.TextField(blank=True)),
('redirect_url', models.TextField(blank=True)),
('metadata', models.JSONField(default=dict)),
('verified', models.BooleanField(default=False)),
('avatar_url', models.URLField(blank=True, null=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='integration_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='integration_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
],
options={
'verbose_name': 'Integration',
'verbose_name_plural': 'Integrations',
'db_table': 'integrations',
'ordering': ('-created_at',),
},
),
migrations.AlterField(
model_name='issueactivity',
name='issue',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_activity', to='db.issue'),
),
migrations.CreateModel(
name='WorkspaceIntegration',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('metadata', models.JSONField(default=dict)),
('config', models.JSONField(default=dict)),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integrations', to=settings.AUTH_USER_MODEL)),
('api_token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integrations', to='db.apitoken')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspaceintegration_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integrated_workspaces', to='db.integration')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspaceintegration_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_integrations', to='db.workspace')),
],
options={
'verbose_name': 'Workspace Integration',
'verbose_name_plural': 'Workspace Integrations',
'db_table': 'workspace_integrations',
'ordering': ('-created_at',),
'unique_together': {('workspace', 'integration')},
},
),
migrations.CreateModel(
name='IssueLink',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('title', models.CharField(max_length=255, null=True)),
('url', models.URLField()),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuelink_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_link', to='db.issue')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuelink', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuelink_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issuelink', to='db.workspace')),
],
options={
'verbose_name': 'Issue Link',
'verbose_name_plural': 'Issue Links',
'db_table': 'issue_links',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='GithubRepositorySync',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('credentials', models.JSONField(default=dict)),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_syncs', to=settings.AUTH_USER_MODEL)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepositorysync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('label', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='repo_syncs', to='db.label')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubrepositorysync', to='db.project')),
('repository', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='syncs', to='db.githubrepository')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepositorysync_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_githubrepositorysync', to='db.workspace')),
('workspace_integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='github_syncs', to='db.workspaceintegration')),
],
options={
'verbose_name': 'Github Repository Sync',
'verbose_name_plural': 'Github Repository Syncs',
'db_table': 'github_repository_syncs',
'ordering': ('-created_at',),
'unique_together': {('project', 'repository')},
},
),
migrations.CreateModel(
name='GithubIssueSync',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('repo_issue_id', models.BigIntegerField()),
('github_issue_id', models.BigIntegerField()),
('issue_url', models.URLField()),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubissuesync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='github_syncs', to='db.issue')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubissuesync', to='db.project')),
('repository_sync', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_syncs', to='db.githubrepositorysync')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubissuesync_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_githubissuesync', to='db.workspace')),
],
options={
'verbose_name': 'Github Issue Sync',
'verbose_name_plural': 'Github Issue Syncs',
'db_table': 'github_issue_syncs',
'ordering': ('-created_at',),
'unique_together': {('repository_sync', 'issue')},
},
),
migrations.CreateModel(
name='GithubCommentSync',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('repo_comment_id', models.BigIntegerField()),
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_syncs', to='db.issuecomment')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubcommentsync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue_sync', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_syncs', to='db.githubissuesync')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubcommentsync', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubcommentsync_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_githubcommentsync', to='db.workspace')),
],
options={
'verbose_name': 'Github Comment Sync',
'verbose_name_plural': 'Github Comment Syncs',
'db_table': 'github_comment_syncs',
'ordering': ('-created_at',),
'unique_together': {('issue_sync', 'comment')},
},
),
]

View File

@ -1,3 +1,7 @@
# Python imports
import uuid
# Django imports
from django.db import models

View File

@ -10,7 +10,13 @@ from .workspace import (
TeamMember,
)
from .project import Project, ProjectMember, ProjectBaseModel, ProjectMemberInvite, ProjectIdentifier
from .project import (
Project,
ProjectMember,
ProjectBaseModel,
ProjectMemberInvite,
ProjectIdentifier,
)
from .issue import (
Issue,
@ -23,6 +29,7 @@ from .issue import (
IssueAssignee,
Label,
IssueBlocker,
IssueLink,
)
from .asset import FileAsset
@ -37,6 +44,15 @@ from .shortcut import Shortcut
from .view import View
from .module import Module, ModuleMember, ModuleIssue, ModuleLink
from .module import Module, ModuleMember, ModuleIssue, ModuleLink
from .api_token import APIToken
from .api_token import APIToken
from .integration import (
WorkspaceIntegration,
Integration,
GithubRepository,
GithubRepositorySync,
GithubIssueSync,
GithubCommentSync,
)

View File

@ -17,7 +17,6 @@ def generate_token():
class APIToken(BaseModel):
token = models.CharField(max_length=255, unique=True, default=generate_token)
label = models.CharField(max_length=255, default=generate_label_token)
user = models.ForeignKey(
@ -28,6 +27,9 @@ class APIToken(BaseModel):
user_type = models.PositiveSmallIntegerField(
choices=((0, "Human"), (1, "Bot")), default=0
)
workspace = models.ForeignKey(
"db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True
)
class Meta:
verbose_name = "API Token"

View File

@ -0,0 +1,2 @@
from .base import Integration, WorkspaceIntegration
from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync

View File

@ -0,0 +1,68 @@
# Python imports
import uuid
# Django imports
from django.db import models
# Module imports
from plane.db.models import BaseModel
from plane.db.mixins import AuditModel
class Integration(AuditModel):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
)
title = models.CharField(max_length=400)
provider = models.CharField(max_length=400, unique=True)
network = models.PositiveIntegerField(
default=1, choices=((1, "Private"), (2, "Public"))
)
description = models.JSONField(default=dict)
author = models.CharField(max_length=400, blank=True)
webhook_url = models.TextField(blank=True)
webhook_secret = models.TextField(blank=True)
redirect_url = models.TextField(blank=True)
metadata = models.JSONField(default=dict)
verified = models.BooleanField(default=False)
avatar_url = models.URLField(blank=True, null=True)
def __str__(self):
"""Return provider of the integration"""
return f"{self.provider}"
class Meta:
verbose_name = "Integration"
verbose_name_plural = "Integrations"
db_table = "integrations"
ordering = ("-created_at",)
class WorkspaceIntegration(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", related_name="workspace_integrations", on_delete=models.CASCADE
)
# Bot user
actor = models.ForeignKey(
"db.User", related_name="integrations", on_delete=models.CASCADE
)
integration = models.ForeignKey(
"db.Integration", related_name="integrated_workspaces", on_delete=models.CASCADE
)
api_token = models.ForeignKey(
"db.APIToken", related_name="integrations", on_delete=models.CASCADE
)
metadata = models.JSONField(default=dict)
config = models.JSONField(default=dict)
def __str__(self):
"""Return name of the integration and workspace"""
return f"{self.workspace.name} <{self.integration.provider}>"
class Meta:
unique_together = ["workspace", "integration"]
verbose_name = "Workspace Integration"
verbose_name_plural = "Workspace Integrations"
db_table = "workspace_integrations"
ordering = ("-created_at",)

View File

@ -0,0 +1,99 @@
# Python imports
import uuid
# Django imports
from django.db import models
# Module imports
from plane.db.models import ProjectBaseModel
from plane.db.mixins import AuditModel
class GithubRepository(ProjectBaseModel):
name = models.CharField(max_length=500)
url = models.URLField(null=True)
config = models.JSONField(default=dict)
repository_id = models.BigIntegerField()
owner = models.CharField(max_length=500)
def __str__(self):
"""Return the repo name"""
return f"{self.name}"
class Meta:
verbose_name = "Repository"
verbose_name_plural = "Repositories"
db_table = "github_repositories"
ordering = ("-created_at",)
class GithubRepositorySync(ProjectBaseModel):
repository = models.OneToOneField(
"db.GithubRepository", on_delete=models.CASCADE, related_name="syncs"
)
credentials = models.JSONField(default=dict)
# Bot user
actor = models.ForeignKey(
"db.User", related_name="user_syncs", on_delete=models.CASCADE
)
workspace_integration = models.ForeignKey(
"db.WorkspaceIntegration", related_name="github_syncs", on_delete=models.CASCADE
)
label = models.ForeignKey(
"db.Label", on_delete=models.SET_NULL, null=True, related_name="repo_syncs"
)
def __str__(self):
"""Return the repo sync"""
return f"{self.repository.name} <{self.project.name}>"
class Meta:
unique_together = ["project", "repository"]
verbose_name = "Github Repository Sync"
verbose_name_plural = "Github Repository Syncs"
db_table = "github_repository_syncs"
ordering = ("-created_at",)
class GithubIssueSync(ProjectBaseModel):
repo_issue_id = models.BigIntegerField()
github_issue_id = models.BigIntegerField()
issue_url = models.URLField(blank=False)
issue = models.ForeignKey(
"db.Issue", related_name="github_syncs", on_delete=models.CASCADE
)
repository_sync = models.ForeignKey(
"db.GithubRepositorySync", related_name="issue_syncs", on_delete=models.CASCADE
)
def __str__(self):
"""Return the github issue sync"""
return f"{self.repository.name}-{self.project.name}-{self.issue.name}"
class Meta:
unique_together = ["repository_sync", "issue"]
verbose_name = "Github Issue Sync"
verbose_name_plural = "Github Issue Syncs"
db_table = "github_issue_syncs"
ordering = ("-created_at",)
class GithubCommentSync(ProjectBaseModel):
repo_comment_id = models.BigIntegerField()
comment = models.ForeignKey(
"db.IssueComment", related_name="comment_syncs", on_delete=models.CASCADE
)
issue_sync = models.ForeignKey(
"db.GithubIssueSync", related_name="comment_syncs", on_delete=models.CASCADE
)
def __str__(self):
"""Return the github issue sync"""
return f"{self.comment.id}"
class Meta:
unique_together = ["issue_sync", "comment"]
verbose_name = "Github Comment Sync"
verbose_name_plural = "Github Comment Syncs"
db_table = "github_comment_syncs"
ordering = ("-created_at",)

View File

@ -4,11 +4,13 @@ from django.db import models
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
# Module imports
from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags
# TODO: Handle identifiers for Bulk Inserts - nk
class Issue(ProjectBaseModel):
PRIORITY_CHOICES = (
@ -32,8 +34,8 @@ class Issue(ProjectBaseModel):
related_name="state_issue",
)
name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, null=True)
description_html = models.TextField(blank=True, null=True)
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
priority = models.CharField(
max_length=30,
@ -56,6 +58,8 @@ class Issue(ProjectBaseModel):
labels = models.ManyToManyField(
"db.Label", blank=True, related_name="labels", through="IssueLabel"
)
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
class Meta:
verbose_name = "Issue"
@ -65,6 +69,36 @@ class Issue(ProjectBaseModel):
def save(self, *args, **kwargs):
# This means that the model isn't saved to the database yet
if self.state is None:
try:
from plane.db.models import State
default_state = State.objects.filter(
project=self.project, default=True
).first()
# if there is no default state assign any random state
if default_state is None:
self.state = State.objects.filter(project=self.project).first()
else:
self.state = default_state
except ImportError:
pass
else:
try:
from plane.db.models import State
# Get the completed states of the project
completed_states = State.objects.filter(
group="completed", project=self.project
).values_list("pk", flat=True)
# Check if the current issue state and completed state id are same
if self.state.id in completed_states:
self.completed_at = timezone.now()
else:
self.completed_at = None
except ImportError:
pass
if self._state.adding:
# Get the maximum display_id value from the database
@ -75,15 +109,12 @@ class Issue(ProjectBaseModel):
# If it isn't none, just use the last ID specified (which should be the greatest) and add one to it
if last_id is not None:
self.sequence_id = last_id + 1
if self.state is None:
try:
from plane.db.models import State
self.state, created = State.objects.get_or_create(
project=self.project, name="Backlog"
)
except ImportError:
pass
largest_sort_order = Issue.objects.filter(
project=self.project, state=self.state
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
# Strip the html tags using html parser
self.description_stripped = (
@ -137,9 +168,26 @@ class IssueAssignee(ProjectBaseModel):
return f"{self.issue.name} {self.assignee.email}"
class IssueLink(ProjectBaseModel):
title = models.CharField(max_length=255, null=True)
url = models.URLField()
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="issue_link"
)
class Meta:
verbose_name = "Issue Link"
verbose_name_plural = "Issue Links"
db_table = "issue_links"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.url}"
class IssueActivity(ProjectBaseModel):
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_activity"
Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity"
)
verb = models.CharField(max_length=255, verbose_name="Action", default="created")
field = models.CharField(
@ -196,8 +244,8 @@ class TimelineIssue(ProjectBaseModel):
class IssueComment(ProjectBaseModel):
comment_stripped = models.TextField(verbose_name="Comment", blank=True)
comment_json = models.JSONField(blank=True, null=True)
comment_html = models.TextField(blank=True)
comment_json = models.JSONField(blank=True, default=dict)
comment_html = models.TextField(blank=True, default="<p></p>")
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
issue = models.ForeignKey(Issue, on_delete=models.CASCADE)
# System can also create comment
@ -246,7 +294,6 @@ class IssueProperty(ProjectBaseModel):
class Label(ProjectBaseModel):
parent = models.ForeignKey(
"self",
on_delete=models.CASCADE,
@ -256,7 +303,7 @@ class Label(ProjectBaseModel):
)
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
colour = models.CharField(max_length=255, blank=True)
color = models.CharField(max_length=255, blank=True)
class Meta:
verbose_name = "Label"
@ -269,7 +316,6 @@ class Label(ProjectBaseModel):
class IssueLabel(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="label_issue"
)
@ -288,7 +334,6 @@ class IssueLabel(ProjectBaseModel):
class IssueSequence(ProjectBaseModel):
issue = models.ForeignKey(
Issue, on_delete=models.SET_NULL, related_name="issue_sequence", null=True
)
@ -305,7 +350,6 @@ class IssueSequence(ProjectBaseModel):
# TODO: Find a better method to save the model
@receiver(post_save, sender=Issue)
def create_issue_sequence(sender, instance, created, **kwargs):
if created:
IssueSequence.objects.create(
issue=instance, sequence=instance.sequence_id, project=instance.project

View File

@ -29,7 +29,6 @@ def get_default_props():
class Project(BaseModel):
NETWORK_CHOICES = ((0, "Secret"), (2, "Public"))
name = models.CharField(max_length=255, verbose_name="Project Name")
description = models.TextField(verbose_name="Project Description", blank=True)
@ -63,6 +62,8 @@ class Project(BaseModel):
blank=True,
)
icon = models.CharField(max_length=255, null=True, blank=True)
module_view = models.BooleanField(default=True)
cycle_view = models.BooleanField(default=True)
def __str__(self):
"""Return name of the project"""
@ -82,7 +83,6 @@ class Project(BaseModel):
class ProjectBaseModel(BaseModel):
project = models.ForeignKey(
Project, on_delete=models.CASCADE, related_name="project_%(class)s"
)
@ -117,7 +117,6 @@ class ProjectMemberInvite(ProjectBaseModel):
class ProjectMember(ProjectBaseModel):
member = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
@ -141,9 +140,9 @@ class ProjectMember(ProjectBaseModel):
"""Return members of the project"""
return f"{self.member.email} <{self.project.name}>"
# TODO: Remove workspace relation later
class ProjectIdentifier(AuditModel):
workspace = models.ForeignKey(
"db.Workspace", models.CASCADE, related_name="project_identifiers", null=True
)

View File

@ -23,6 +23,7 @@ class State(ProjectBaseModel):
default="backlog",
max_length=20,
)
default = models.BooleanField(default=False)
def __str__(self):
"""Return name of the state"""
@ -37,4 +38,13 @@ class State(ProjectBaseModel):
def save(self, *args, **kwargs):
self.slug = slugify(self.name)
if self._state.adding:
# Get the maximum sequence value from the database
last_id = State.objects.filter(project=self.project).aggregate(
largest=models.Max("sequence")
)["largest"]
# if last_id is not None
if last_id is not None:
self.sequence = last_id + 15000
return super().save(*args, **kwargs)

View File

@ -1,12 +1,13 @@
import os
import datetime
from datetime import timedelta
from django.core.management.utils import get_random_secret_key
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECRET_KEY = os.environ.get("SECRET_KEY")
SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

View File

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

View File

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

View File

@ -185,3 +185,5 @@ RQ_QUEUES = {
WEB_URL = os.environ.get("WEB_URL")
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)

View File

@ -0,0 +1,31 @@
def group_results(results_data, group_by):
"""
Utility function to group data into a given attribute.
Function can group attributes of string and list type.
"""
response_dict = dict()
for value in results_data:
group_attribute = value.get(group_by, None)
if isinstance(group_attribute, list):
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in response_dict:
response_dict[str(attrib)].append(value)
else:
response_dict[str(attrib)] = []
response_dict[str(attrib)].append(value)
else:
if str(None) in response_dict:
response_dict[str(None)].append(value)
else:
response_dict[str(None)] = []
response_dict[str(None)].append(value)
else:
if str(group_attribute) in response_dict:
response_dict[str(group_attribute)].append(value)
else:
response_dict[str(group_attribute)] = []
response_dict[str(group_attribute)].append(value)
return response_dict

View File

@ -0,0 +1,74 @@
import os
import jwt
import requests
from datetime import datetime, timedelta
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.backends import default_backend
def get_jwt_token():
app_id = os.environ.get("GITHUB_APP_ID", "")
secret = bytes(os.environ.get("GITHUB_APP_PRIVATE_KEY", ""), encoding="utf8")
current_timestamp = int(datetime.now().timestamp())
due_date = datetime.now() + timedelta(minutes=10)
expiry = int(due_date.timestamp())
payload = {
"iss": app_id,
"sub": app_id,
"exp": expiry,
"iat": current_timestamp,
"aud": "https://github.com/login/oauth/access_token",
}
priv_rsakey = load_pem_private_key(secret, None, default_backend())
token = jwt.encode(payload, priv_rsakey, algorithm="RS256")
return token
def get_github_metadata(installation_id):
token = get_jwt_token()
url = f"https://api.github.com/app/installations/{installation_id}"
headers = {
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
}
response = requests.get(url, headers=headers).json()
return response
def get_github_repos(access_tokens_url, repositories_url):
token = get_jwt_token()
headers = {
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
}
oauth_response = requests.post(
access_tokens_url,
headers=headers,
).json()
oauth_token = oauth_response.get("token")
headers = {
"Authorization": "Bearer " + oauth_token,
"Accept": "application/vnd.github+json",
}
response = requests.get(
repositories_url,
headers=headers,
).json()
return response
def delete_github_installation(installation_id):
token = get_jwt_token()
url = f"https://api.github.com/app/installations/{installation_id}"
headers = {
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
}
response = requests.delete(url, headers=headers)
return response

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,8 +6,16 @@
"website": "https://plane.so/",
"success_url": "/",
"stack": "heroku-22",
"keywords": ["plane", "project management", "django", "next"],
"addons": ["heroku-postgresql:mini", "heroku-redis:mini"],
"keywords": [
"plane",
"project management",
"django",
"next"
],
"addons": [
"heroku-postgresql:mini",
"heroku-redis:mini"
],
"buildpacks": [
{
"url": "https://github.com/heroku/heroku-buildpack-python.git"
@ -74,4 +82,4 @@
"value": ""
}
}
}
}

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

@ -0,0 +1,7 @@
NEXT_PUBLIC_API_BASE_URL = "http://localhost"
NEXT_PUBLIC_GOOGLE_CLIENTID="<-- google client id -->"
NEXT_PUBLIC_GITHUB_APP_NAME="<-- github app name -->"
NEXT_PUBLIC_GITHUB_ID="<-- github client id -->"
NEXT_PUBLIC_SENTRY_DSN="<-- sentry dns -->"
NEXT_PUBLIC_ENABLE_OAUTH=0
NEXT_PUBLIC_ENABLE_SENTRY=0

View File

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

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

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

View File

@ -4,33 +4,14 @@ RUN apk update
# Set working directory
WORKDIR /app
RUN apk add curl
RUN yarn global add turbo
COPY . .
RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm;
ENV PNPM_HOME="pnpm"
ENV PATH="${PATH}:./pnpm"
COPY ./apps ./apps
COPY ./package.json ./package.json
COPY ./.eslintrc.js ./.eslintrc.js
COPY ./turbo.json ./turbo.json
COPY ./pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY ./pnpm-lock.yaml ./pnpm-lock.yaml
RUN pnpm add -g turbo
RUN turbo prune --scope=app --docker
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
RUN apk add curl
RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm;
ENV PNPM_HOME="pnpm"
ENV PATH="${PATH}:./pnpm"
RUN apk add --no-cache libc6-compat
RUN apk update
@ -39,14 +20,14 @@ WORKDIR /app
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN pnpm install
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install
# Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
RUN pnpm turbo run build --filter=app...
RUN yarn turbo run build --filter=app
FROM node:18-alpine AS runner
WORKDIR /app
@ -62,8 +43,9 @@ COPY --from=installer /app/apps/app/package.json .
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
# COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone/node_modules ./apps/app/node_modules
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
EXPOSE 3000
ENV NEXT_TELEMETRY_DISABLED 1
CMD node apps/app/server.js
EXPOSE 3000

View File

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

View File

@ -34,7 +34,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
return (
<Link
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}`}
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
>
<button className="flex w-full items-center rounded bg-black px-3 py-2 text-sm text-white opacity-90 duration-300 hover:opacity-100">
<Image

View File

@ -15,7 +15,7 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
<>
<div className="flex items-center">
<div
className="grid h-8 w-8 cursor-pointer place-items-center rounded border border-gray-300 text-center text-sm hover:bg-gray-100"
className="grid h-8 w-8 cursor-pointer place-items-center flex-shrink-0 rounded border border-gray-300 text-center text-sm hover:bg-gray-100"
onClick={() => router.back()}
>
<ArrowLeftIcon className="h-3 w-3" />
@ -44,10 +44,10 @@ const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) =>
</a>
</Link>
) : (
<div className="px-3 text-sm">
<div className="px-3 text-sm max-w-64">
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
{icon}
{title}
<span className="break-all">{title}</span>
</p>
</div>
)}

View File

@ -1,41 +1,41 @@
// TODO: Refactor this component: into a different file, use this file to export the components
import React, { useState, useCallback, useEffect } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// hooks
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import userService from "services/user.service";
// hooks
import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// components
import { ShortcutsModal } from "components/command-palette";
import { BulkDeleteIssuesModal } from "components/core";
import { CreateProjectModal } from "components/project";
import { CreateUpdateIssueModal } from "components/issues";
import { CreateUpdateCycleModal } from "components/cycles";
import { CreateUpdateModuleModal } from "components/modules";
// ui
import { Button } from "components/ui";
// icons
import {
FolderIcon,
RectangleStackIcon,
ClipboardDocumentListIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/24/outline";
import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// services
import userService from "services/user.service";
// components
import ShortcutsModal from "components/command-palette/shortcuts";
import { CreateProjectModal } from "components/project";
import { CreateUpdateIssueModal } from "components/issues/modal";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
import CreateUpdateModuleModal from "components/project/modules/create-update-module-modal";
import BulkDeleteIssuesModal from "components/common/bulk-delete-issues-modal";
// headless ui
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IIssue } from "types";
// ui
import { Button } from "components/ui";
// icons
// fetch-keys
import { USER_ISSUE } from "constants/fetch-keys";
const CommandPalette: React.FC = () => {
export const CommandPalette: React.FC = () => {
const [query, setQuery] = useState("");
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
@ -74,7 +74,7 @@ const CommandPalette: React.FC = () => {
name: "Add new issue...",
icon: RectangleStackIcon,
hide: !projectId,
shortcut: "I",
shortcut: "C",
onClick: () => {
setIsIssueModalOpen(true);
},
@ -102,16 +102,15 @@ const CommandPalette: React.FC = () => {
!(e.target instanceof HTMLInputElement) &&
!(e.target as Element).classList?.contains("remirror-editor")
) {
if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setIsPaletteOpen(true);
} else if (e.ctrlKey && (e.key === "c" || e.key === "C")) {
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
if (e.altKey) {
e.preventDefault();
if (!router.query.issueId) return;
const url = new URL(window.location.href);
console.log(url);
copyTextToClipboard(url.href)
.then(() => {
setToastAlert({
@ -125,26 +124,23 @@ const CommandPalette: React.FC = () => {
title: "Some error occurred",
});
});
console.log("URL Copied");
} else {
console.log("Text copied");
}
} else if (e.key === "c" || e.key === "C") {
} else if (e.key.toLowerCase() === "c") {
e.preventDefault();
setIsIssueModalOpen(true);
} else if (e.key === "p" || e.key === "P") {
} else if (e.key.toLowerCase() === "p") {
e.preventDefault();
setIsProjectModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && (e.key === "b" || e.key === "B")) {
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") {
e.preventDefault();
toggleCollapsed();
} else if (e.key === "h" || e.key === "H") {
} else if (e.key.toLowerCase() === "h") {
e.preventDefault();
setIsShortcutsModalOpen(true);
} else if (e.key === "q" || e.key === "Q") {
} else if (e.key.toLowerCase() === "q") {
e.preventDefault();
setIsCreateCycleModalOpen(true);
} else if (e.key === "m" || e.key === "M") {
} else if (e.key.toLowerCase() === "m") {
e.preventDefault();
setIsCreateModuleModalOpen(true);
} else if (e.key === "Delete") {
@ -173,13 +169,11 @@ const CommandPalette: React.FC = () => {
<>
<CreateUpdateCycleModal
isOpen={isCreateCycleModalOpen}
setIsOpen={setIsCreateCycleModalOpen}
projectId={projectId as string}
handleClose={() => setIsCreateCycleModalOpen(false)}
/>
<CreateUpdateModuleModal
isOpen={isCreateModuleModalOpen}
setIsOpen={setIsCreateModuleModalOpen}
projectId={projectId as string}
/>
</>
)}
@ -330,7 +324,6 @@ const CommandPalette: React.FC = () => {
/>
<span className="ml-3 flex-auto truncate">{action.name}</span>
<span className="ml-3 flex-none text-xs font-semibold text-gray-500">
<kbd className="font-sans"></kbd>
<kbd className="font-sans">{action.shortcut}</kbd>
</span>
</>
@ -371,5 +364,3 @@ const CommandPalette: React.FC = () => {
</>
);
};
export default CommandPalette;

View File

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

View File

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

View File

@ -1,109 +0,0 @@
import React from "react";
// react-beautiful-dnd
import { DraggableProvided } from "react-beautiful-dnd";
// icons
import {
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
EllipsisHorizontalIcon,
PlusIcon,
} from "@heroicons/react/24/outline";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, NestedKeyOf } from "types";
type Props = {
isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
createdBy: string | null;
bgColor: string;
addIssueToState: () => void;
provided?: DraggableProvided;
};
const BoardHeader: React.FC<Props> = ({
isCollapsed,
setIsCollapsed,
provided,
groupedByIssues,
selectedGroup,
groupTitle,
createdBy,
bgColor,
addIssueToState,
}) => (
<div
className={`flex justify-between p-3 pb-0 ${
!isCollapsed ? "flex-col rounded-md border bg-gray-50" : ""
}`}
>
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
{provided && (
<button
type="button"
{...provided.dragHandleProps}
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
!isCollapsed ? "" : "rotate-90"
} ${selectedGroup !== "state_detail.name" ? "hidden" : ""}`}
>
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600" />
<EllipsisHorizontalIcon className="mt-[-0.7rem] h-4 w-4 text-gray-600" />
</button>
)}
<div
className={`flex cursor-pointer items-center gap-x-1 rounded-md bg-slate-900 px-2 ${
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
}`}
style={{
border: `2px solid ${bgColor}`,
backgroundColor: `${bgColor}20`,
}}
>
<h2
className={`text-[0.9rem] font-medium capitalize`}
style={{
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
}}
>
{groupTitle === null || groupTitle === "null"
? "None"
: createdBy
? createdBy
: 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>
);
export default BoardHeader;

View File

@ -1,3 +0,0 @@
const SingleBoard = () => <></>;
export default SingleBoard;

View File

@ -1,464 +0,0 @@
import React from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-beautiful-dnd
import { DraggableStateSnapshot } from "react-beautiful-dnd";
// react-datepicker
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// constants
import { TrashIcon } from "@heroicons/react/24/outline";
// services
import issuesService from "services/issues.service";
import stateService from "services/state.service";
import projectService from "services/project.service";
// components
import { AssigneesList, CustomDatePicker } from "components/ui";
// helpers
import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import {
CycleIssueResponse,
IIssue,
IssueResponse,
IUserLite,
IWorkspaceMember,
ModuleIssueResponse,
Properties,
UserAuth,
} from "types";
// common
import { PRIORITIES } from "constants/";
import {
STATE_LIST,
PROJECT_DETAILS,
CYCLE_ISSUES,
MODULE_ISSUES,
PROJECT_ISSUES_LIST,
} from "constants/fetch-keys";
import { getPriorityIcon } from "constants/global";
type Props = {
type?: string;
typeId?: string;
issue: IIssue;
properties: Properties;
snapshot?: DraggableStateSnapshot;
assignees: Partial<IUserLite>[] | (Partial<IUserLite> | undefined)[];
people: IWorkspaceMember[] | undefined;
handleDeleteIssue?: React.Dispatch<React.SetStateAction<string | undefined>>;
userAuth: UserAuth;
};
const SingleBoardIssue: React.FC<Props> = ({
type,
typeId,
issue,
properties,
snapshot,
assignees,
people,
handleDeleteIssue,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: states } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const { data: projectDetails } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const partialUpdateIssue = (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return;
if (typeId) {
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(typeId ?? ""),
(prevData) => {
const updatedIssues = (prevData ?? []).map((p) => {
if (p.issue_detail.id === issue.id) {
return {
...p,
issue_detail: {
...p.issue_detail,
...formData,
},
};
}
return p;
});
return [...updatedIssues];
},
false
);
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(typeId ?? ""),
(prevData) => {
const updatedIssues = (prevData ?? []).map((p) => {
if (p.issue_detail.id === issue.id) {
return {
...p,
issue_detail: {
...p.issue_detail,
...formData,
},
};
}
return p;
});
return [...updatedIssues];
},
false
);
}
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => ({
...(prevData as IssueResponse),
results: (prevData?.results ?? []).map((p) => {
if (p.id === issue.id) return { ...p, ...formData };
return p;
}),
}),
false
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
.then((res) => {
if (typeId) {
mutate(CYCLE_ISSUES(typeId ?? ""));
mutate(MODULE_ISSUES(typeId ?? ""));
}
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
})
.catch((error) => {
console.log(error);
});
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div
className={`rounded border bg-white shadow-sm ${
snapshot && snapshot.isDragging ? "border-theme bg-indigo-50 shadow-lg" : ""
}`}
>
<div className="group/card relative select-none p-2">
{handleDeleteIssue && !isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
<button
type="button"
className="grid h-7 w-7 place-items-center rounded bg-white p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
onClick={() => handleDeleteIssue(issue.id)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
)}
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a>
{properties.key && (
<div className="mb-2 text-xs font-medium text-gray-500">
{projectDetails?.identifier}-{issue.sequence_id}
</div>
)}
<h5
className="mb-3 text-sm group-hover:text-theme"
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
>
{issue.name}
</h5>
</a>
</Link>
<div className="flex flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<Listbox
as="div"
value={issue.priority}
onChange={(data: string) => {
partialUpdateIssue({ priority: data });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<>
<div>
<Listbox.Button
className={`grid ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} place-items-center rounded px-2 py-1 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{getPriorityIcon(issue?.priority ?? "None")}
</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-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{PRIORITIES?.map((priority) => (
<Listbox.Option
key={priority}
className={({ active }) =>
`flex cursor-pointer select-none items-center gap-2 px-3 py-2 capitalize ${
active ? "bg-indigo-50" : "bg-white"
}`
}
value={priority}
>
{getPriorityIcon(priority)}
{priority}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
{properties.state && (
<Listbox
as="div"
value={issue.state}
onChange={(data: string) => {
partialUpdateIssue({ state: data });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<>
<div>
<Listbox.Button
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} 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`}
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
{addSpaceIfCamelCase(issue.state_detail.name)}
</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-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{states?.map((state) => (
<Listbox.Option
key={state.id}
className={({ active }) =>
`flex cursor-pointer select-none items-center gap-2 px-3 py-2 ${
active ? "bg-indigo-50" : "bg-white"
}`
}
value={state.id}
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: state.color,
}}
/>
{addSpaceIfCamelCase(state.name)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
{/* {properties.cycle && !typeId && (
<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_cycle ? issue.issue_cycle.cycle_detail.name : "None"}
</div>
)} */}
{properties.due_date && (
<div
className={`group relative ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`}
>
<CustomDatePicker
placeholder="N/A"
value={issue?.target_date}
onChange={(val) =>
partialUpdateIssue({
target_date: val,
})
}
className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"}
/>
{/* <DatePicker
placeholderText="N/A"
value={
issue?.target_date ? `${renderShortNumericDateFormat(issue.target_date)}` : "N/A"
}
selected={issue?.target_date ? new Date(issue.target_date) : null}
onChange={(val: Date) => {
partialUpdateIssue({
target_date: val
? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
: null,
});
}}
dateFormat="dd-MM-yyyy"
className={`cursor-pointer rounded-md border px-2 py-[3px] 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?.target_date ? "w-[4.5rem]" : "w-[3rem] text-center"
}`}
isClearable
/> */}
</div>
)}
{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 && (
<Listbox
as="div"
value={issue.assignees}
onChange={(data: any) => {
const newData = issue.assignees ?? [];
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
else newData.push(data);
partialUpdateIssue({ assignees_list: newData });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<div>
<Listbox.Button>
<div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 text-xs`}
>
<AssigneesList users={assignees} length={3} />
</div>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute left-0 z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`cursor-pointer select-none p-2 ${active ? "bg-indigo-50" : "bg-white"}`
}
value={person.member.id}
>
<div
className={`flex items-center gap-x-1 ${
assignees.includes({
id: person.member.last_name,
first_name: person.member.first_name,
last_name: person.member.last_name,
email: person.member.email,
avatar: person.member.avatar,
})
? "font-medium"
: "font-normal"
}`}
>
{person.member.avatar && person.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<Image
src={person.member.avatar}
alt="avatar"
className="rounded-full"
layout="fill"
objectFit="cover"
priority={false}
loading="lazy"
/>
</div>
) : (
<div className="grid h-4 w-4 place-items-center rounded-full bg-gray-700 capitalize text-white">
{person.member.first_name && person.member.first_name !== ""
? person.member.first_name.charAt(0)
: person.member.email.charAt(0)}
</div>
)}
<p>
{person.member.first_name && person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</p>
</div>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
)}
</div>
</div>
</div>
);
};
export default SingleBoardIssue;

View File

@ -1,434 +0,0 @@
import React, { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// services
import issuesService from "services/issues.service";
import workspaceService from "services/workspace.service";
import stateService from "services/state.service";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// ui
import { CustomMenu, CustomSelect, AssigneesList, Avatar, CustomDatePicker } from "components/ui";
// components
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
// helpers
import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import {
CycleIssueResponse,
IIssue,
IssueResponse,
IWorkspaceMember,
ModuleIssueResponse,
Properties,
UserAuth,
} from "types";
// fetch-keys
import {
CYCLE_ISSUES,
MODULE_ISSUES,
PROJECT_ISSUES_LIST,
STATE_LIST,
WORKSPACE_MEMBERS,
} from "constants/fetch-keys";
// constants
import { getPriorityIcon } from "constants/global";
import { PRIORITIES } from "constants/";
type Props = {
type?: string;
typeId?: string;
issue: IIssue;
properties: Properties;
editIssue: () => void;
removeIssue?: () => void;
userAuth: UserAuth;
};
const SingleListIssue: React.FC<Props> = ({
type,
typeId,
issue,
properties,
editIssue,
removeIssue,
userAuth,
}) => {
const [deleteIssue, setDeleteIssue] = useState<IIssue | undefined>();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: states } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const { data: people } = useSWR<IWorkspaceMember[]>(
workspaceSlug ? WORKSPACE_MEMBERS : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
const partialUpdateIssue = (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return;
if (typeId) {
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(typeId ?? ""),
(prevData) => {
const updatedIssues = (prevData ?? []).map((p) => {
if (p.issue_detail.id === issue.id) {
return {
...p,
issue_detail: {
...p.issue_detail,
...formData,
},
};
}
return p;
});
return [...updatedIssues];
},
false
);
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(typeId ?? ""),
(prevData) => {
const updatedIssues = (prevData ?? []).map((p) => {
if (p.issue_detail.id === issue.id) {
return {
...p,
issue_detail: {
...p.issue_detail,
...formData,
},
};
}
return p;
});
return [...updatedIssues];
},
false
);
}
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => ({
...(prevData as IssueResponse),
results: (prevData?.results ?? []).map((p) => {
if (p.id === issue.id) return { ...p, ...formData };
return p;
}),
}),
false
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
.then((res) => {
if (typeId) {
mutate(CYCLE_ISSUES(typeId ?? ""));
mutate(MODULE_ISSUES(typeId ?? ""));
}
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
})
.catch((error) => {
console.log(error);
});
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<>
<ConfirmIssueDeletion
handleClose={() => setDeleteIssue(undefined)}
isOpen={!!deleteIssue}
data={deleteIssue}
/>
<div className="flex items-center justify-between gap-2 px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<span
className={`block h-1.5 w-1.5 flex-shrink-0 rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
<span>{issue.name}</span>
</a>
</Link>
</div>
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<Listbox
as="div"
value={issue.priority}
onChange={(data: string) => {
partialUpdateIssue({ priority: data });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<>
<div>
<Listbox.Button
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-x-2 rounded px-2 py-0.5 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{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 right-0 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">
{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>
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium text-gray-900">Priority</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div>
</>
)}
</Listbox>
)}
{properties.state && (
<CustomSelect
label={
<>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
{addSpaceIfCamelCase(issue.state_detail.name)}
</>
}
value={issue.state}
onChange={(data: string) => {
partialUpdateIssue({ state: data });
}}
maxHeight="md"
noChevron
disabled={isNotAllowed}
>
{states?.map((state) => (
<CustomSelect.Option key={state.id} value={state.id}>
<>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: state.color,
}}
/>
{addSpaceIfCamelCase(state.name)}
</>
</CustomSelect.Option>
))}
</CustomSelect>
)}
{/* {properties.cycle && !typeId && (
<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_cycle ? issue.issue_cycle.cycle_detail.name : "None"}
</div>
)} */}
{properties.due_date && (
<div
className={`group relative ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`}
>
<CustomDatePicker
placeholder="N/A"
value={issue?.target_date}
onChange={(val) =>
partialUpdateIssue({
target_date: val,
})
}
className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"}
/>
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium text-gray-900">Due date</h5>
<div>{renderShortNumericDateFormat(issue.target_date ?? "")}</div>
<div>
{issue.target_date
? issue.target_date < new Date().toISOString()
? `Due date has passed by ${findHowManyDaysLeft(issue.target_date)} days`
: findHowManyDaysLeft(issue.target_date) <= 3
? `Due date is in ${findHowManyDaysLeft(issue.target_date)} days`
: "Due date"
: "N/A"}
</div>
</div>
</div>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm">
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.assignee && (
<Listbox
as="div"
value={issue.assignees}
onChange={(data: any) => {
const newData = issue.assignees ?? [];
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
else newData.push(data);
partialUpdateIssue({ assignees_list: newData });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<>
<div>
<Listbox.Button>
<div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 text-xs`}
>
<AssigneesList userIds={issue.assignees ?? []} />
</div>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-48 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active, selected }) =>
`flex items-center gap-x-1 cursor-pointer select-none p-2 ${
active ? "bg-indigo-50" : ""
} ${
selected || issue.assignees?.includes(person.member.id)
? "bg-indigo-50 font-medium"
: "font-normal"
}`
}
value={person.member.id}
>
<Avatar user={person.member} />
<p>
{person.member.first_name && person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</p>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium">Assigned to</h5>
<div>
{issue.assignee_details?.length > 0
? issue.assignee_details.map((assignee) => assignee.first_name).join(", ")
: "No one"}
</div>
</div>
</>
)}
</Listbox>
)}
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>Edit</CustomMenu.MenuItem>
{type !== "issue" && (
<CustomMenu.MenuItem onClick={removeIssue}>
<>Remove from {type}</>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => setDeleteIssue(issue)}>
Delete permanently
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</div>
</>
);
};
export default SingleListIssue;

View File

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

View File

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

View File

@ -0,0 +1,4 @@
export * from "./all-boards";
export * from "./board-header";
export * from "./single-board";
export * from "./single-issue";

View File

@ -0,0 +1,183 @@
import { useState } from "react";
import { useRouter } from "next/router";
// react-beautiful-dnd
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
// components
import { BoardHeader, SingleBoardIssue } from "components/core";
// ui
import { CustomMenu } from "components/ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IProjectMember, NestedKeyOf, UserAuth } from "types";
type Props = {
type?: "issue" | "cycle" | "module";
bgColor?: string;
groupTitle: string;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: NestedKeyOf<IIssue> | null;
members: IProjectMember[] | undefined;
handleEditIssue: (issue: IIssue) => void;
addIssueToState: () => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
orderBy: NestedKeyOf<IIssue> | null;
handleTrashBox: (isDragging: boolean) => void;
removeIssue: ((bridgeId: string) => void) | null;
userAuth: UserAuth;
};
export const SingleBoard: React.FC<Props> = ({
type,
bgColor,
groupTitle,
groupedByIssues,
selectedGroup,
members,
handleEditIssue,
addIssueToState,
handleDeleteIssue,
openIssuesListModal,
orderBy,
handleTrashBox,
removeIssue,
userAuth,
}) => {
// collapse/expand
const [isCollapsed, setIsCollapsed] = useState(true);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<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"}`}>
<BoardHeader
addIssueToState={addIssueToState}
bgColor={bgColor}
selectedGroup={selectedGroup}
groupTitle={groupTitle}
groupedByIssues={groupedByIssues}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
members={members}
/>
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`relative mt-3 h-full px-3 pb-3 overflow-y-auto ${
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
} ${!isCollapsed ? "hidden" : "block"}`}
ref={provided.innerRef}
{...provided.droppableProps}
>
{orderBy !== "sort_order" && (
<>
<div
className={`absolute ${
snapshot.isDraggingOver ? "block" : "hidden"
} top-0 left-0 h-full w-full bg-indigo-200 opacity-50 pointer-events-none z-[99999998]`}
/>
<div
className={`absolute ${
snapshot.isDraggingOver ? "block" : "hidden"
} top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 text-xs whitespace-nowrap bg-white p-2 rounded pointer-events-none z-[99999999]`}
>
This board is ordered by {orderBy}
</div>
</>
)}
{groupedByIssues[groupTitle].map((issue, index: number) => (
<Draggable
key={issue.id}
draggableId={issue.id}
index={index}
isDragDisabled={
isNotAllowed || selectedGroup === "created_by" || selectedGroup === "assignees"
}
>
{(provided, snapshot) => (
<SingleBoardIssue
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
style={{
display: orderBy === "sort_order" ? "inline" : "none",
}}
>
{provided.placeholder}
</span>
{type === "issue" ? (
<button
type="button"
className="flex items-center rounded p-2 text-xs font-medium outline-none duration-300 hover:bg-gray-100"
onClick={addIssueToState}
>
<PlusIcon className="mr-1 h-3 w-3" />
Create
</button>
) : (
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
className="mt-1"
optionsPosition="left"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
)}
</StrictModeDroppable>
</div>
</div>
);
};

View File

@ -0,0 +1,289 @@
import React, { useCallback, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-beautiful-dnd
import {
DraggableProvided,
DraggableStateSnapshot,
DraggingStyle,
NotDraggingStyle,
} from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
// hooks
import useToast from "hooks/use-toast";
// components
import {
ViewAssigneeSelect,
ViewDueDateSelect,
ViewPrioritySelect,
ViewStateSelect,
} from "components/issues/view-select";
// ui
import { CustomMenu } from "components/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import {
CycleIssueResponse,
IIssue,
ModuleIssueResponse,
NestedKeyOf,
Properties,
UserAuth,
} from "types";
// fetch-keys
import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
type Props = {
type?: string;
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
issue: IIssue;
selectedGroup: NestedKeyOf<IIssue> | null;
properties: Properties;
editIssue: () => void;
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
orderBy: NestedKeyOf<IIssue> | null;
handleTrashBox: (isDragging: boolean) => void;
userAuth: UserAuth;
};
export const SingleBoardIssue: React.FC<Props> = ({
type,
provided,
snapshot,
issue,
selectedGroup,
properties,
editIssue,
removeIssue,
handleDeleteIssue,
orderBy,
handleTrashBox,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return;
if (cycleId)
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(cycleId as string),
(prevData) => {
const updatedIssues = (prevData ?? []).map((p) => {
if (p.issue_detail.id === issue.id) {
return {
...p,
issue_detail: {
...p.issue_detail,
...formData,
},
};
}
return p;
});
return [...updatedIssues];
},
false
);
if (moduleId)
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(moduleId as string),
(prevData) => {
const updatedIssues = (prevData ?? []).map((p) => {
if (p.issue_detail.id === issue.id) {
return {
...p,
issue_detail: {
...p.issue_detail,
...formData,
},
};
}
return p;
});
return [...updatedIssues];
},
false
);
mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === issue.id) return { ...p, ...formData };
return p;
}),
false
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
.then((res) => {
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
})
.catch((error) => {
console.log(error);
});
},
[workspaceSlug, projectId, cycleId, moduleId, issue]
);
function getStyle(
style: DraggingStyle | NotDraggingStyle | undefined,
snapshot: DraggableStateSnapshot
) {
if (orderBy === "sort_order") return style;
if (!snapshot.isDragging) return {};
if (!snapshot.isDropAnimating) {
return style;
}
return {
...style,
transitionDuration: `0.001s`,
};
}
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: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
useEffect(() => {
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
}, [snapshot, handleTrashBox]);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div
className={`rounded border bg-white shadow-sm mb-3 ${
snapshot.isDragging ? "border-theme bg-indigo-50 shadow-lg" : ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={getStyle(provided.draggableProps.style, snapshot)}
>
<div className="group/card relative select-none p-2">
{!isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
{type !== "issue" && removeIssue && (
<CustomMenu.MenuItem onClick={removeIssue}>
<>Remove from {type}</>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
Delete issue
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
)}
<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>
)}
<h5
className="mb-3 text-sm group-hover:text-theme"
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
>
{issue.name}
</h5>
</a>
</Link>
<div className="relative flex flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && selectedGroup !== "priority" && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
selfPositioned
/>
)}
{properties.state && selectedGroup !== "state_detail.name" && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
selfPositioned
/>
)}
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
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.labels && (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
key={label.id}
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</span>
))}
</div>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
tooltipPosition="left"
selfPositioned
/>
)}
</div>
</div>
</div>
);
};

View File

@ -1,27 +1,26 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR, { mutate } from "swr";
// react hook form
import { SubmitHandler, useForm } from "react-hook-form";
// services
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
import { FolderIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// services
import issuesServices from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useToast from "hooks/use-toast";
// headless ui
// ui
import { Button } from "components/ui";
// icons
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// types
import { IIssue, IssueResponse } from "types";
import { IIssue } from "types";
// fetch keys
import { PROJECT_ISSUES_LIST, PROJECT_DETAILS } from "constants/fetch-keys";
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
type FormInput = {
delete_issue_ids: string[];
@ -32,14 +31,11 @@ type Props = {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const [query, setQuery] = useState("");
const router = useRouter();
const {
query: { workspaceSlug, projectId },
} = router;
const { workspaceSlug, projectId } = router.query;
const { data: issues } = useSWR(
workspaceSlug && projectId
@ -50,13 +46,6 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
: null
);
const { data: projectDetails } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const { setToastAlert } = useToast();
const {
@ -73,8 +62,8 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const filteredIssues: IIssue[] =
query === ""
? issues?.results ?? []
: issues?.results.filter(
? issues ?? []
: issues?.filter(
(issue) =>
issue.name.toLowerCase().includes(query.toLowerCase()) ||
`${issue.project_detail.identifier}-${issue.sequence_id}`
@ -112,17 +101,9 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
message: res.message,
});
mutate<IssueResponse>(
mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => ({
...(prevData as IssueResponse),
count: (prevData?.results ?? []).filter(
(p) => !data.delete_issue_ids.some((id) => p.id === id)
).length,
results: (prevData?.results ?? []).filter(
(p) => !data.delete_issue_ids.some((id) => p.id === id)
),
}),
(prevData) => (prevData ?? []).filter((p) => !data.delete_issue_ids.includes(p.id)),
false
);
handleClose();
@ -156,12 +137,7 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
"delete_issue_ids",
selectedIssues.filter((i) => i !== val)
);
else {
const newToDelete = selectedIssues;
newToDelete.push(val);
setValue("delete_issue_ids", newToDelete);
}
else setValue("delete_issue_ids", [...selectedIssues, val]);
}}
>
<div className="relative m-1">
@ -213,7 +189,7 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{projectDetails?.identifier}-{issue.sequence_id}
{issue.project_detail.identifier}-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
@ -226,7 +202,7 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
<pre className="inline rounded bg-gray-200 px-2 py-1">C</pre>.
</h3>
</div>
)}
@ -256,5 +232,3 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
</Transition.Root>
);
};
export default BulkDeleteIssuesModal;

View File

@ -1,24 +1,17 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// hooks
import { Combobox, Dialog, Transition } from "@headlessui/react";
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
import useToast from "hooks/use-toast";
// services
import projectService from "services/project.service";
// headless ui
// ui
import { Button } from "components/ui";
import { LayerDiagonalIcon } from "components/icons";
// types
import { IIssue } from "types";
// fetch-keys
import { PROJECT_DETAILS } from "constants/fetch-keys";
type FormInput = {
issues: string[];
@ -27,30 +20,18 @@ type FormInput = {
type Props = {
isOpen: boolean;
handleClose: () => void;
type: string;
issues: IIssue[];
handleOnSubmit: any;
};
const ExistingIssuesListModal: React.FC<Props> = ({
export const ExistingIssuesListModal: React.FC<Props> = ({
isOpen,
handleClose: onClose,
issues,
handleOnSubmit,
type,
}) => {
const [query, setQuery] = useState("");
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: projectDetails } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const { setToastAlert } = useToast();
const handleClose = () => {
@ -122,7 +103,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
<form>
<Controller
control={control}
@ -149,7 +130,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select issues to add to {type}
Select issues to add
</h2>
)}
<ul className="text-sm text-gray-700">
@ -175,7 +156,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{projectDetails?.identifier}-{issue.sequence_id}
{issue.project_detail.identifier}-{issue.sequence_id}
</span>
{issue.name}
</>
@ -189,7 +170,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
<pre className="inline rounded bg-gray-200 px-2 py-1">C</pre>.
</h3>
</div>
)}
@ -220,7 +201,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting}
>
{isSubmitting ? "Adding..." : `Add to ${type}`}
{isSubmitting ? "Adding..." : "Add selected issues"}
</Button>
</div>
)}
@ -233,5 +214,3 @@ const ExistingIssuesListModal: React.FC<Props> = ({
</>
);
};
export default ExistingIssuesListModal;

View File

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

View File

@ -17,13 +17,13 @@ import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { IIssue, Properties } from "types";
// common
import { filterIssueOptions, groupByOptions, orderByOptions } from "constants/";
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
type Props = {
issues?: IIssue[];
};
const View: React.FC<Props> = ({ issues }) => {
export const IssuesFilterView: React.FC<Props> = ({ issues }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -99,36 +99,40 @@ const View: React.FC<Props> = ({ issues }) => {
<h4 className="text-sm text-gray-600">Group by</h4>
<CustomMenu
label={
groupByOptions.find((option) => option.key === groupByProperty)
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
?.name ?? "Select"
}
width="lg"
>
{groupByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
{GROUP_BY_OPTIONS.map((option) =>
issueView === "kanban" && option.key === null ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
<div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Order by</h4>
<CustomMenu
label={
orderByOptions.find((option) => option.key === orderBy)?.name ??
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
width="lg"
>
{orderByOptions.map((option) =>
{ORDER_BY_OPTIONS.map((option) =>
groupByProperty === "priority" &&
option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setOrderBy(option.key)}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
@ -140,12 +144,12 @@ const View: React.FC<Props> = ({ issues }) => {
<h4 className="text-sm text-gray-600">Issue type</h4>
<CustomMenu
label={
filterIssueOptions.find((option) => option.key === filterIssue)
FILTER_ISSUE_OPTIONS.find((option) => option.key === filterIssue)
?.name ?? "Select"
}
width="lg"
>
{filterIssueOptions.map((option) => (
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setFilterIssue(option.key)}
@ -176,20 +180,29 @@ const View: React.FC<Props> = ({ issues }) => {
<div className="space-y-2 py-3">
<h4 className="text-sm text-gray-600">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: "border-gray-300"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{replaceUnderscoreIfSnakeCase(key)}
</button>
))}
{Object.keys(properties).map((key) => {
if (
issueView === "kanban" &&
((groupByProperty === "state_detail.name" && key === "state") ||
(groupByProperty === "priority" && key === "priority"))
)
return;
return (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: "border-gray-300"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div>
</div>
</div>
@ -203,5 +216,3 @@ const View: React.FC<Props> = ({ issues }) => {
</>
);
};
export default View;

View File

@ -0,0 +1,429 @@
import { useCallback, useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
import stateService from "services/state.service";
import projectService from "services/project.service";
import modulesService from "services/modules.service";
// hooks
import useIssueView from "hooks/use-issue-view";
// components
import { AllLists, AllBoards } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// icons
import { TrashIcon } from "@heroicons/react/24/outline";
// helpers
import { getStatesList } from "helpers/state.helper";
// types
import { CycleIssueResponse, IIssue, ModuleIssueResponse, UserAuth } from "types";
// fetch-keys
import {
CYCLE_ISSUES,
MODULE_ISSUES,
PROJECT_ISSUES_LIST,
PROJECT_MEMBERS,
STATE_LIST,
} from "constants/fetch-keys";
type Props = {
type?: "issue" | "cycle" | "module";
issues: IIssue[];
openIssuesListModal?: () => void;
userAuth: UserAuth;
};
export const IssuesView: React.FC<Props> = ({
type = "issue",
issues,
openIssuesListModal,
userAuth,
}) => {
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined);
// updates issue modal
const [editIssueModal, setEditIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<
(IIssue & { actionType: "edit" | "delete" }) | undefined
>(undefined);
// delete issue modal
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [issueToDelete, setIssueToDelete] = useState<IIssue | null>(null);
// trash box
const [trashBox, setTrashBox] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const {
issueView,
groupedByIssues,
groupByProperty: selectedGroup,
orderBy,
} = useIssueView(issues);
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups ?? {});
const { data: members } = useSWR(
projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const handleDeleteIssue = useCallback(
(issue: IIssue) => {
setDeleteIssueModal(true);
setIssueToDelete(issue);
},
[setDeleteIssueModal, setIssueToDelete]
);
const handleOnDragEnd = useCallback(
(result: DropResult) => {
setTrashBox(false);
if (!result.destination || !workspaceSlug || !projectId) return;
const { source, destination } = result;
const draggedItem = groupedByIssues[source.droppableId][source.index];
if (destination.droppableId === "trashBox") {
handleDeleteIssue(draggedItem);
} else {
if (orderBy === "sort_order") {
let newSortOrder = draggedItem.sort_order;
const destinationGroupArray = groupedByIssues[destination.droppableId];
if (destinationGroupArray.length !== 0) {
// check if dropping in the same group
if (source.droppableId === destination.droppableId) {
// check if dropping at beginning
if (destination.index === 0)
newSortOrder = destinationGroupArray[0].sort_order - 10000;
// check if dropping at last
else if (destination.index === destinationGroupArray.length - 1)
newSortOrder =
destinationGroupArray[destinationGroupArray.length - 1].sort_order + 10000;
else {
if (destination.index > source.index)
newSortOrder =
(destinationGroupArray[source.index + 1].sort_order +
destinationGroupArray[source.index + 2].sort_order) /
2;
else if (destination.index < source.index)
newSortOrder =
(destinationGroupArray[source.index - 1].sort_order +
destinationGroupArray[source.index - 2].sort_order) /
2;
}
} else {
// check if dropping at beginning
if (destination.index === 0)
newSortOrder = destinationGroupArray[0].sort_order - 10000;
// check if dropping at last
else if (destination.index === destinationGroupArray.length)
newSortOrder =
destinationGroupArray[destinationGroupArray.length - 1].sort_order + 10000;
else
newSortOrder =
(destinationGroupArray[destination.index - 1].sort_order +
destinationGroupArray[destination.index].sort_order) /
2;
}
}
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)
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,
};
}
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,
};
}
return issue;
});
return [...updatedIssues];
},
false
);
mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => {
if (!prevData) return prevData;
const updatedIssues = prevData.map((i) => {
if (i.id === draggedItem.id) return draggedItem;
return i;
});
return updatedIssues;
},
false
);
// patch request
issuesService
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
priority: draggedItem.priority,
state: draggedItem.state,
sort_order: draggedItem.sort_order,
})
.then((res) => {
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
});
}
}
},
[
workspaceSlug,
cycleId,
moduleId,
groupedByIssues,
projectId,
selectedGroup,
orderBy,
states,
handleDeleteIssue,
]
);
const addIssueToState = useCallback(
(groupTitle: string, stateId: string | null) => {
setCreateIssueModal(true);
if (selectedGroup)
setPreloadedData({
state: stateId ?? undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
else setPreloadedData({ actionType: "createIssue" });
},
[setCreateIssueModal, setPreloadedData, selectedGroup]
);
const handleEditIssue = useCallback(
(issue: IIssue) => {
setEditIssueModal(true);
setIssueToEdit({
...issue,
actionType: "edit",
cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null,
module: issue.issue_module ? issue.issue_module.module : null,
});
},
[setEditIssueModal, setIssueToEdit]
);
const removeIssueFromCycle = useCallback(
(bridgeId: string) => {
if (!workspaceSlug || !projectId) return;
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(cycleId as string),
(prevData) => prevData?.filter((p) => p.id !== bridgeId),
false
);
issuesService
.removeIssueFromCycle(
workspaceSlug as string,
projectId as string,
cycleId as string,
bridgeId
)
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
});
},
[workspaceSlug, projectId, cycleId]
);
const removeIssueFromModule = useCallback(
(bridgeId: string) => {
if (!workspaceSlug || !projectId) return;
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(moduleId as string),
(prevData) => prevData?.filter((p) => p.id !== bridgeId),
false
);
modulesService
.removeIssueFromModule(
workspaceSlug as string,
projectId as string,
moduleId as string,
bridgeId
)
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
});
},
[workspaceSlug, projectId, moduleId]
);
const handleTrashBox = useCallback(
(isDragging: boolean) => {
if (isDragging && !trashBox) setTrashBox(true);
},
[trashBox, setTrashBox]
);
return (
<>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
prePopulateData={{
...preloadedData,
}}
/>
<CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
prePopulateData={{ ...issueToEdit }}
handleClose={() => setEditIssueModal(false)}
data={issueToEdit}
/>
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueToDelete}
/>
<div className="relative">
<DragDropContext onDragEnd={handleOnDragEnd}>
<StrictModeDroppable droppableId="trashBox">
{(provided, snapshot) => (
<div
className={`${
trashBox ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
} 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 ${
snapshot.isDraggingOver ? "bg-red-500 text-white" : ""
} duration-200`}
ref={provided.innerRef}
{...provided.droppableProps}
>
<TrashIcon className="h-3 w-3" />
Drop issue here to delete
</div>
)}
</StrictModeDroppable>
{issueView === "list" ? (
<AllLists
type={type}
issues={issues}
states={states}
members={members}
addIssueToState={addIssueToState}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
removeIssue={
type === "cycle"
? removeIssueFromCycle
: type === "module"
? removeIssueFromModule
: null
}
userAuth={userAuth}
/>
) : (
<AllBoards
type={type}
issues={issues}
states={states}
members={members}
addIssueToState={addIssueToState}
handleEditIssue={handleEditIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
handleDeleteIssue={handleDeleteIssue}
handleTrashBox={handleTrashBox}
removeIssue={
type === "cycle"
? removeIssueFromCycle
: type === "module"
? removeIssueFromModule
: null
}
userAuth={userAuth}
/>
)}
</DragDropContext>
</div>
</>
);
};

View File

@ -4,23 +4,19 @@ import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// hooks
// types
import type { IModule, ModuleLink } from "types";
// services
import modulesService from "services/modules.service";
// ui
import { Button, Input } from "components/ui";
// fetch-keys
import { MODULE_LIST } from "constants/fetch-keys";
// types
import type { IIssueLink, ModuleLink } from "types";
type Props = {
isOpen: boolean;
module: IModule | undefined;
handleClose: () => void;
onFormSubmit: (formData: IIssueLink | ModuleLink) => void;
};
const defaultValues: ModuleLink = {
@ -28,47 +24,20 @@ const defaultValues: ModuleLink = {
url: "",
};
const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }) => {
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
setError,
} = useForm<ModuleLink>({
defaultValues,
});
const onSubmit = async (formData: ModuleLink) => {
if (!workspaceSlug || !projectId || !module) return;
await onFormSubmit(formData);
const previousLinks = module.link_module.map((l) => ({ title: l.title, url: l.url }));
const payload: Partial<IModule> = {
links_list: [...previousLinks, formData],
};
await modulesService
.patchModule(workspaceSlug as string, projectId as string, module.id, payload)
.then(() => {
mutate<IModule[]>(projectId && MODULE_LIST(projectId as string), (prevData) =>
(prevData ?? []).map((module) => {
if (module.id === moduleId) return { ...module, ...payload };
return module;
})
);
onClose();
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof ModuleLink, {
message: err[key].join(", "),
});
});
});
onClose();
};
const onClose = () => {
@ -113,21 +82,6 @@ const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
Add Link
</Dialog.Title>
<div className="mt-2 space-y-3">
<div>
<Input
id="title"
label="Title"
name="title"
type="text"
placeholder="Enter title"
autoComplete="off"
error={errors.title}
register={register}
validations={{
required: "Title is required",
}}
/>
</div>
<div>
<Input
id="url"
@ -143,6 +97,21 @@ const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
}}
/>
</div>
<div>
<Input
id="title"
label="Title"
name="title"
type="text"
placeholder="Enter title"
autoComplete="off"
error={errors.title}
register={register}
validations={{
required: "Title is required",
}}
/>
</div>
</div>
</div>
</div>
@ -163,5 +132,3 @@ const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
</Transition.Root>
);
};
export default ModuleLinkModal;

View File

@ -0,0 +1,63 @@
// hooks
import useIssueView from "hooks/use-issue-view";
// components
import { SingleList } from "components/core/list-view/single-list";
// types
import { IIssue, IProjectMember, IState, UserAuth } from "types";
// types
type Props = {
type: "issue" | "cycle" | "module";
issues: IIssue[];
states: IState[] | undefined;
members: IProjectMember[] | undefined;
addIssueToState: (groupTitle: string, stateId: string | null) => void;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string) => void) | null;
userAuth: UserAuth;
};
export const AllLists: React.FC<Props> = ({
type,
issues,
states,
members,
addIssueToState,
openIssuesListModal,
handleEditIssue,
handleDeleteIssue,
removeIssue,
userAuth,
}) => {
const { groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues);
return (
<div className="flex flex-col space-y-5">
{Object.keys(groupedByIssues).map((singleGroup) => {
const stateId =
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null;
return (
<SingleList
key={singleGroup}
type={type}
groupTitle={singleGroup}
groupedByIssues={groupedByIssues}
selectedGroup={selectedGroup}
members={members}
addIssueToState={() => addIssueToState(singleGroup, stateId)}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
removeIssue={removeIssue}
userAuth={userAuth}
/>
);
})}
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from "./all-lists";
export * from "./single-issue";
export * from "./single-list";

View File

@ -0,0 +1,237 @@
import React, { useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import issuesService from "services/issues.service";
// hooks
import useToast from "hooks/use-toast";
// components
import {
ViewAssigneeSelect,
ViewDueDateSelect,
ViewPrioritySelect,
ViewStateSelect,
} from "components/issues/view-select";
// ui
import { Tooltip, CustomMenu } from "components/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { CycleIssueResponse, IIssue, ModuleIssueResponse, Properties, UserAuth } from "types";
// fetch-keys
import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
type Props = {
type?: string;
issue: IIssue;
properties: Properties;
editIssue: () => void;
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
userAuth: UserAuth;
};
export const SingleListIssue: React.FC<Props> = ({
type,
issue,
properties,
editIssue,
removeIssue,
handleDeleteIssue,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return;
if (cycleId)
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(cycleId as string),
(prevData) => {
const updatedIssues = (prevData ?? []).map((p) => {
if (p.issue_detail.id === issue.id) {
return {
...p,
issue_detail: {
...p.issue_detail,
...formData,
},
};
}
return p;
});
return [...updatedIssues];
},
false
);
if (moduleId)
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(moduleId as string),
(prevData) => {
const updatedIssues = (prevData ?? []).map((p) => {
if (p.issue_detail.id === issue.id) {
return {
...p,
issue_detail: {
...p.issue_detail,
...formData,
},
};
}
return p;
});
return [...updatedIssues];
},
false
);
mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === issue.id) return { ...p, ...formData };
return p;
}),
false
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
.then((res) => {
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
})
.catch((error) => {
console.log(error);
});
},
[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: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div className="flex items-center justify-between gap-2 px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties.key && (
<Tooltip
tooltipHeading="ID"
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
)}
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
<span className="w-auto max-w-lg text-ellipsis overflow-hidden whitespace-nowrap">
{issue.name}
</span>
</Tooltip>
</a>
</Link>
</div>
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
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">
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.labels && (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
key={label.id}
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</span>
))}
</div>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
{type !== "issue" && removeIssue && (
<CustomMenu.MenuItem onClick={removeIssue}>
<>Remove from {type}</>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
Delete issue
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,166 @@
import { useRouter } from "next/router";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
// components
import { SingleListIssue } from "components/core";
// icons
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/24/outline";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IProjectMember, NestedKeyOf, UserAuth } from "types";
import { CustomMenu } from "components/ui";
type Props = {
type?: "issue" | "cycle" | "module";
groupTitle: string;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: NestedKeyOf<IIssue> | null;
members: IProjectMember[] | undefined;
addIssueToState: () => void;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string) => void) | null;
userAuth: UserAuth;
};
export const SingleList: React.FC<Props> = ({
type,
groupTitle,
groupedByIssues,
selectedGroup,
members,
addIssueToState,
handleEditIssue,
handleDeleteIssue,
openIssuesListModal,
removeIssue,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
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;
let assignees: any;
if (selectedGroup === "assignees") {
assignees = groupTitle && groupTitle !== "" ? groupTitle.split(",") : [];
assignees =
assignees.length > 0
? assignees
.map((a: string) => members?.find((m) => m.member.id === a)?.member.first_name)
.join(", ")
: "No assignee";
}
return (
<Disclosure key={groupTitle} as="div" defaultOpen>
{({ open }) => (
<div className="rounded-lg bg-white">
<div className="rounded-t-lg bg-gray-100 px-4 py-3">
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${!open ? "-rotate-90 transform" : ""}`}
/>
</span>
{selectedGroup !== null ? (
<h2 className="font-medium capitalize leading-5">
{selectedGroup === "created_by"
? createdBy
: selectedGroup === "assignees"
? assignees
: addSpaceIfCamelCase(groupTitle)}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<p className="text-sm text-gray-500">
{groupedByIssues[groupTitle as keyof IIssue].length}
</p>
</div>
</Disclosure.Button>
</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="divide-y-2">
{groupedByIssues[groupTitle] ? (
groupedByIssues[groupTitle].length > 0 ? (
groupedByIssues[groupTitle].map((issue: IIssue) => (
<SingleListIssue
key={issue.id}
type={type}
issue={issue}
properties={properties}
editIssue={() => handleEditIssue(issue)}
handleDeleteIssue={handleDeleteIssue}
removeIssue={() => {
removeIssue && removeIssue(issue.bridge);
}}
userAuth={userAuth}
/>
))
) : (
<p className="px-4 py-3 text-sm text-gray-500">No issues.</p>
)
) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div>
)}
</div>
</Disclosure.Panel>
</Transition>
<div className="p-3">
{type === "issue" ? (
<button
type="button"
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100"
onClick={addIssueToState}
>
<PlusIcon className="h-3 w-3" />
Add issue
</button>
) : (
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
optionsPosition="left"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
</div>
)}
</Disclosure>
);
};

View File

@ -0,0 +1,150 @@
import React, { useState } from "react";
import { Listbox, Transition } from "@headlessui/react";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid";
type TSelectOption = {
id: string;
label: string;
value: any;
children?:
| (TSelectOption & {
children?: null;
})[]
| null;
};
type TMultipleSelectProps = {
options: TSelectOption[];
selected: TSelectOption | null;
setSelected: (value: any) => void;
label: string;
direction?: "left" | "right";
};
export const MultiLevelSelect: React.FC<TMultipleSelectProps> = (props) => {
const { options, selected, setSelected, label, direction = "right" } = props;
const [openChildFor, setOpenChildFor] = useState<TSelectOption | null>(null);
return (
<div className="fixed top-16 w-72">
<Listbox
value={selected}
onChange={(value) => {
if (value?.children === null) {
setSelected(value);
setOpenChildFor(null);
} else setOpenChildFor(value);
}}
>
{({ open }) => (
<div className="relative mt-1">
<Listbox.Button
onClick={() => setOpenChildFor(null)}
className="relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md sm:text-sm"
>
<span className="block truncate">{selected?.label ?? label}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</Listbox.Button>
<Transition
as={React.Fragment}
show={open}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
static
className="absolute mt-1 max-h-60 w-full rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
{options.map((option) => (
<Listbox.Option
key={option.id}
className={
"relative cursor-default select-none py-2 pl-10 pr-4 hover:bg-gray-100 hover:text-gray-900"
}
onClick={(e: any) => {
if (option.children !== null) {
e.preventDefault();
setOpenChildFor(option);
}
if (option.id === openChildFor?.id) {
e.preventDefault();
setOpenChildFor(null);
}
}}
value={option}
>
{({ selected }) => (
<>
{openChildFor?.id === option.id && (
<div
className={`w-72 h-auto max-h-72 bg-white border border-gray-200 absolute rounded-lg ${
direction === "right"
? "rounded-tl-none shadow-md left-full translate-x-2"
: "rounded-tr-none shadow-md right-full -translate-x-2"
}`}
>
{option.children?.map((child) => (
<Listbox.Option
key={child.id}
className={
"relative cursor-default select-none py-2 pl-10 pr-4 hover:bg-gray-100 hover:text-gray-900"
}
as="div"
value={child}
>
{({ selected }) => (
<>
<span
className={`block truncate ${
selected ? "font-medium" : "font-normal"
}`}
>
{child.label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-600">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
<div
className={`w-0 h-0 absolute border-t-8 border-gray-300 ${
direction === "right"
? "top-0 left-0 -translate-x-2 border-r-8 border-b-8 border-b-transparent border-t-transparent border-l-transparent"
: "top-0 right-0 translate-x-2 border-l-8 border-b-8 border-b-transparent border-t-transparent border-r-transparent"
}`}
/>
</div>
)}
<span
className={`block truncate ${selected ? "font-medium" : "font-normal"}`}
>
{option.label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-600">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
</div>
);
};

View File

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

View File

@ -0,0 +1,69 @@
import Link from "next/link";
// icons
import { LinkIcon, TrashIcon } from "@heroicons/react/24/outline";
import { ExternalLinkIcon } from "components/icons";
// 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="relative">
{!isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-[1] flex items-center gap-1">
<Link href={link.url}>
<a
className="grid h-7 w-7 place-items-center rounded bg-gray-100 p-1 outline-none"
target="_blank"
>
<ExternalLinkIcon width="14" height="14" />
</a>
</Link>
<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}>
<a className="relative flex gap-2 rounded-md border bg-gray-50 p-2" target="_blank">
<div className="mt-0.5">
<LinkIcon className="h-3.5 w-3.5" />
</div>
<div>
<h5 className="w-4/5">{link.title}</h5>
<p className="mt-0.5 text-gray-500">
Added {timeAgo(link.created_at)}
{/* <br />
by {link.created_by_detail.email} */}
</p>
</div>
</a>
</Link>
</div>
))}
</>
);
};

View File

@ -0,0 +1,97 @@
import React from "react";
import {
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
AreaChart,
Area,
ReferenceLine,
} from "recharts";
//types
import { IIssue } from "types";
// helper
import { getDatesInRange, renderShortNumericDateFormat } from "helpers/date-time.helper";
type Props = {
issues: IIssue[];
start: string;
end: string;
};
const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
const startDate = new Date(start);
const endDate = new Date(end);
const getChartData = () => {
const dateRangeArray = getDatesInRange(startDate, endDate);
let count = 0;
const dateWiseData = dateRangeArray.map((d) => {
const current = d.toISOString().split("T")[0];
const total = issues.length;
const currentData = issues.filter(
(i) => i.completed_at && i.completed_at.toString().split("T")[0] === current
);
count = currentData ? currentData.length + count : count;
return {
currentDate: renderShortNumericDateFormat(current),
currentDateData: currentData,
pending: new Date(current) < new Date() ? total - count : null,
};
});
return dateWiseData;
};
const ChartData = getChartData();
return (
<div className="relative h-[200px] w-full ">
<div className="flex justify-start items-start gap-4 text-xs">
<div className="flex justify-center items-center gap-1">
<span className="h-2 w-2 bg-green-600 rounded-full" />
<span>Ideal</span>
</div>
<div className="flex justify-center items-center gap-1">
<span className="h-2 w-2 bg-[#8884d8] rounded-full" />
<span>Current</span>
</div>
</div>
<div className="flex items-center justify-center h-full w-full absolute -left-8 py-3 text-xs">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
width={300}
height={200}
data={ChartData}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
>
<XAxis dataKey="currentDate" />
<YAxis />
<Tooltip />
<Area
type="monotone"
dataKey="pending"
stroke="#8884d8"
fill="#98d1fb"
activeDot={{ r: 8 }}
/>
<ReferenceLine
stroke="#16a34a"
strokeDasharray="3 3"
segment={[
{ x: `${renderShortNumericDateFormat(endDate)}`, y: 0 },
{ x: `${renderShortNumericDateFormat(startDate)}`, y: issues.length },
]}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default ProgressChart;

View File

@ -0,0 +1,213 @@
import React from "react";
import Image from "next/image";
import { useRouter } from "next/router";
import useSWR from "swr";
// headless ui
import { Tab } from "@headlessui/react";
// services
import issuesServices from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useLocalStorage from "hooks/use-local-storage";
// components
import { SingleProgressStats } from "components/core";
// ui
import { Avatar } from "components/ui";
// icons
import User from "public/user.png";
// types
import { IIssue, IIssueLabels } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
// types
type Props = {
groupedIssues: any;
issues: IIssue[];
};
const stateGroupColours: {
[key: string]: string;
} = {
backlog: "#3f76ff",
unstarted: "#ff9e9e",
started: "#d687ff",
cancelled: "#ff5353",
completed: "#096e8d",
};
export const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees");
const { data: issueLabels } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId
? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId as string)
: null
);
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(workspaceSlug as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const currentValue = (tab: string | null) => {
switch (tab) {
case "Assignees":
return 0;
case "Labels":
return 1;
case "States":
return 2;
default:
return 0;
}
};
return (
<Tab.Group
defaultIndex={currentValue(tab)}
onChange={(i) => {
switch (i) {
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"}`
}
>
Assignees
</Tab>
<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}
/>
);
}
})}
{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>
</>
}
completed={groupedIssues[group].length}
total={issues.length}
/>
))}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);
};

View File

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

View File

@ -1,8 +1,7 @@
// react
import { useState } from "react";
// components
import SingleStat from "components/project/cycles/stats-view/single-stat";
import ConfirmCycleDeletion from "components/project/cycles/confirm-cycle-deletion";
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
// types
import { ICycle, SelectCycleType } from "types";
import { CompletedCycleIcon, CurrentCycleIcon, UpcomingCycleIcon } from "components/icons";
@ -14,7 +13,7 @@ type TCycleStatsViewProps = {
type: "current" | "upcoming" | "completed";
};
const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
export const CyclesListView: React.FC<TCycleStatsViewProps> = ({
cycles,
setCreateUpdateCycleModal,
setSelectedCycle,
@ -35,7 +34,7 @@ const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
return (
<>
<ConfirmCycleDeletion
<DeleteCycleModal
isOpen={
cycleDeleteModal &&
!!selectedCycleForDelete &&
@ -46,7 +45,7 @@ const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
/>
{cycles.length > 0 ? (
cycles.map((cycle) => (
<SingleStat
<SingleCycleCard
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
@ -64,12 +63,10 @@ const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
)}
<h3 className="text-gray-500">
No {type} {type === "current" ? "cycle" : "cycles"} yet. Create with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">Q</pre>.
<pre className="inline rounded bg-gray-200 px-2 py-1">Q</pre>.
</h3>
</div>
)}
</>
);
};
export default CycleStatsView;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,357 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import Image from "next/image";
import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
import { Popover, Transition } from "@headlessui/react";
import DatePicker from "react-datepicker";
// icons
import {
CalendarDaysIcon,
ChartPieIcon,
LinkIcon,
Squares2X2Icon,
TrashIcon,
UserIcon,
} from "@heroicons/react/24/outline";
// ui
import { CustomSelect, Loader, ProgressBar } from "components/ui";
// hooks
import useToast from "hooks/use-toast";
// services
import cyclesService from "services/cycles.service";
// components
import { SidebarProgressStats } from "components/core";
import ProgressChart from "components/core/sidebar/progress-chart";
import { DeleteCycleModal } from "components/cycles";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
import { groupBy } from "helpers/array.helper";
import { renderDateFormat, renderShortNumericDateFormat } from "helpers/date-time.helper";
// types
import { CycleIssueResponse, ICycle, IIssue } from "types";
// fetch-keys
import { CYCLE_DETAILS } from "constants/fetch-keys";
// constants
import { CYCLE_STATUS } from "constants/cycle";
type Props = {
issues: IIssue[];
cycle: ICycle | undefined;
isOpen: boolean;
cycleIssues: CycleIssueResponse[];
};
export const CycleDetailsSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cycleIssues }) => {
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const [startDateRange, setStartDateRange] = useState<Date | null>(new Date());
const [endDateRange, setEndDateRange] = useState<Date | null>(null);
const { setToastAlert } = useToast();
const defaultValues: Partial<ICycle> = {
start_date: new Date().toString(),
end_date: new Date().toString(),
status: cycle?.status,
};
const groupedIssues = {
backlog: [],
unstarted: [],
started: [],
cancelled: [],
completed: [],
...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"),
};
const { reset, watch, control } = useForm({
defaultValues,
});
const submitChanges = (data: Partial<ICycle>) => {
if (!workspaceSlug || !projectId || !cycleId) return;
mutate<ICycle>(
CYCLE_DETAILS(cycleId as string),
(prevData) => ({ ...(prevData as ICycle), ...data }),
false
);
cyclesService
.patchCycle(workspaceSlug as string, projectId as string, cycleId as string, data)
.then((res) => {
console.log(res);
mutate(CYCLE_DETAILS(cycleId as string));
})
.catch((e) => {
console.log(e);
});
};
useEffect(() => {
if (cycle)
reset({
...cycle,
});
}, [cycle, reset]);
const isStartValid = new Date(`${cycle?.start_date}`) <= new Date();
const isEndValid = new Date(`${cycle?.end_date}`) >= new Date(`${cycle?.start_date}`);
return (
<>
<DeleteCycleModal isOpen={cycleDeleteModal} setIsOpen={setCycleDeleteModal} data={cycle} />
<div
className={`fixed top-0 ${
isOpen ? "right-0" : "-right-[24rem]"
} z-20 h-full w-[24rem] overflow-y-auto border-l bg-gray-50 p-5 duration-300`}
>
{cycle ? (
<>
<div className="flex gap-1 text-sm my-2">
<div className="flex items-center ">
<Controller
control={control}
name="status"
render={({ field: { value } }) => (
<CustomSelect
label={
<span
className={`flex items-center gap-1 text-left capitalize p-1 text-xs h-full w-full text-gray-900`}
>
<Squares2X2Icon className="h-4 w-4 flex-shrink-0" />
{watch("status")}
</span>
}
value={value}
onChange={(value: any) => {
submitChanges({ status: value });
}}
>
{CYCLE_STATUS.map((option) => (
<CustomSelect.Option key={option.value} value={option.value}>
<span className="text-xs">{option.label}</span>
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex justify-center items-center gap-2 rounded-md border bg-transparent h-full p-2 px-4 text-xs font-medium text-gray-900 hover:bg-gray-100 hover:text-gray-900 focus:outline-none">
<Popover className="flex justify-center items-center relative rounded-lg">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center ${open ? "bg-gray-100" : ""}`}
>
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0 mr-2" />
<span>
{renderShortNumericDateFormat(`${cycle.start_date}`)
? renderShortNumericDateFormat(`${cycle.start_date}`)
: "N/A"}
</span>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -left-10 z-20 transform overflow-hidden">
<DatePicker
selected={startDateRange}
onChange={(date) => {
submitChanges({
start_date: renderDateFormat(date),
});
setStartDateRange(date);
}}
selectsStart
startDate={startDateRange}
endDate={endDateRange}
inline
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<Popover className="flex justify-center items-center relative rounded-lg">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center ${open ? "bg-gray-100" : ""}`}
>
<span>
-{" "}
{renderShortNumericDateFormat(`${cycle.end_date}`)
? renderShortNumericDateFormat(`${cycle.end_date}`)
: "N/A"}
</span>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -right-20 z-20 transform overflow-hidden">
<DatePicker
selected={endDateRange}
onChange={(date) => {
submitChanges({
end_date: renderDateFormat(date),
});
setEndDateRange(date);
}}
selectsEnd
startDate={startDateRange}
endDate={endDateRange}
minDate={startDateRange}
inline
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
</div>
<div className="flex items-center justify-between pb-3">
<h4 className="text-sm font-medium">{cycle.name}</h4>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="rounded-md border p-2 shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`
)
.then(() => {
setToastAlert({
type: "success",
title: "Cycle link copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<LinkIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-50 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
onClick={() => setCycleDeleteModal(true)}
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="divide-y-2 divide-gray-100 text-xs">
<div className="py-1">
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserIcon className="h-4 w-4 flex-shrink-0" />
<p>Owned by</p>
</div>
<div className="sm:basis-1/2 flex items-center gap-1">
{cycle.owned_by &&
(cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<div className="h-5 w-5 rounded-full border-2 border-transparent">
<Image
src={cycle.owned_by.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={cycle.owned_by?.first_name}
/>
</div>
) : (
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
{cycle.owned_by?.first_name && cycle.owned_by.first_name !== ""
? cycle.owned_by.first_name.charAt(0)
: cycle.owned_by?.email.charAt(0)}
</div>
))}
{cycle.owned_by.first_name !== ""
? cycle.owned_by.first_name
: cycle.owned_by.email}
</div>
</div>
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<ChartPieIcon className="h-4 w-4 flex-shrink-0" />
<p>Progress</p>
</div>
<div className="flex items-center gap-2 sm:basis-1/2">
<div className="grid flex-shrink-0 place-items-center">
<span className="h-4 w-4">
<ProgressBar
value={groupedIssues.completed.length}
maxValue={cycleIssues?.length}
/>
</span>
</div>
{groupedIssues.completed.length}/{cycleIssues?.length}
</div>
</div>
</div>
<div className="py-1" />
</div>
<div className="flex flex-col items-center justify-center w-full gap-2 ">
{isStartValid && isEndValid ? (
<div className="relative h-[200px] w-full ">
<ProgressChart
issues={issues}
start={cycle?.start_date ?? ""}
end={cycle?.end_date ?? ""}
/>
</div>
) : (
""
)}
{issues.length > 0 ? (
<SidebarProgressStats issues={issues} groupedIssues={groupedIssues} />
) : (
""
)}
</div>
</>
) : (
<Loader>
<div className="space-y-2">
<Loader.Item height="15px" width="50%" />
<Loader.Item height="15px" width="30%" />
</div>
<div className="mt-8 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
)}
</div>
</>
);
};

View File

@ -8,6 +8,8 @@ import { useRouter } from "next/router";
import useSWR from "swr";
// services
import cyclesService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button, CustomMenu } from "components/ui";
// icons
@ -17,6 +19,7 @@ import { CyclesIcon } from "components/icons";
// helpers
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
import { groupBy } from "helpers/array.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { CycleIssueResponse, ICycle } from "types";
// fetch-keys
@ -38,11 +41,12 @@ const stateGroupColours: {
completed: "#096e8d",
};
const SingleStat: React.FC<TSingleStatProps> = (props) => {
export const SingleCycleCard: React.FC<TSingleStatProps> = (props) => {
const { cycle, handleEditCycle, handleDeleteCycle } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const { data: cycleIssues } = useSWR<CycleIssueResponse[]>(
workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null,
@ -63,12 +67,27 @@ const SingleStat: React.FC<TSingleStatProps> = (props) => {
...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"),
};
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
return (
<>
<div className="rounded-md border bg-white p-3">
<div className="grid grid-cols-9 gap-2 divide-x">
<div className="col-span-3 flex flex-col space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-start justify-between gap-2">
<Link href={`/${workspaceSlug}/projects/${projectId as string}/cycles/${cycle.id}`}>
<a>
<h2 className="font-medium w-full max-w-[175px] lg:max-w-[225px] xl:max-w-[300px] text-ellipsis overflow-hidden">
@ -78,9 +97,8 @@ const SingleStat: React.FC<TSingleStatProps> = (props) => {
</Link>
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={handleEditCycle}>Edit cycle</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
Delete cycle permanently
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>Delete cycle</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy cycle link</CustomMenu.MenuItem>
</CustomMenu>
</div>
<div className="grid grid-cols-3 gap-x-2 gap-y-3 text-xs">
@ -161,5 +179,3 @@ const SingleStat: React.FC<TSingleStatProps> = (props) => {
</>
);
};
export default SingleStat;

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
// react beautiful dnd
import { Droppable, DroppableProps } from "react-beautiful-dnd";
@ -14,9 +15,7 @@ const StrictModeDroppable = ({ children, ...props }: DroppableProps) => {
};
}, []);
if (!enabled) {
return null;
}
if (!enabled) return null;
return <Droppable {...props}>{children}</Droppable>;
};

View File

@ -7,7 +7,7 @@ import { Props } from "./types";
import emojis from "./emojis.json";
// helpers
import { getRecentEmojis, saveRecentEmoji } from "./helpers";
import { getRandomEmoji } from "helpers/functions.helper";
import { getRandomEmoji } from "helpers/common.helper";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
@ -59,7 +59,7 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
leaveTo="transform opacity-0 scale-95"
>
<Popover.Panel className="absolute z-10 mt-2 w-80 rounded-md bg-white shadow-lg">
<div className="h-80 w-80 overflow-auto rounded border bg-white p-2 shadow-2xl">
<div className="h-72 w-80 overflow-auto rounded border bg-white p-2 shadow-2xl">
<Tab.Group as="div" className="flex h-full w-full flex-col">
<Tab.List className="flex-0 -mx-2 flex justify-around gap-1 rounded border-b p-1">
{tabOptions.map((tab) => (

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