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 = { module.exports = {
root: true, root: true,
// This tells ESLint to load the config from the package `config` // This tells ESLint to load the config from the package `eslint-config-custom`
// extends: ["custom"], extends: ["custom"],
settings: { settings: {
next: { next: {
rootDir: ["apps/*/"], rootDir: ["apps/*"],
}, },
}, },
}; };

8
.gitignore vendored
View File

@ -62,3 +62,11 @@ yarn-error.log
*.sln *.sln
package-lock.json package-lock.json
.vscode .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>
<p align="center"> <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" /> <img alt="Discord" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
</a> </a>
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" /> <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 ## 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 -->" SECRET_KEY="<-- django secret -->"
DJANGO_SETTINGS_MODULE="plane.settings.production"
# Database
DATABASE_URL=postgres://plane:plane@plane-db-1:5432/plane
# Cache
REDIS_URL=redis://redis:6379/
# SMPT
EMAIL_HOST="<-- email smtp -->" EMAIL_HOST="<-- email smtp -->"
EMAIL_HOST_USER="<-- email host user -->" EMAIL_HOST_USER="<-- email host user -->"
EMAIL_HOST_PASSWORD="<-- email host password -->" EMAIL_HOST_PASSWORD="<-- email host password -->"
# AWS
AWS_REGION="<-- aws region -->" AWS_REGION="<-- aws region -->"
AWS_ACCESS_KEY_ID="<-- aws access key -->" AWS_ACCESS_KEY_ID="<-- aws access key -->"
AWS_SECRET_ACCESS_KEY="<-- aws secret acess key -->" AWS_SECRET_ACCESS_KEY="<-- aws secret acess key -->"
AWS_S3_BUCKET_NAME="<-- aws s3 bucket name -->" AWS_S3_BUCKET_NAME="<-- aws s3 bucket name -->"
# FE
SENTRY_DSN="<-- sentry dsn -->" WEB_URL="localhost/"
WEB_URL="<-- frontend web url -->" # OAUTH
GITHUB_CLIENT_SECRET="<-- github secret -->" GITHUB_CLIENT_SECRET="<-- github secret -->"
# Flags
DISABLE_COLLECTSTATIC=1 DISABLE_COLLECTSTATIC=1
DOCKERIZED=0 //True if running docker compose else 0 DOCKERIZED=1

View File

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

View File

@ -1,11 +1,14 @@
# All the python scripts that are used for back migrations # 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 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 # Update description and description html values for old descriptions
def update_description(): def update_description():
try: try:
issues = Issue.objects.all() issues = Issue.objects.all()
updated_issues = [] updated_issues = []
@ -25,7 +28,6 @@ def update_description():
def update_comments(): def update_comments():
try: try:
issue_comments = IssueComment.objects.all() issue_comments = IssueComment.objects.all()
updated_issue_comments = [] updated_issue_comments = []
@ -44,9 +46,11 @@ def update_comments():
def update_project_identifiers(): def update_project_identifiers():
try: 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 = [] updated_identifiers = []
for identifier in project_identifiers: for identifier in project_identifiers:
identifier.workspace_id = identifier.project.workspace_id identifier.workspace_id = identifier.project.workspace_id
updated_identifiers.append(identifier) updated_identifiers.append(identifier)
@ -58,3 +62,37 @@ def update_project_identifiers():
except Exception as e: except Exception as e:
print(e) print(e)
print("Failed") 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 set -e
python manage.py wait_for_db python manage.py wait_for_db
python manage.py migrate python manage.py migrate
# Create a Default User
python bin/user_script.py
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 1200 --max-requests-jitter 1000 --access-logfile - exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 1200 --max-requests-jitter 1000 --access-logfile -

View File

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

View File

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

View File

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

View File

@ -5,7 +5,6 @@ from django.urls import path
from plane.api.views import ( from plane.api.views import (
# Authentication # Authentication
SignUpEndpoint,
SignInEndpoint, SignInEndpoint,
SignOutEndpoint, SignOutEndpoint,
MagicSignInEndpoint, MagicSignInEndpoint,
@ -87,6 +86,14 @@ from plane.api.views import (
# Api Tokens # Api Tokens
ApiTokenEndpoint, ApiTokenEndpoint,
## End Api Tokens ## 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"), path("social-auth/", OauthEndpoint.as_view(), name="oauth"),
# Auth # Auth
path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), path("sign-in/", SignInEndpoint.as_view(), name="sign-in"),
path("sign-up/", SignUpEndpoint.as_view(), name="sign-up"),
path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"),
# Magic Sign In/Up # Magic Sign In/Up
path( path(
@ -683,7 +689,118 @@ urlpatterns = [
), ),
## End Modules ## End Modules
# API Tokens # API Tokens
path("api-tokens/", 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-token"), path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-tokens"),
## End 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 ( from .authentication import (
SignUpEndpoint,
SignInEndpoint, SignInEndpoint,
SignOutEndpoint, SignOutEndpoint,
MagicSignInEndpoint, MagicSignInEndpoint,
@ -73,4 +72,13 @@ from .authentication import (
from .module import ModuleViewSet, ModuleIssueViewSet 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): class ApiTokenEndpoint(BaseAPIView):
def post(self, request): def post(self, request):
try: try:
label = request.data.get("label", str(uuid4().hex)) 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( api_token = APIToken.objects.create(
label=label, label=label, user=request.user, workspace_id=workspace
user=request.user,
) )
serializer = APITokenSerializer(api_token) serializer = APITokenSerializer(api_token)

View File

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

View File

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

View File

@ -1,5 +1,9 @@
# Python imports
import json
# Django imports # Django imports
from django.db.models import OuterRef, Func, F from django.db.models import OuterRef, Func, F
from django.core import serializers
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -11,10 +15,10 @@ from . import BaseViewSet
from plane.api.serializers import CycleSerializer, CycleIssueSerializer from plane.api.serializers import CycleSerializer, CycleIssueSerializer
from plane.api.permissions import ProjectEntityPermission from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Cycle, CycleIssue, Issue from plane.db.models import Cycle, CycleIssue, Issue
from plane.bgtasks.issue_activites_task import issue_activity
class CycleViewSet(BaseViewSet): class CycleViewSet(BaseViewSet):
serializer_class = CycleSerializer serializer_class = CycleSerializer
model = Cycle model = Cycle
permission_classes = [ permission_classes = [
@ -41,7 +45,6 @@ class CycleViewSet(BaseViewSet):
class CycleIssueViewSet(BaseViewSet): class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer serializer_class = CycleIssueSerializer
model = CycleIssue model = CycleIssue
@ -79,7 +82,6 @@ class CycleIssueViewSet(BaseViewSet):
def create(self, request, slug, project_id, cycle_id): def create(self, request, slug, project_id, cycle_id):
try: try:
issues = request.data.get("issues", []) issues = request.data.get("issues", [])
if not len(issues): if not len(issues):
@ -91,29 +93,77 @@ class CycleIssueViewSet(BaseViewSet):
workspace__slug=slug, project_id=project_id, pk=cycle_id workspace__slug=slug, project_id=project_id, pk=cycle_id
) )
issues = Issue.objects.filter( # Get all CycleIssues already created
pk__in=issues, workspace__slug=slug, project_id=project_id 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 for issue in issues:
CycleIssue.objects.filter(issue_id__in=issues).delete() 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.objects.bulk_create(
[ record_to_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
],
batch_size=10, batch_size=10,
ignore_conflicts=True, 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: except Cycle.DoesNotExist:
return Response( 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 from itertools import groupby, chain
# Django imports # 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 from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports # Third Party imports
@ -22,6 +22,7 @@ from plane.api.serializers import (
LabelSerializer, LabelSerializer,
IssueSerializer, IssueSerializer,
LabelSerializer, LabelSerializer,
IssueFlatSerializer,
) )
from plane.api.permissions import ( from plane.api.permissions import (
ProjectEntityPermission, ProjectEntityPermission,
@ -39,8 +40,10 @@ from plane.db.models import (
IssueBlocker, IssueBlocker,
CycleIssue, CycleIssue,
ModuleIssue, ModuleIssue,
IssueLink,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
class IssueViewSet(BaseViewSet): class IssueViewSet(BaseViewSet):
@ -75,10 +78,9 @@ class IssueViewSet(BaseViewSet):
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
) )
if current_instance is not None: if current_instance is not None:
issue_activity.delay( issue_activity.delay(
{ {
"type": "issue.activity", "type": "issue.activity.updated",
"requested_data": requested_data, "requested_data": requested_data,
"actor_id": str(self.request.user.id), "actor_id": str(self.request.user.id),
"issue_id": str(self.kwargs.get("pk", None)), "issue_id": str(self.kwargs.get("pk", None)),
@ -91,8 +93,28 @@ class IssueViewSet(BaseViewSet):
return super().perform_update(serializer) 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 ( return (
super() super()
.get_queryset() .get_queryset()
@ -136,52 +158,42 @@ class IssueViewSet(BaseViewSet):
).prefetch_related("module__members"), ).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): def list(self, request, slug, project_id):
try: 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 ## Grouping the results
group_by = request.GET.get("group_by", False) group_by = request.GET.get("group_by", False)
# TODO: Move this group by from ittertools to ORM for better performance - nk
if group_by: 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 return Response(issues, status=status.HTTP_200_OK)
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,
)
except Exception as e: except Exception as e:
print(e) print(e)
@ -202,15 +214,18 @@ class IssueViewSet(BaseViewSet):
serializer.save() serializer.save()
# Track the issue # Track the issue
IssueActivity.objects.create( issue_activity.delay(
issue_id=serializer.data["id"], {
project_id=project_id, "type": "issue.activity.created",
workspace_id=serializer["workspace"], "requested_data": json.dumps(
comment=f"{request.user.email} created the issue", self.request.data, cls=DjangoJSONEncoder
verb="created", ),
actor=request.user, "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.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -265,6 +280,14 @@ class UserWorkSpaceIssues(BaseAPIView):
queryset=ModuleIssue.objects.select_related("module", "issue"), queryset=ModuleIssue.objects.select_related("module", "issue"),
), ),
) )
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related(
"issue"
).select_related("created_by"),
)
)
) )
serializer = IssueSerializer(issues, many=True) serializer = IssueSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@ -277,7 +300,6 @@ class UserWorkSpaceIssues(BaseAPIView):
class WorkSpaceIssuesEndpoint(BaseAPIView): class WorkSpaceIssuesEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
WorkSpaceAdminPermission, WorkSpaceAdminPermission,
] ]
@ -298,7 +320,6 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
class IssueActivityEndpoint(BaseAPIView): class IssueActivityEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]
@ -307,7 +328,10 @@ class IssueActivityEndpoint(BaseAPIView):
try: try:
issue_activities = ( issue_activities = (
IssueActivity.objects.filter(issue_id=issue_id) 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") .select_related("actor")
).order_by("created_by") ).order_by("created_by")
issue_comments = ( issue_comments = (
@ -333,7 +357,6 @@ class IssueActivityEndpoint(BaseAPIView):
class IssueCommentViewSet(BaseViewSet): class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer serializer_class = IssueCommentSerializer
model = IssueComment model = IssueComment
permission_classes = [ permission_classes = [
@ -351,6 +374,60 @@ class IssueCommentViewSet(BaseViewSet):
issue_id=self.kwargs.get("issue_id"), issue_id=self.kwargs.get("issue_id"),
actor=self.request.user if self.request.user is not None else None, 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): def get_queryset(self):
return self.filter_queryset( return self.filter_queryset(
@ -436,7 +513,6 @@ class IssuePropertyViewSet(BaseViewSet):
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
try: try:
issue_property, created = IssueProperty.objects.get_or_create( issue_property, created = IssueProperty.objects.get_or_create(
user=request.user, user=request.user,
project_id=project_id, project_id=project_id,
@ -463,7 +539,6 @@ class IssuePropertyViewSet(BaseViewSet):
class LabelViewSet(BaseViewSet): class LabelViewSet(BaseViewSet):
serializer_class = LabelSerializer serializer_class = LabelSerializer
model = Label model = Label
permission_classes = [ permission_classes = [
@ -490,14 +565,12 @@ class LabelViewSet(BaseViewSet):
class BulkDeleteIssuesEndpoint(BaseAPIView): class BulkDeleteIssuesEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]
def delete(self, request, slug, project_id): def delete(self, request, slug, project_id):
try: try:
issue_ids = request.data.get("issue_ids", []) issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids): if not len(issue_ids):
@ -527,14 +600,12 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
class SubIssuesEndpoint(BaseAPIView): class SubIssuesEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]
def get(self, request, slug, project_id, issue_id): def get(self, request, slug, project_id, issue_id):
try: try:
sub_issues = ( sub_issues = (
Issue.objects.filter( Issue.objects.filter(
parent_id=issue_id, workspace__slug=slug, project_id=project_id parent_id=issue_id, workspace__slug=slug, project_id=project_id
@ -583,3 +654,39 @@ class SubIssuesEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, 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 # Django Imports
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Prefetch, F, OuterRef, Func from django.db.models import Prefetch, F, OuterRef, Func
from django.core import serializers
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -22,10 +26,10 @@ from plane.db.models import (
Issue, Issue,
ModuleLink, ModuleLink,
) )
from plane.bgtasks.issue_activites_task import issue_activity
class ModuleViewSet(BaseViewSet): class ModuleViewSet(BaseViewSet):
model = Module model = Module
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
@ -95,7 +99,6 @@ class ModuleViewSet(BaseViewSet):
class ModuleIssueViewSet(BaseViewSet): class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer serializer_class = ModuleIssueSerializer
model = ModuleIssue model = ModuleIssue
@ -148,29 +151,77 @@ class ModuleIssueViewSet(BaseViewSet):
workspace__slug=slug, project_id=project_id, pk=module_id workspace__slug=slug, project_id=project_id, pk=module_id
) )
issues = Issue.objects.filter( module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues))
pk__in=issues, workspace__slug=slug, project_id=project_id
)
# Delete old records in order to maintain the database integrity update_module_issue_activity = []
ModuleIssue.objects.filter(issue_id__in=issues).delete() 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.objects.bulk_create(
[ record_to_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
],
batch_size=10, batch_size=10,
ignore_conflicts=True, 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: except Module.DoesNotExist:
return Response( return Response(
{"error": "Module Does not exists"}, status=status.HTTP_400_BAD_REQUEST {"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): def validate_google_token(token, client_id):
try: try:
id_info = id_token.verify_oauth2_token( id_info = id_token.verify_oauth2_token(
token, google_auth_request.Request(), client_id 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) 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): class OauthEndpoint(BaseAPIView):
@ -116,7 +125,6 @@ class OauthEndpoint(BaseAPIView):
def post(self, request): def post(self, request):
try: try:
medium = request.data.get("medium", False) medium = request.data.get("medium", False)
id_token = request.data.get("credential", False) id_token = request.data.get("credential", False)
client_id = request.data.get("clientId", False) client_id = request.data.get("clientId", False)
@ -138,7 +146,6 @@ class OauthEndpoint(BaseAPIView):
email = data.get("email", None) email = data.get("email", None)
if email == None: if email == None:
return Response( return Response(
{ {
"error": "Something went wrong. Please try again later or contact the support team." "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 mobile_number = uuid.uuid4().hex
email_verified = True email_verified = True
else: else:
return Response( return Response(
{ {
"error": "Something went wrong. Please try again later or contact the support team." "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): def create(self, request, slug):
try: try:
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
serializer = ProjectSerializer( serializer = ProjectSerializer(
@ -96,6 +95,7 @@ class ProjectViewSet(BaseViewSet):
"color": "#5e6ad2", "color": "#5e6ad2",
"sequence": 15000, "sequence": 15000,
"group": "backlog", "group": "backlog",
"default": True,
}, },
{ {
"name": "Todo", "name": "Todo",
@ -132,6 +132,7 @@ class ProjectViewSet(BaseViewSet):
sequence=state["sequence"], sequence=state["sequence"],
workspace=serializer.instance.workspace, workspace=serializer.instance.workspace,
group=state["group"], group=state["group"],
default=state.get("default", False),
) )
for state in states for state in states
] ]
@ -188,7 +189,7 @@ class ProjectViewSet(BaseViewSet):
{"name": "The project name is already taken"}, {"name": "The project name is already taken"},
status=status.HTTP_410_GONE, status=status.HTTP_410_GONE,
) )
except (Project.DoesNotExist or Workspace.DoesNotExist) as e: except Project.DoesNotExist or Workspace.DoesNotExist as e:
return Response( return Response(
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
) )
@ -206,14 +207,12 @@ class ProjectViewSet(BaseViewSet):
class InviteProjectEndpoint(BaseAPIView): class InviteProjectEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectBasePermission, ProjectBasePermission,
] ]
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
try: try:
email = request.data.get("email", False) email = request.data.get("email", False)
role = request.data.get("role", False) role = request.data.get("role", False)
@ -287,7 +286,6 @@ class InviteProjectEndpoint(BaseAPIView):
class UserProjectInvitationsViewset(BaseViewSet): class UserProjectInvitationsViewset(BaseViewSet):
serializer_class = ProjectMemberInviteSerializer serializer_class = ProjectMemberInviteSerializer
model = ProjectMemberInvite model = ProjectMemberInvite
@ -301,7 +299,6 @@ class UserProjectInvitationsViewset(BaseViewSet):
def create(self, request): def create(self, request):
try: try:
invitations = request.data.get("invitations") invitations = request.data.get("invitations")
project_invitations = ProjectMemberInvite.objects.filter( project_invitations = ProjectMemberInvite.objects.filter(
pk__in=invitations, accepted=True pk__in=invitations, accepted=True
@ -331,7 +328,6 @@ class UserProjectInvitationsViewset(BaseViewSet):
class ProjectMemberViewSet(BaseViewSet): class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberSerializer serializer_class = ProjectMemberSerializer
model = ProjectMember model = ProjectMember
permission_classes = [ permission_classes = [
@ -356,14 +352,12 @@ class ProjectMemberViewSet(BaseViewSet):
class AddMemberToProjectEndpoint(BaseAPIView): class AddMemberToProjectEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectBasePermission, ProjectBasePermission,
] ]
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
try: try:
member_id = request.data.get("member_id", False) member_id = request.data.get("member_id", False)
role = request.data.get("role", False) role = request.data.get("role", False)
@ -412,13 +406,11 @@ class AddMemberToProjectEndpoint(BaseAPIView):
class AddTeamToProjectEndpoint(BaseAPIView): class AddTeamToProjectEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectBasePermission, ProjectBasePermission,
] ]
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
try: try:
team_members = TeamMember.objects.filter( team_members = TeamMember.objects.filter(
workspace__slug=slug, team__in=request.data.get("teams", []) workspace__slug=slug, team__in=request.data.get("teams", [])
@ -467,7 +459,6 @@ class AddTeamToProjectEndpoint(BaseAPIView):
class ProjectMemberInvitationsViewset(BaseViewSet): class ProjectMemberInvitationsViewset(BaseViewSet):
serializer_class = ProjectMemberInviteSerializer serializer_class = ProjectMemberInviteSerializer
model = ProjectMemberInvite model = ProjectMemberInvite
@ -489,7 +480,6 @@ class ProjectMemberInvitationsViewset(BaseViewSet):
class ProjectMemberInviteDetailViewSet(BaseViewSet): class ProjectMemberInviteDetailViewSet(BaseViewSet):
serializer_class = ProjectMemberInviteSerializer serializer_class = ProjectMemberInviteSerializer
model = ProjectMemberInvite model = ProjectMemberInvite
@ -509,14 +499,12 @@ class ProjectMemberInviteDetailViewSet(BaseViewSet):
class ProjectIdentifierEndpoint(BaseAPIView): class ProjectIdentifierEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectBasePermission, ProjectBasePermission,
] ]
def get(self, request, slug): def get(self, request, slug):
try: try:
name = request.GET.get("name", "").strip().upper() name = request.GET.get("name", "").strip().upper()
if name == "": if name == "":
@ -541,7 +529,6 @@ class ProjectIdentifierEndpoint(BaseAPIView):
def delete(self, request, slug): def delete(self, request, slug):
try: try:
name = request.data.get("name", "").strip().upper() name = request.data.get("name", "").strip().upper()
if name == "": if name == "":
@ -616,7 +603,6 @@ class ProjectJoinEndpoint(BaseAPIView):
class ProjectUserViewsEndpoint(BaseAPIView): class ProjectUserViewsEndpoint(BaseAPIView):
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
try: try:
project = Project.objects.get(pk=project_id, workspace__slug=slug) project = Project.objects.get(pk=project_id, workspace__slug=slug)
project_member = ProjectMember.objects.filter( project_member = ProjectMember.objects.filter(
@ -655,7 +641,6 @@ class ProjectUserViewsEndpoint(BaseAPIView):
class ProjectMemberUserEndpoint(BaseAPIView): class ProjectMemberUserEndpoint(BaseAPIView):
def get(self, request, slug, project_id): def get(self, request, slug, project_id):
try: try:
project_member = ProjectMember.objects.get( project_member = ProjectMember.objects.get(
project_id=project_id, workspace__slug=slug, member=request.user project_id=project_id, workspace__slug=slug, member=request.user
) )

View File

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

View File

@ -1,12 +1,27 @@
# Python imports # Python imports
import json import json
import requests
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports # Third Party imports
from django_rq import job from django_rq import job
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
# Module imports # 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 # Track Chnages in name
@ -44,7 +59,6 @@ def track_parent(
issue_activities, issue_activities,
): ):
if current_instance.get("parent") != requested_data.get("parent"): if current_instance.get("parent") != requested_data.get("parent"):
if requested_data.get("parent") == None: if requested_data.get("parent") == None:
old_parent = Issue.objects.get(pk=current_instance.get("parent")) old_parent = Issue.objects.get(pk=current_instance.get("parent"))
issue_activities.append( issue_activities.append(
@ -134,7 +148,6 @@ def track_state(
issue_activities, issue_activities,
): ):
if current_instance.get("state") != requested_data.get("state"): if current_instance.get("state") != requested_data.get("state"):
new_state = State.objects.get(pk=requested_data.get("state", None)) new_state = State.objects.get(pk=requested_data.get("state", None))
old_state = State.objects.get(pk=current_instance.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( if current_instance.get("description_html") != requested_data.get(
"description_html" "description_html"
): ):
issue_activities.append( issue_activities.append(
IssueActivity( IssueActivity(
issue_id=issue_id, issue_id=issue_id,
@ -274,7 +286,6 @@ def track_labels(
): ):
# Label Addition # Label Addition
if len(requested_data.get("labels_list")) > len(current_instance.get("labels")): if len(requested_data.get("labels_list")) > len(current_instance.get("labels")):
for label in requested_data.get("labels_list"): for label in requested_data.get("labels_list"):
if label not in current_instance.get("labels"): if label not in current_instance.get("labels"):
label = Label.objects.get(pk=label) label = Label.objects.get(pk=label)
@ -296,7 +307,6 @@ def track_labels(
# Label Removal # Label Removal
if len(requested_data.get("labels_list")) < len(current_instance.get("labels")): if len(requested_data.get("labels_list")) < len(current_instance.get("labels")):
for label in current_instance.get("labels"): for label in current_instance.get("labels"):
if label not in requested_data.get("labels_list"): if label not in requested_data.get("labels_list"):
label = Label.objects.get(pk=label) label = Label.objects.get(pk=label)
@ -326,12 +336,10 @@ def track_assignees(
actor, actor,
issue_activities, issue_activities,
): ):
# Assignee Addition # Assignee Addition
if len(requested_data.get("assignees_list")) > len( if len(requested_data.get("assignees_list")) > len(
current_instance.get("assignees") current_instance.get("assignees")
): ):
for assignee in requested_data.get("assignees_list"): for assignee in requested_data.get("assignees_list"):
if assignee not in current_instance.get("assignees"): if assignee not in current_instance.get("assignees"):
assignee = User.objects.get(pk=assignee) assignee = User.objects.get(pk=assignee)
@ -354,7 +362,6 @@ def track_assignees(
if len(requested_data.get("assignees_list")) < len( if len(requested_data.get("assignees_list")) < len(
current_instance.get("assignees") current_instance.get("assignees")
): ):
for assignee in current_instance.get("assignees"): for assignee in current_instance.get("assignees"):
if assignee not in requested_data.get("assignees_list"): if assignee not in requested_data.get("assignees_list"):
assignee = User.objects.get(pk=assignee) assignee = User.objects.get(pk=assignee)
@ -386,7 +393,6 @@ def track_blocks(
if len(requested_data.get("blocks_list")) > len( if len(requested_data.get("blocks_list")) > len(
current_instance.get("blocked_issues") current_instance.get("blocked_issues")
): ):
for block in requested_data.get("blocks_list"): for block in requested_data.get("blocks_list"):
if ( if (
len( len(
@ -418,7 +424,6 @@ def track_blocks(
if len(requested_data.get("blocks_list")) < len( if len(requested_data.get("blocks_list")) < len(
current_instance.get("blocked_issues") current_instance.get("blocked_issues")
): ):
for blocked in current_instance.get("blocked_issues"): for blocked in current_instance.get("blocked_issues"):
if blocked.get("block") not in requested_data.get("blocks_list"): if blocked.get("block") not in requested_data.get("blocks_list"):
issue = Issue.objects.get(pk=blocked.get("block")) issue = Issue.objects.get(pk=blocked.get("block"))
@ -450,7 +455,6 @@ def track_blockings(
if len(requested_data.get("blockers_list")) > len( if len(requested_data.get("blockers_list")) > len(
current_instance.get("blocker_issues") current_instance.get("blocker_issues")
): ):
for block in requested_data.get("blockers_list"): for block in requested_data.get("blockers_list"):
if ( if (
len( len(
@ -482,7 +486,6 @@ def track_blockings(
if len(requested_data.get("blockers_list")) < len( if len(requested_data.get("blockers_list")) < len(
current_instance.get("blocker_issues") current_instance.get("blocker_issues")
): ):
for blocked in 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"): if blocked.get("blocked_by") not in requested_data.get("blockers_list"):
issue = Issue.objects.get(pk=blocked.get("blocked_by")) 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 # Receive message from room group
@job("default") @job("default")
def issue_activity(event): def issue_activity(event):
try: try:
issue_activities = [] issue_activities = []
type = event.get("type")
requested_data = json.loads(event.get("requested_data")) requested_data = json.loads(event.get("requested_data"))
current_instance = json.loads(event.get("current_instance")) current_instance = (
issue_id = event.get("issue_id") 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") actor_id = event.get("actor_id")
project_id = event.get("project_id") project_id = event.get("project_id")
@ -518,35 +756,43 @@ def issue_activity(event):
project = Project.objects.get(pk=project_id) project = Project.objects.get(pk=project_id)
ISSUE_ACTIVITY_MAPPER = { ACTIVITY_MAPPER = {
"name": track_name, "issue.activity.created": create_issue_activity,
"parent": track_parent, "issue.activity.updated": update_issue_activity,
"priority": track_priority, "issue.activity.deleted": delete_issue_activity,
"state": track_state, "comment.activity.created": create_comment_activity,
"description": track_description, "comment.activity.updated": update_comment_activity,
"target_date": track_target_date, "comment.activity.deleted": delete_comment_activity,
"start_date": track_start_date,
"labels_list": track_labels,
"assignees_list": track_assignees,
"blocks_list": track_blocks,
"blockers_list": track_blockings,
} }
for key in requested_data: func = ACTIVITY_MAPPER.get(type)
func = ISSUE_ACTIVITY_MAPPER.get(key, None) if func is not None:
if func is not None: func(
func( requested_data,
requested_data, current_instance,
current_instance, issue_id,
issue_id, project,
project, actor,
actor, issue_activities,
issue_activities, )
)
# Save all the values to database # 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 return
except Exception as e: except Exception as e:
capture_exception(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 from django.db import models

View File

@ -10,7 +10,13 @@ from .workspace import (
TeamMember, TeamMember,
) )
from .project import Project, ProjectMember, ProjectBaseModel, ProjectMemberInvite, ProjectIdentifier from .project import (
Project,
ProjectMember,
ProjectBaseModel,
ProjectMemberInvite,
ProjectIdentifier,
)
from .issue import ( from .issue import (
Issue, Issue,
@ -23,6 +29,7 @@ from .issue import (
IssueAssignee, IssueAssignee,
Label, Label,
IssueBlocker, IssueBlocker,
IssueLink,
) )
from .asset import FileAsset from .asset import FileAsset
@ -37,6 +44,15 @@ from .shortcut import Shortcut
from .view import View 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): class APIToken(BaseModel):
token = models.CharField(max_length=255, unique=True, default=generate_token) token = models.CharField(max_length=255, unique=True, default=generate_token)
label = models.CharField(max_length=255, default=generate_label_token) label = models.CharField(max_length=255, default=generate_label_token)
user = models.ForeignKey( user = models.ForeignKey(
@ -28,6 +27,9 @@ class APIToken(BaseModel):
user_type = models.PositiveSmallIntegerField( user_type = models.PositiveSmallIntegerField(
choices=((0, "Human"), (1, "Bot")), default=0 choices=((0, "Human"), (1, "Bot")), default=0
) )
workspace = models.ForeignKey(
"db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True
)
class Meta: class Meta:
verbose_name = "API Token" 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.conf import settings
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone
# Module imports # Module imports
from . import ProjectBaseModel from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags from plane.utils.html_processor import strip_tags
# TODO: Handle identifiers for Bulk Inserts - nk # TODO: Handle identifiers for Bulk Inserts - nk
class Issue(ProjectBaseModel): class Issue(ProjectBaseModel):
PRIORITY_CHOICES = ( PRIORITY_CHOICES = (
@ -32,8 +34,8 @@ class Issue(ProjectBaseModel):
related_name="state_issue", related_name="state_issue",
) )
name = models.CharField(max_length=255, verbose_name="Issue Name") name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, null=True) description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, null=True) description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True) description_stripped = models.TextField(blank=True, null=True)
priority = models.CharField( priority = models.CharField(
max_length=30, max_length=30,
@ -56,6 +58,8 @@ class Issue(ProjectBaseModel):
labels = models.ManyToManyField( labels = models.ManyToManyField(
"db.Label", blank=True, related_name="labels", through="IssueLabel" "db.Label", blank=True, related_name="labels", through="IssueLabel"
) )
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
class Meta: class Meta:
verbose_name = "Issue" verbose_name = "Issue"
@ -65,6 +69,36 @@ class Issue(ProjectBaseModel):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# This means that the model isn't saved to the database yet # 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: if self._state.adding:
# Get the maximum display_id value from the database # 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 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: if last_id is not None:
self.sequence_id = last_id + 1 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( largest_sort_order = Issue.objects.filter(
project=self.project, name="Backlog" project=self.project, state=self.state
) ).aggregate(largest=models.Max("sort_order"))["largest"]
except ImportError: if largest_sort_order is not None:
pass self.sort_order = largest_sort_order + 10000
# Strip the html tags using html parser # Strip the html tags using html parser
self.description_stripped = ( self.description_stripped = (
@ -137,9 +168,26 @@ class IssueAssignee(ProjectBaseModel):
return f"{self.issue.name} {self.assignee.email}" return f"{self.issue.name} {self.assignee.email}"
class IssueLink(ProjectBaseModel):
title = models.CharField(max_length=255, null=True)
url = models.URLField()
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="issue_link"
)
class Meta:
verbose_name = "Issue Link"
verbose_name_plural = "Issue Links"
db_table = "issue_links"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.url}"
class IssueActivity(ProjectBaseModel): class IssueActivity(ProjectBaseModel):
issue = models.ForeignKey( issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_activity" Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity"
) )
verb = models.CharField(max_length=255, verbose_name="Action", default="created") verb = models.CharField(max_length=255, verbose_name="Action", default="created")
field = models.CharField( field = models.CharField(
@ -196,8 +244,8 @@ class TimelineIssue(ProjectBaseModel):
class IssueComment(ProjectBaseModel): class IssueComment(ProjectBaseModel):
comment_stripped = models.TextField(verbose_name="Comment", blank=True) comment_stripped = models.TextField(verbose_name="Comment", blank=True)
comment_json = models.JSONField(blank=True, null=True) comment_json = models.JSONField(blank=True, default=dict)
comment_html = models.TextField(blank=True) comment_html = models.TextField(blank=True, default="<p></p>")
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
issue = models.ForeignKey(Issue, on_delete=models.CASCADE) issue = models.ForeignKey(Issue, on_delete=models.CASCADE)
# System can also create comment # System can also create comment
@ -246,7 +294,6 @@ class IssueProperty(ProjectBaseModel):
class Label(ProjectBaseModel): class Label(ProjectBaseModel):
parent = models.ForeignKey( parent = models.ForeignKey(
"self", "self",
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -256,7 +303,7 @@ class Label(ProjectBaseModel):
) )
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
description = models.TextField(blank=True) description = models.TextField(blank=True)
colour = models.CharField(max_length=255, blank=True) color = models.CharField(max_length=255, blank=True)
class Meta: class Meta:
verbose_name = "Label" verbose_name = "Label"
@ -269,7 +316,6 @@ class Label(ProjectBaseModel):
class IssueLabel(ProjectBaseModel): class IssueLabel(ProjectBaseModel):
issue = models.ForeignKey( issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="label_issue" "db.Issue", on_delete=models.CASCADE, related_name="label_issue"
) )
@ -288,7 +334,6 @@ class IssueLabel(ProjectBaseModel):
class IssueSequence(ProjectBaseModel): class IssueSequence(ProjectBaseModel):
issue = models.ForeignKey( issue = models.ForeignKey(
Issue, on_delete=models.SET_NULL, related_name="issue_sequence", null=True 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 # TODO: Find a better method to save the model
@receiver(post_save, sender=Issue) @receiver(post_save, sender=Issue)
def create_issue_sequence(sender, instance, created, **kwargs): def create_issue_sequence(sender, instance, created, **kwargs):
if created: if created:
IssueSequence.objects.create( IssueSequence.objects.create(
issue=instance, sequence=instance.sequence_id, project=instance.project issue=instance, sequence=instance.sequence_id, project=instance.project

View File

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

View File

@ -23,6 +23,7 @@ class State(ProjectBaseModel):
default="backlog", default="backlog",
max_length=20, max_length=20,
) )
default = models.BooleanField(default=False)
def __str__(self): def __str__(self):
"""Return name of the state""" """Return name of the state"""
@ -37,4 +38,13 @@ class State(ProjectBaseModel):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.slug = slugify(self.name) 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) return super().save(*args, **kwargs)

View File

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

View File

@ -2,6 +2,7 @@
from __future__ import absolute_import from __future__ import absolute_import
import dj_database_url
import sentry_sdk import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration from sentry_sdk.integrations.redis import RedisIntegration
@ -24,6 +25,10 @@ DATABASES = {
} }
} }
DOCKERIZED = os.environ.get("DOCKERIZED", False)
if DOCKERIZED:
DATABASES["default"] = dj_database_url.config()
CACHES = { CACHES = {
"default": { "default": {
@ -41,15 +46,16 @@ INTERNAL_IPS = ("127.0.0.1",)
CORS_ORIGIN_ALLOW_ALL = True CORS_ORIGIN_ALLOW_ALL = True
sentry_sdk.init( if os.environ.get("SENTRY_DSN", False):
dsn=os.environ.get("SENTRY_DSN"), sentry_sdk.init(
integrations=[DjangoIntegration(), RedisIntegration()], dsn=os.environ.get("SENTRY_DSN"),
# If you wish to associate users to errors (assuming you are using integrations=[DjangoIntegration(), RedisIntegration()],
# django.contrib.auth) you may enable sending PII data. # If you wish to associate users to errors (assuming you are using
send_default_pii=True, # django.contrib.auth) you may enable sending PII data.
environment="local", send_default_pii=True,
traces_sample_rate=0.7, environment="local",
) traces_sample_rate=0.7,
)
REDIS_HOST = "localhost" REDIS_HOST = "localhost"
REDIS_PORT = 6379 REDIS_PORT = 6379
@ -64,5 +70,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() DATABASES["default"] = dj_database_url.config()
SITE_ID = 1 SITE_ID = 1
DOCKERIZED = os.environ.get(
"DOCKERIZED", False
) # Set the variable true if running in docker-compose environment
# Enable Connection Pooling (if desired) # Enable Connection Pooling (if desired)
# DATABASES['default']['ENGINE'] = 'django_postgrespool' # DATABASES['default']['ENGINE'] = 'django_postgrespool'
@ -48,99 +52,110 @@ CORS_ALLOW_ALL_ORIGINS = True
# Simplified static file serving. # Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
if os.environ.get("SENTRY_DSN", False):
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN", ""),
integrations=[DjangoIntegration(), RedisIntegration()],
# If you wish to associate users to errors (assuming you are using
# django.contrib.auth) you may enable sending PII data.
traces_sample_rate=1,
send_default_pii=True,
environment="production",
)
sentry_sdk.init( if (
dsn=os.environ.get("SENTRY_DSN"), os.environ.get("AWS_REGION", False)
integrations=[DjangoIntegration(), RedisIntegration()], and os.environ.get("AWS_ACCESS_KEY_ID", False)
# If you wish to associate users to errors (assuming you are using and os.environ.get("AWS_SECRET_ACCESS_KEY", False)
# django.contrib.auth) you may enable sending PII data. and os.environ.get("AWS_S3_BUCKET_NAME", False)
traces_sample_rate=1, ):
send_default_pii=True, # The AWS region to connect to.
environment="production", AWS_REGION = os.environ.get("AWS_REGION", "")
)
# The AWS region to connect to. # The AWS access key to use.
AWS_REGION = os.environ.get("AWS_REGION") AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
# The AWS access key to use. # The AWS secret access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
# The AWS secret access key to use. # The optional AWS session token to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY") # AWS_SESSION_TOKEN = ""
# The optional AWS session token to use. # The name of the bucket to store files in.
# AWS_SESSION_TOKEN = "" AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "")
# How to construct S3 URLs ("auto", "path", "virtual").
AWS_S3_ADDRESSING_STYLE = "auto"
# The name of the bucket to store files in. # The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") AWS_S3_ENDPOINT_URL = ""
# How to construct S3 URLs ("auto", "path", "virtual"). # A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
AWS_S3_ADDRESSING_STYLE = "auto" AWS_S3_KEY_PREFIX = ""
# The full URL to the S3 endpoint. Leave blank to use the default region URL. # Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
AWS_S3_ENDPOINT_URL = "" # token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
# and their permissions will be set to "public-read".
AWS_S3_BUCKET_AUTH = False
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. # How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
AWS_S3_KEY_PREFIX = "" # is True. It also affects the "Cache-Control" header of the files.
# Important: Changing this setting will not affect existing files.
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication # A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, # cannot be used with `AWS_S3_BUCKET_AUTH`.
# and their permissions will be set to "public-read". AWS_S3_PUBLIC_URL = ""
AWS_S3_BUCKET_AUTH = False
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` # If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
# is True. It also affects the "Cache-Control" header of the files. # understand the consequences before enabling.
# Important: Changing this setting will not affect existing files. # Important: Changing this setting will not affect existing files.
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours. AWS_S3_REDUCED_REDUNDANCY = False
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting # The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
# cannot be used with `AWS_S3_BUCKET_AUTH`. # single `name` argument.
AWS_S3_PUBLIC_URL = "" # Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_DISPOSITION = ""
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you # The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
# understand the consequences before enabling. # single `name` argument.
# Important: Changing this setting will not affect existing files. # Important: Changing this setting will not affect existing files.
AWS_S3_REDUCED_REDUNDANCY = False AWS_S3_CONTENT_LANGUAGE = ""
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a # A mapping of custom metadata for each file. Each value can be a string, or a function taking a
# single `name` argument. # single `name` argument.
# Important: Changing this setting will not affect existing files. # Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_DISPOSITION = "" AWS_S3_METADATA = {}
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a # If True, then files will be stored using AES256 server-side encryption.
# single `name` argument. # If this is a string value (e.g., "aws:kms"), that encryption type will be used.
# Important: Changing this setting will not affect existing files. # Otherwise, server-side encryption is not be enabled.
AWS_S3_CONTENT_LANGUAGE = "" # Important: Changing this setting will not affect existing files.
AWS_S3_ENCRYPT_KEY = False
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a # The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
# single `name` argument. # This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
# Important: Changing this setting will not affect existing files. # AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
AWS_S3_METADATA = {}
# If True, then files will be stored using AES256 server-side encryption. # If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
# If this is a string value (e.g., "aws:kms"), that encryption type will be used. # compressed size is smaller than their uncompressed size.
# Otherwise, server-side encryption is not be enabled. # Important: Changing this setting will not affect existing files.
# Important: Changing this setting will not affect existing files. AWS_S3_GZIP = True
AWS_S3_ENCRYPT_KEY = False
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present. # The signature version to use for S3 requests.
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above). AWS_S3_SIGNATURE_VERSION = None
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their # If True, then files with the same name will overwrite each other. By default it's set to False to have
# compressed size is smaller than their uncompressed size. # extra characters appended.
# Important: Changing this setting will not affect existing files. AWS_S3_FILE_OVERWRITE = False
AWS_S3_GZIP = True
# The signature version to use for S3 requests. # AWS Settings End
AWS_S3_SIGNATURE_VERSION = None
# If True, then files with the same name will overwrite each other. By default it's set to False to have DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
# extra characters appended.
AWS_S3_FILE_OVERWRITE = False
# AWS Settings End else:
MEDIA_URL = "/uploads/"
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
# Enable Connection Pooling (if desired) # Enable Connection Pooling (if desired)
@ -155,7 +170,6 @@ ALLOWED_HOSTS = [
] ]
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
# Simplified static file serving. # Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
@ -165,16 +179,27 @@ CSRF_COOKIE_SECURE = True
REDIS_URL = os.environ.get("REDIS_URL") REDIS_URL = os.environ.get("REDIS_URL")
CACHES = { if DOCKERIZED:
"default": { CACHES = {
"BACKEND": "django_redis.cache.RedisCache", "default": {
"LOCATION": REDIS_URL, "BACKEND": "django_redis.cache.RedisCache",
"OPTIONS": { "LOCATION": REDIS_URL,
"CLIENT_CLASS": "django_redis.client.DefaultClient", "OPTIONS": {
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, "CLIENT_CLASS": "django_redis.client.DefaultClient",
}, },
}
}
else:
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
},
}
} }
}
RQ_QUEUES = { RQ_QUEUES = {
"default": { "default": {
@ -183,10 +208,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") 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") 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 # base requirements
Django==3.2.16 Django==3.2.18
django-braces==1.15.0 django-braces==1.15.0
django-taggit==2.1.0 django-taggit==3.1.0
psycopg2==2.9.3 psycopg2==2.9.5
django-oauth-toolkit==2.0.0 django-oauth-toolkit==2.2.0
mistune==2.0.3 mistune==2.0.4
djangorestframework==3.14.0 djangorestframework==3.14.0
redis==4.2.2 redis==4.4.2
django-nested-admin==3.4.0 django-nested-admin==4.0.2
django-cors-headers==3.11.0 django-cors-headers==3.13.0
whitenoise==6.0.0 whitenoise==6.3.0
django-allauth==0.50.0 django-allauth==0.52.0
faker==13.4.0 faker==13.4.0
django-filter==21.1 django-filter==22.1
jsonmodels==2.5.0 jsonmodels==2.6.0
djangorestframework-simplejwt==5.1.0 djangorestframework-simplejwt==5.2.2
sentry-sdk==1.13.0 sentry-sdk==1.14.0
django-s3-storage==0.13.6 django-s3-storage==0.13.11
django-crum==0.7.9 django-crum==0.7.9
django-guardian==2.4.0 django-guardian==2.4.0
dj_rest_auth==2.2.5 dj_rest_auth==2.2.5
google-auth==2.9.1 google-auth==2.16.0
google-api-python-client==2.55.0 google-api-python-client==2.75.0
django-rq==2.5.1 django-rq==2.6.0
django-redis==5.2.0 django-redis==5.2.0
uvicorn==0.20.0 uvicorn==0.20.0
channels==4.0.0

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,7 +34,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
return ( return (
<Link <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"> <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 <Image

View File

@ -15,7 +15,7 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
<> <>
<div className="flex items-center"> <div className="flex items-center">
<div <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()} onClick={() => router.back()}
> >
<ArrowLeftIcon className="h-3 w-3" /> <ArrowLeftIcon className="h-3 w-3" />
@ -44,10 +44,10 @@ const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) =>
</a> </a>
</Link> </Link>
) : ( ) : (
<div className="px-3 text-sm"> <div className="px-3 text-sm max-w-64">
<p className={`${icon ? "flex items-center gap-2" : ""}`}> <p className={`${icon ? "flex items-center gap-2" : ""}`}>
{icon} {icon}
{title} <span className="break-all">{title}</span>
</p> </p>
</div> </div>
)} )}

View File

@ -1,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"; import React, { useState, useCallback, useEffect } from "react";
// next
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// swr
import useSWR from "swr"; import useSWR from "swr";
// hooks
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react"; 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 { import {
FolderIcon, FolderIcon,
RectangleStackIcon, RectangleStackIcon,
ClipboardDocumentListIcon, ClipboardDocumentListIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
} from "@heroicons/react/24/outline"; } 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 // helpers
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
// ui
import { Button } from "components/ui";
// icons
// fetch-keys // fetch-keys
import { USER_ISSUE } from "constants/fetch-keys"; import { USER_ISSUE } from "constants/fetch-keys";
const CommandPalette: React.FC = () => { export const CommandPalette: React.FC = () => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isPaletteOpen, setIsPaletteOpen] = useState(false); const [isPaletteOpen, setIsPaletteOpen] = useState(false);
@ -74,7 +74,7 @@ const CommandPalette: React.FC = () => {
name: "Add new issue...", name: "Add new issue...",
icon: RectangleStackIcon, icon: RectangleStackIcon,
hide: !projectId, hide: !projectId,
shortcut: "I", shortcut: "C",
onClick: () => { onClick: () => {
setIsIssueModalOpen(true); setIsIssueModalOpen(true);
}, },
@ -102,16 +102,15 @@ const CommandPalette: React.FC = () => {
!(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLInputElement) &&
!(e.target as Element).classList?.contains("remirror-editor") !(e.target as Element).classList?.contains("remirror-editor")
) { ) {
if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault(); e.preventDefault();
setIsPaletteOpen(true); setIsPaletteOpen(true);
} else if (e.ctrlKey && (e.key === "c" || e.key === "C")) { } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
if (e.altKey) { if (e.altKey) {
e.preventDefault(); e.preventDefault();
if (!router.query.issueId) return; if (!router.query.issueId) return;
const url = new URL(window.location.href); const url = new URL(window.location.href);
console.log(url);
copyTextToClipboard(url.href) copyTextToClipboard(url.href)
.then(() => { .then(() => {
setToastAlert({ setToastAlert({
@ -125,26 +124,23 @@ const CommandPalette: React.FC = () => {
title: "Some error occurred", title: "Some error occurred",
}); });
}); });
console.log("URL Copied");
} else {
console.log("Text copied");
} }
} else if (e.key === "c" || e.key === "C") { } else if (e.key.toLowerCase() === "c") {
e.preventDefault(); e.preventDefault();
setIsIssueModalOpen(true); setIsIssueModalOpen(true);
} else if (e.key === "p" || e.key === "P") { } else if (e.key.toLowerCase() === "p") {
e.preventDefault(); e.preventDefault();
setIsProjectModalOpen(true); setIsProjectModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && (e.key === "b" || e.key === "B")) { } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") {
e.preventDefault(); e.preventDefault();
toggleCollapsed(); toggleCollapsed();
} else if (e.key === "h" || e.key === "H") { } else if (e.key.toLowerCase() === "h") {
e.preventDefault(); e.preventDefault();
setIsShortcutsModalOpen(true); setIsShortcutsModalOpen(true);
} else if (e.key === "q" || e.key === "Q") { } else if (e.key.toLowerCase() === "q") {
e.preventDefault(); e.preventDefault();
setIsCreateCycleModalOpen(true); setIsCreateCycleModalOpen(true);
} else if (e.key === "m" || e.key === "M") { } else if (e.key.toLowerCase() === "m") {
e.preventDefault(); e.preventDefault();
setIsCreateModuleModalOpen(true); setIsCreateModuleModalOpen(true);
} else if (e.key === "Delete") { } else if (e.key === "Delete") {
@ -173,13 +169,11 @@ const CommandPalette: React.FC = () => {
<> <>
<CreateUpdateCycleModal <CreateUpdateCycleModal
isOpen={isCreateCycleModalOpen} isOpen={isCreateCycleModalOpen}
setIsOpen={setIsCreateCycleModalOpen} handleClose={() => setIsCreateCycleModalOpen(false)}
projectId={projectId as string}
/> />
<CreateUpdateModuleModal <CreateUpdateModuleModal
isOpen={isCreateModuleModalOpen} isOpen={isCreateModuleModalOpen}
setIsOpen={setIsCreateModuleModalOpen} 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-auto truncate">{action.name}</span>
<span className="ml-3 flex-none text-xs font-semibold text-gray-500"> <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> <kbd className="font-sans">{action.shortcut}</kbd>
</span> </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 // headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// icons // icons
@ -15,7 +15,7 @@ const shortcuts = [
{ {
title: "Navigation", title: "Navigation",
shortcuts: [ shortcuts: [
{ keys: "Ctrl,Cmd,K", description: "To open navigator" }, { keys: "Ctrl,/,Cmd,K", description: "To open navigator" },
{ keys: "↑", description: "Move up" }, { keys: "↑", description: "Move up" },
{ keys: "↓", description: "Move down" }, { keys: "↓", description: "Move down" },
{ keys: "←", description: "Move left" }, { keys: "←", description: "Move left" },
@ -34,22 +34,27 @@ const shortcuts = [
{ keys: "Delete", description: "To bulk delete issues" }, { keys: "Delete", description: "To bulk delete issues" },
{ keys: "H", description: "To open shortcuts guide" }, { keys: "H", description: "To open shortcuts guide" },
{ {
keys: "Ctrl,Cmd,Alt,C", keys: "Ctrl,/,Cmd,Alt,C",
description: "To copy issue url when on issue detail page.", description: "To copy issue url when on issue detail page.",
}, },
], ],
}, },
]; ];
const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => { const allShortcuts = shortcuts.map((i) => i.shortcuts).flat(1);
const [query, setQuery] = useState("");
const filteredShortcuts = shortcuts.filter((shortcut) => export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
shortcut.shortcuts.some((item) => item.description.includes(query.trim())) || query === "" const [query, setQuery] = useState("");
const filteredShortcuts = allShortcuts.filter((shortcut) =>
shortcut.description.toLowerCase().includes(query.trim().toLowerCase()) || query === ""
? true ? true
: false : false
); );
useEffect(() => {
if (!isOpen) setQuery("");
}, [isOpen]);
return ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={setIsOpen}> <Dialog as="div" className="relative z-20" onClose={setIsOpen}>
@ -104,8 +109,40 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
/> />
</div> </div>
<div className="flex w-full flex-col gap-y-3"> <div className="flex w-full flex-col gap-y-3">
{filteredShortcuts.length > 0 ? ( {query.trim().length > 0 ? (
filteredShortcuts.map(({ title, shortcuts }) => ( filteredShortcuts.length > 0 ? (
filteredShortcuts.map((shortcut) => (
<div key={shortcut.keys} className="flex w-full flex-col">
<div className="flex flex-col gap-y-3">
<div className="flex justify-between">
<p className="text-sm text-gray-500">{shortcut.description}</p>
<div className="flex items-center gap-x-1">
{shortcut.keys.split(",").map((key, index) => (
<span key={index} className="flex items-center gap-1">
<kbd className="rounded bg-gray-200 px-1 text-sm">
{key}
</kbd>
</span>
))}
</div>
</div>
</div>
</div>
))
) : (
<div className="flex flex-col gap-y-3">
<p className="text-sm text-gray-500">
No shortcuts found for{" "}
<span className="font-semibold italic">
{`"`}
{query}
{`"`}
</span>
</p>
</div>
)
) : (
shortcuts.map(({ title, shortcuts }) => (
<div key={title} className="flex w-full flex-col"> <div key={title} className="flex w-full flex-col">
<p className="mb-4 font-medium">{title}</p> <p className="mb-4 font-medium">{title}</p>
<div className="flex flex-col gap-y-3"> <div className="flex flex-col gap-y-3">
@ -126,17 +163,6 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
</div> </div>
</div> </div>
)) ))
) : (
<div className="flex flex-col gap-y-3">
<p className="text-sm text-gray-500">
No shortcuts found for{" "}
<span className="font-semibold italic">
{`"`}
{query}
{`"`}
</span>
</p>
</div>
)} )}
</div> </div>
</div> </div>
@ -150,5 +176,3 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
</Transition.Root> </Transition.Root>
); );
}; };
export default ShortcutsModal;

View File

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

View File

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

View File

@ -17,13 +17,13 @@ import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types // types
import { IIssue, Properties } from "types"; import { IIssue, Properties } from "types";
// common // common
import { filterIssueOptions, groupByOptions, orderByOptions } from "constants/"; import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
type Props = { type Props = {
issues?: IIssue[]; issues?: IIssue[];
}; };
const View: React.FC<Props> = ({ issues }) => { export const IssuesFilterView: React.FC<Props> = ({ issues }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; 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> <h4 className="text-sm text-gray-600">Group by</h4>
<CustomMenu <CustomMenu
label={ label={
groupByOptions.find((option) => option.key === groupByProperty) GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
?.name ?? "Select" ?.name ?? "Select"
} }
width="lg" width="lg"
> >
{groupByOptions.map((option) => ( {GROUP_BY_OPTIONS.map((option) =>
<CustomMenu.MenuItem issueView === "kanban" && option.key === null ? null : (
key={option.key} <CustomMenu.MenuItem
onClick={() => setGroupByProperty(option.key)} key={option.key}
> onClick={() => setGroupByProperty(option.key)}
{option.name} >
</CustomMenu.MenuItem> {option.name}
))} </CustomMenu.MenuItem>
)
)}
</CustomMenu> </CustomMenu>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Order by</h4> <h4 className="text-sm text-gray-600">Order by</h4>
<CustomMenu <CustomMenu
label={ label={
orderByOptions.find((option) => option.key === orderBy)?.name ?? ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select" "Select"
} }
width="lg" width="lg"
> >
{orderByOptions.map((option) => {ORDER_BY_OPTIONS.map((option) =>
groupByProperty === "priority" && groupByProperty === "priority" &&
option.key === "priority" ? null : ( option.key === "priority" ? null : (
<CustomMenu.MenuItem <CustomMenu.MenuItem
key={option.key} key={option.key}
onClick={() => setOrderBy(option.key)} onClick={() => {
setOrderBy(option.key);
}}
> >
{option.name} {option.name}
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
@ -140,12 +144,12 @@ const View: React.FC<Props> = ({ issues }) => {
<h4 className="text-sm text-gray-600">Issue type</h4> <h4 className="text-sm text-gray-600">Issue type</h4>
<CustomMenu <CustomMenu
label={ label={
filterIssueOptions.find((option) => option.key === filterIssue) FILTER_ISSUE_OPTIONS.find((option) => option.key === filterIssue)
?.name ?? "Select" ?.name ?? "Select"
} }
width="lg" width="lg"
> >
{filterIssueOptions.map((option) => ( {FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem <CustomMenu.MenuItem
key={option.key} key={option.key}
onClick={() => setFilterIssue(option.key)} onClick={() => setFilterIssue(option.key)}
@ -176,20 +180,29 @@ const View: React.FC<Props> = ({ issues }) => {
<div className="space-y-2 py-3"> <div className="space-y-2 py-3">
<h4 className="text-sm text-gray-600">Display Properties</h4> <h4 className="text-sm text-gray-600">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => ( {Object.keys(properties).map((key) => {
<button if (
key={key} issueView === "kanban" &&
type="button" ((groupByProperty === "state_detail.name" && key === "state") ||
className={`rounded border px-2 py-1 text-xs capitalize ${ (groupByProperty === "priority" && key === "priority"))
properties[key as keyof Properties] )
? "border-theme bg-theme text-white" return;
: "border-gray-300"
}`} return (
onClick={() => setProperties(key as keyof Properties)} <button
> key={key}
{replaceUnderscoreIfSnakeCase(key)} type="button"
</button> className={`rounded border px-2 py-1 text-xs capitalize ${
))} properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: "border-gray-300"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div> </div>
</div> </div>
</div> </div>
@ -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"; import { mutate } from "swr";
// react-hook-form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// hooks
// types
import type { IModule, ModuleLink } from "types";
// services
import modulesService from "services/modules.service";
// ui // ui
import { Button, Input } from "components/ui"; import { Button, Input } from "components/ui";
// fetch-keys // types
import { MODULE_LIST } from "constants/fetch-keys"; import type { IIssueLink, ModuleLink } from "types";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
module: IModule | undefined;
handleClose: () => void; handleClose: () => void;
onFormSubmit: (formData: IIssueLink | ModuleLink) => void;
}; };
const defaultValues: ModuleLink = { const defaultValues: ModuleLink = {
@ -28,47 +24,20 @@ const defaultValues: ModuleLink = {
url: "", url: "",
}; };
const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => { export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }) => {
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
const { const {
register, register,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
handleSubmit, handleSubmit,
reset, reset,
setError,
} = useForm<ModuleLink>({ } = useForm<ModuleLink>({
defaultValues, defaultValues,
}); });
const onSubmit = async (formData: ModuleLink) => { const onSubmit = async (formData: ModuleLink) => {
if (!workspaceSlug || !projectId || !module) return; await onFormSubmit(formData);
const previousLinks = module.link_module.map((l) => ({ title: l.title, url: l.url })); onClose();
const payload: Partial<IModule> = {
links_list: [...previousLinks, formData],
};
await modulesService
.patchModule(workspaceSlug as string, projectId as string, 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(", "),
});
});
});
}; };
const onClose = () => { const onClose = () => {
@ -113,21 +82,6 @@ const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
Add Link Add Link
</Dialog.Title> </Dialog.Title>
<div className="mt-2 space-y-3"> <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> <div>
<Input <Input
id="url" id="url"
@ -143,6 +97,21 @@ const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
}} }}
/> />
</div> </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> </div>
</div> </div>
@ -163,5 +132,3 @@ const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
</Transition.Root> </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 // react
import { useState } from "react"; import { useState } from "react";
// components // components
import SingleStat from "components/project/cycles/stats-view/single-stat"; import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
import ConfirmCycleDeletion from "components/project/cycles/confirm-cycle-deletion";
// types // types
import { ICycle, SelectCycleType } from "types"; import { ICycle, SelectCycleType } from "types";
import { CompletedCycleIcon, CurrentCycleIcon, UpcomingCycleIcon } from "components/icons"; import { CompletedCycleIcon, CurrentCycleIcon, UpcomingCycleIcon } from "components/icons";
@ -14,7 +13,7 @@ type TCycleStatsViewProps = {
type: "current" | "upcoming" | "completed"; type: "current" | "upcoming" | "completed";
}; };
const CycleStatsView: React.FC<TCycleStatsViewProps> = ({ export const CyclesListView: React.FC<TCycleStatsViewProps> = ({
cycles, cycles,
setCreateUpdateCycleModal, setCreateUpdateCycleModal,
setSelectedCycle, setSelectedCycle,
@ -35,7 +34,7 @@ const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
return ( return (
<> <>
<ConfirmCycleDeletion <DeleteCycleModal
isOpen={ isOpen={
cycleDeleteModal && cycleDeleteModal &&
!!selectedCycleForDelete && !!selectedCycleForDelete &&
@ -46,7 +45,7 @@ const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
/> />
{cycles.length > 0 ? ( {cycles.length > 0 ? (
cycles.map((cycle) => ( cycles.map((cycle) => (
<SingleStat <SingleCycleCard
key={cycle.id} key={cycle.id}
cycle={cycle} cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)} handleDeleteCycle={() => handleDeleteCycle(cycle)}
@ -64,12 +63,10 @@ const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
)} )}
<h3 className="text-gray-500"> <h3 className="text-gray-500">
No {type} {type === "current" ? "cycle" : "cycles"} yet. Create with{" "} 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> </h3>
</div> </div>
)} )}
</> </>
); );
}; };
export default CycleStatsView;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import { Props } from "./types";
import emojis from "./emojis.json"; import emojis from "./emojis.json";
// helpers // helpers
import { getRecentEmojis, saveRecentEmoji } from "./helpers"; import { getRecentEmojis, saveRecentEmoji } from "./helpers";
import { getRandomEmoji } from "helpers/functions.helper"; import { getRandomEmoji } from "helpers/common.helper";
// hooks // hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector"; 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" leaveTo="transform opacity-0 scale-95"
> >
<Popover.Panel className="absolute z-10 mt-2 w-80 rounded-md bg-white shadow-lg"> <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.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"> <Tab.List className="flex-0 -mx-2 flex justify-around gap-1 rounded border-b p-1">
{tabOptions.map((tab) => ( {tabOptions.map((tab) => (

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