mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
170 Commits
chore/defa
...
priv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ceb9974f6 | ||
|
|
e0fcc0c876 | ||
|
|
5ac2c270f7 | ||
|
|
af61054c0b | ||
|
|
67922f9c5d | ||
|
|
85147db85f | ||
|
|
62035f3bad | ||
|
|
1c43d313d1 | ||
|
|
77c1b90e6b | ||
|
|
64af5e2e75 | ||
|
|
4ec2811388 | ||
|
|
9482cc3a73 | ||
|
|
18bcf55f78 | ||
|
|
ea2c1e2d06 | ||
|
|
48c65c9c95 | ||
|
|
d0a51d75f6 | ||
|
|
7bdca9c73a | ||
|
|
05920a72a5 | ||
|
|
7a3b556ae0 | ||
|
|
c2f0ae1ec5 | ||
|
|
fb6f6454df | ||
|
|
1e9149d872 | ||
|
|
eacf543439 | ||
|
|
981e2aafdd | ||
|
|
4017f6bc55 | ||
|
|
db1bcdb54f | ||
|
|
cecdf890de | ||
|
|
d9bd07886f | ||
|
|
0d0cf3052a | ||
|
|
6d463ded1c | ||
|
|
de33e6775f | ||
|
|
c96960955b | ||
|
|
7eba4a5032 | ||
|
|
c56aacdd13 | ||
|
|
3ff47b38f4 | ||
|
|
25a973aaf9 | ||
|
|
ebf79ee2aa | ||
|
|
f001852f69 | ||
|
|
6b5fe0fff1 | ||
|
|
f73135c504 | ||
|
|
f751619759 | ||
|
|
adf49782ba | ||
|
|
843ba5bb63 | ||
|
|
8d4ac9b430 | ||
|
|
7c125075b1 | ||
|
|
4cab00ec79 | ||
|
|
f1879a404d | ||
|
|
26bb51e686 | ||
|
|
1b28125919 | ||
|
|
2a770e4a95 | ||
|
|
fbf88c3196 | ||
|
|
459999e8c9 | ||
|
|
6cb4b222d0 | ||
|
|
a048e513b7 | ||
|
|
4503810aeb | ||
|
|
ec91a0d2e5 | ||
|
|
60a69e28e3 | ||
|
|
34af666b5f | ||
|
|
6afbd3f1ba | ||
|
|
32d2f912f7 | ||
|
|
9dd22f07f4 | ||
|
|
7404fe71b1 | ||
|
|
dc2b4de95e | ||
|
|
62ba8d5e9f | ||
|
|
7b453dd6b5 | ||
|
|
191aecaaac | ||
|
|
e00ae0b48a | ||
|
|
a243bb6a15 | ||
|
|
b3be363b00 | ||
|
|
5298f1e53c | ||
|
|
2d8cbccfbc | ||
|
|
3a6d72e4b6 | ||
|
|
698b42768e | ||
|
|
a187e7765c | ||
|
|
4c333d5767 | ||
|
|
b317a14983 | ||
|
|
6e0999c35a | ||
|
|
52b57b1e37 | ||
|
|
88a35efa06 | ||
|
|
ab028a317b | ||
|
|
d38594376b | ||
|
|
dae8ca6053 | ||
|
|
6d3bd78052 | ||
|
|
1ad99873a9 | ||
|
|
7db78594dc | ||
|
|
5e8d523ed4 | ||
|
|
de7a672b79 | ||
|
|
0e96eddb57 | ||
|
|
afa10d7195 | ||
|
|
68c8741f93 | ||
|
|
e8d303dd10 | ||
|
|
c9a6380636 | ||
|
|
1aadbee7e2 | ||
|
|
02060f654c | ||
|
|
771ca585db | ||
|
|
daa0b16960 | ||
|
|
0005ff5f99 | ||
|
|
0c7b7c4e94 | ||
|
|
4d835c5b4a | ||
|
|
c7092edb61 | ||
|
|
73afb8f4d8 | ||
|
|
978909c021 | ||
|
|
de9f34cac3 | ||
|
|
e3793f4464 | ||
|
|
1621125f6d | ||
|
|
bd077e6500 | ||
|
|
60ae940d40 | ||
|
|
cdfff12f4f | ||
|
|
e01a0d20fe | ||
|
|
63c4792e70 | ||
|
|
ce562fa3ea | ||
|
|
a6a0eb9774 | ||
|
|
d603c1e8f0 | ||
|
|
405ef9314f | ||
|
|
926d2ae0a0 | ||
|
|
11258686ad | ||
|
|
f6b92fc953 | ||
|
|
79bf7d4c0c | ||
|
|
5d331477ef | ||
|
|
3d72279edb | ||
|
|
c107b36d34 | ||
|
|
ccffbe1b4e | ||
|
|
9bfdcff44d | ||
|
|
b274a21ca5 | ||
|
|
32d945be0d | ||
|
|
eda4da8aed | ||
|
|
759a604cb8 | ||
|
|
6659cfc8b0 | ||
|
|
a53b428bbd | ||
|
|
4e0e02522f | ||
|
|
f983d787b4 | ||
|
|
87abf3ccb1 | ||
|
|
d0f6ca3bac | ||
|
|
af73bbe718 | ||
|
|
9033ceb03c | ||
|
|
9bac7cb036 | ||
|
|
32d08570e7 | ||
|
|
1b1ed37405 | ||
|
|
2bc05cc7b7 | ||
|
|
14fe545709 | ||
|
|
bc99ec0f1d | ||
|
|
cc63f67654 | ||
|
|
b8dd9ca729 | ||
|
|
73b360c2fd | ||
|
|
80bcca71ff | ||
|
|
3db0ec819a | ||
|
|
414ea7371d | ||
|
|
2b84b7c18d | ||
|
|
674347c99e | ||
|
|
c1102180e6 | ||
|
|
650c0c3b78 | ||
|
|
6f397710ce | ||
|
|
e6bd6b6a8c | ||
|
|
9d3952006b | ||
|
|
b75473a684 | ||
|
|
2f5bd58c61 | ||
|
|
e833fccf61 | ||
|
|
62ba9abdb4 | ||
|
|
46b138eb0b | ||
|
|
59bdf222f5 | ||
|
|
eb50ade5e3 | ||
|
|
85a08e4abd | ||
|
|
aa2e1697b0 | ||
|
|
3beab9de6f | ||
|
|
13d21e752d | ||
|
|
9ff8994c0e | ||
|
|
3488001197 | ||
|
|
2ced7e4911 | ||
|
|
e5a3bec28c | ||
|
|
e930f8cc7b |
54
.env.example
54
.env.example
@@ -1,38 +1,3 @@
|
||||
# Frontend
|
||||
# Extra image domains that need to be added for Next Image
|
||||
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
|
||||
# Google Client ID for Google OAuth
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
||||
# Github ID for Github OAuth
|
||||
NEXT_PUBLIC_GITHUB_ID=""
|
||||
# Github App Name for GitHub Integration
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME=""
|
||||
# Sentry DSN for error monitoring
|
||||
NEXT_PUBLIC_SENTRY_DSN=""
|
||||
# Enable/Disable OAUTH - default 0 for selfhosted instance
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
# Enable/Disable sentry
|
||||
NEXT_PUBLIC_ENABLE_SENTRY=0
|
||||
# Enable/Disable session recording
|
||||
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
|
||||
# Enable/Disable event tracking
|
||||
NEXT_PUBLIC_TRACK_EVENTS=0
|
||||
# Slack for Slack Integration
|
||||
NEXT_PUBLIC_SLACK_CLIENT_ID=""
|
||||
# For Telemetry, set it to "app.plane.so"
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
|
||||
# public boards deploy url
|
||||
NEXT_PUBLIC_DEPLOY_URL=""
|
||||
# plane deploy using nginx
|
||||
NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
|
||||
|
||||
# Backend
|
||||
# Debug value for api server use it as 0 for production use
|
||||
DEBUG=0
|
||||
|
||||
# Error logs
|
||||
SENTRY_DSN=""
|
||||
|
||||
# Database Settings
|
||||
PGUSER="plane"
|
||||
PGPASSWORD="plane"
|
||||
@@ -45,15 +10,6 @@ REDIS_HOST="plane-redis"
|
||||
REDIS_PORT="6379"
|
||||
REDIS_URL="redis://${REDIS_HOST}:6379/"
|
||||
|
||||
# Email Settings
|
||||
EMAIL_HOST=""
|
||||
EMAIL_HOST_USER=""
|
||||
EMAIL_HOST_PASSWORD=""
|
||||
EMAIL_PORT=587
|
||||
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
|
||||
EMAIL_USE_TLS="1"
|
||||
EMAIL_USE_SSL="0"
|
||||
|
||||
# AWS Settings
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID="access-key"
|
||||
@@ -69,9 +25,6 @@ OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint
|
||||
OPENAI_API_KEY="sk-" # add your openai key here
|
||||
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access
|
||||
|
||||
# Github
|
||||
GITHUB_CLIENT_SECRET="" # For fetching release notes
|
||||
|
||||
# Settings related to Docker
|
||||
DOCKERIZED=1
|
||||
# set to 1 If using the pre-configured minio setup
|
||||
@@ -80,10 +33,3 @@ USE_MINIO=1
|
||||
# Nginx Configuration
|
||||
NGINX_PORT=80
|
||||
|
||||
# Default Creds
|
||||
DEFAULT_EMAIL="captain@plane.so"
|
||||
DEFAULT_PASSWORD="password123"
|
||||
|
||||
# SignUps
|
||||
ENABLE_SIGNUP="1"
|
||||
# Auto generated and Required that will be generated from setup.sh
|
||||
|
||||
@@ -33,14 +33,9 @@ jobs:
|
||||
deploy:
|
||||
- space/**
|
||||
|
||||
- name: Setup .npmrc for repository
|
||||
run: |
|
||||
echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc
|
||||
|
||||
- name: Build Plane's Main App
|
||||
if: steps.changed-files.outputs.web_any_changed == 'true'
|
||||
run: |
|
||||
mv ./.npmrc ./web
|
||||
cd web
|
||||
yarn
|
||||
yarn build
|
||||
77
.github/workflows/create-sync-pr.yml
vendored
Normal file
77
.github/workflows/create-sync-pr.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
name: Create PR in Plane EE Repository to sync the changes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
|
||||
jobs:
|
||||
create_pr:
|
||||
# Only run the job when a PR is merged
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check SOURCE_REPO
|
||||
id: check_repo
|
||||
env:
|
||||
SOURCE_REPO: ${{ secrets.SOURCE_REPO_NAME }}
|
||||
run: |
|
||||
echo "::set-output name=is_correct_repo::$(if [[ "$SOURCE_REPO" == "makeplane/plane" ]]; then echo 'true'; else echo 'false'; fi)"
|
||||
|
||||
- name: Checkout Code
|
||||
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Branch Name
|
||||
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||
run: |
|
||||
echo "SOURCE_BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup GH CLI
|
||||
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||
run: |
|
||||
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install gh -y
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: |
|
||||
TARGET_REPO="${{ secrets.TARGET_REPO_NAME }}"
|
||||
TARGET_BRANCH="${{ secrets.TARGET_REPO_BRANCH }}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
|
||||
git checkout $SOURCE_BRANCH
|
||||
git remote add target "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
git push target $SOURCE_BRANCH:$SOURCE_BRANCH
|
||||
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_BODY="${{ github.event.pull_request.body }}"
|
||||
|
||||
# Remove double quotes
|
||||
PR_TITLE_CLEANED="${PR_TITLE//\"/}"
|
||||
PR_BODY_CLEANED="${PR_BODY//\"/}"
|
||||
|
||||
# Construct PR_BODY_CONTENT using a here-document
|
||||
PR_BODY_CONTENT=$(cat <<EOF
|
||||
$PR_BODY_CLEANED
|
||||
EOF
|
||||
)
|
||||
|
||||
gh pr create \
|
||||
--base $TARGET_BRANCH \
|
||||
--head $SOURCE_BRANCH \
|
||||
--title "[SYNC] $PR_TITLE_CLEANED" \
|
||||
--body "$PR_BODY_CONTENT" \
|
||||
--repo $TARGET_REPO
|
||||
@@ -22,10 +22,6 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Setup .npmrc for repository
|
||||
run: |
|
||||
echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
|
||||
id: metaFrontend
|
||||
uses: docker/metadata-action@v4.3.0
|
||||
@@ -43,10 +39,10 @@ jobs:
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
|
||||
id: metaDeploy
|
||||
id: metaSpace
|
||||
uses: docker/metadata-action@v4.3.0
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-deploy
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
|
||||
@@ -91,7 +87,7 @@ jobs:
|
||||
file: ./space/Dockerfile.space
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.metaDeploy.outputs.tags }}
|
||||
tags: ${{ steps.metaSpace.outputs.tags }}
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -8,8 +8,8 @@ Before submitting a new issue, please search the [issues](https://github.com/mak
|
||||
|
||||
While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like:
|
||||
|
||||
- 3rd-party libraries being used and their versions
|
||||
- a use-case that fails
|
||||
- 3rd-party libraries being used and their versions
|
||||
- a use-case that fails
|
||||
|
||||
Without said minimal reproduction, we won't be able to investigate all [issues](https://github.com/makeplane/plane/issues), and the issue might not be resolved.
|
||||
|
||||
@@ -19,10 +19,10 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js version v16.18.0
|
||||
- Python version 3.8+
|
||||
- Postgres version v14
|
||||
- Redis version v6.2.7
|
||||
- Node.js version v16.18.0
|
||||
- Python version 3.8+
|
||||
- Postgres version v14
|
||||
- Redis version v6.2.7
|
||||
|
||||
### Setup the project
|
||||
|
||||
@@ -30,6 +30,48 @@ The project is a monorepo, with backend api and frontend in a single repo.
|
||||
|
||||
The backend is a django project which is kept inside apiserver
|
||||
|
||||
1. Clone the repo
|
||||
|
||||
```bash
|
||||
git clone https://github.com/makeplane/plane
|
||||
cd plane
|
||||
chmod +x setup.sh
|
||||
```
|
||||
|
||||
2. Run setup.sh
|
||||
|
||||
```bash
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
3. Define `NEXT_PUBLIC_API_BASE_URL=http://localhost` in **web/.env** and **space/.env** file
|
||||
|
||||
```bash
|
||||
echo "\nNEXT_PUBLIC_API_BASE_URL=http://localhost\n" >> ./web/.env
|
||||
```
|
||||
|
||||
```bash
|
||||
echo "\nNEXT_PUBLIC_API_BASE_URL=http://localhost\n" >> ./space/.env
|
||||
```
|
||||
|
||||
4. Run Docker compose up
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. Install dependencies
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
6. Run the web app in development mode
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## Missing a Feature?
|
||||
|
||||
If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository.
|
||||
@@ -39,8 +81,8 @@ If you would like to _implement_ it, an issue with your proposal must be submitt
|
||||
|
||||
To ensure consistency throughout the source code, please keep these rules in mind as you are working:
|
||||
|
||||
- All features or bug fixes must be tested by one or more specs (unit-tests).
|
||||
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
|
||||
- All features or bug fixes must be tested by one or more specs (unit-tests).
|
||||
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
|
||||
|
||||
## Need help? Questions and suggestions
|
||||
|
||||
@@ -48,11 +90,11 @@ Questions, suggestions, and thoughts are most welcome. We can also be reached in
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
- Try Plane Cloud and the self hosting platform and give feedback
|
||||
- Add new integrations
|
||||
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
|
||||
- Share your thoughts and suggestions with us
|
||||
- Help create tutorials and blog posts
|
||||
- Request a feature by submitting a proposal
|
||||
- Report a bug
|
||||
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
|
||||
- Try Plane Cloud and the self hosting platform and give feedback
|
||||
- Add new integrations
|
||||
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
|
||||
- Share your thoughts and suggestions with us
|
||||
- Help create tutorials and blog posts
|
||||
- Request a feature by submitting a proposal
|
||||
- Report a bug
|
||||
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
|
||||
|
||||
13
README.md
13
README.md
@@ -54,22 +54,11 @@ chmod +x setup.sh
|
||||
- Run setup.sh
|
||||
|
||||
```bash
|
||||
./setup.sh http://localhost
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
> If running in a cloud env replace localhost with public facing IP address of the VM
|
||||
|
||||
- Setup Tiptap Pro
|
||||
|
||||
Visit [Tiptap Pro](https://collab.tiptap.dev/pro-extensions) and signup (it is free).
|
||||
|
||||
Create a **`.npmrc`** file, copy the following and replace your registry token generated from Tiptap Pro.
|
||||
|
||||
```
|
||||
@tiptap-pro:registry=https://registry.tiptap.dev/
|
||||
//registry.tiptap.dev/:_authToken=YOUR_REGISTRY_TOKEN
|
||||
```
|
||||
|
||||
- Run Docker compose up
|
||||
|
||||
```bash
|
||||
|
||||
72
apiserver/.env.example
Normal file
72
apiserver/.env.example
Normal file
@@ -0,0 +1,72 @@
|
||||
# Backend
|
||||
# Debug value for api server use it as 0 for production use
|
||||
DEBUG=0
|
||||
DJANGO_SETTINGS_MODULE="plane.settings.production"
|
||||
|
||||
# Error logs
|
||||
SENTRY_DSN=""
|
||||
|
||||
# Database Settings
|
||||
PGUSER="plane"
|
||||
PGPASSWORD="plane"
|
||||
PGHOST="plane-db"
|
||||
PGDATABASE="plane"
|
||||
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
|
||||
|
||||
# Redis Settings
|
||||
REDIS_HOST="plane-redis"
|
||||
REDIS_PORT="6379"
|
||||
REDIS_URL="redis://${REDIS_HOST}:6379/"
|
||||
|
||||
# Email Settings
|
||||
EMAIL_HOST=""
|
||||
EMAIL_HOST_USER=""
|
||||
EMAIL_HOST_PASSWORD=""
|
||||
EMAIL_PORT=587
|
||||
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
|
||||
EMAIL_USE_TLS="1"
|
||||
EMAIL_USE_SSL="0"
|
||||
|
||||
# AWS Settings
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID="access-key"
|
||||
AWS_SECRET_ACCESS_KEY="secret-key"
|
||||
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
|
||||
# Changing this requires change in the nginx.conf for uploads if using minio setup
|
||||
AWS_S3_BUCKET_NAME="uploads"
|
||||
# Maximum file upload limit
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
|
||||
# GPT settings
|
||||
OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint
|
||||
OPENAI_API_KEY="sk-" # add your openai key here
|
||||
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access
|
||||
|
||||
# Github
|
||||
GITHUB_CLIENT_SECRET="" # For fetching release notes
|
||||
|
||||
# Settings related to Docker
|
||||
DOCKERIZED=1
|
||||
# set to 1 If using the pre-configured minio setup
|
||||
USE_MINIO=1
|
||||
|
||||
# Nginx Configuration
|
||||
NGINX_PORT=80
|
||||
|
||||
# Default Creds
|
||||
DEFAULT_EMAIL="captain@plane.so"
|
||||
DEFAULT_PASSWORD="password123"
|
||||
|
||||
# SignUps
|
||||
ENABLE_SIGNUP="1"
|
||||
|
||||
|
||||
# Enable Email/Password Signup
|
||||
ENABLE_EMAIL_PASSWORD="1"
|
||||
|
||||
# Enable Magic link Login
|
||||
ENABLE_MAGIC_LINK_LOGIN="0"
|
||||
|
||||
# Email redirections and minio domain settings
|
||||
WEB_URL="http://localhost"
|
||||
|
||||
@@ -58,8 +58,17 @@ class WorkspaceEntityPermission(BasePermission):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
## Safe Methods -> Handle the filtering logic in queryset
|
||||
if request.method in SAFE_METHODS:
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
).exists()
|
||||
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=view.workspace_slug
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role__in=[Owner, Admin],
|
||||
).exists()
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from .project import (
|
||||
ProjectPublicMemberSerializer
|
||||
)
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
|
||||
from .view import GlobalViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer
|
||||
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer
|
||||
from .asset import FileAssetSerializer
|
||||
from .issue import (
|
||||
|
||||
@@ -34,7 +34,6 @@ class CycleSerializer(BaseSerializer):
|
||||
unstarted_issues = serializers.IntegerField(read_only=True)
|
||||
backlog_issues = serializers.IntegerField(read_only=True)
|
||||
assignees = serializers.SerializerMethodField(read_only=True)
|
||||
labels = serializers.SerializerMethodField(read_only=True)
|
||||
total_estimates = serializers.IntegerField(read_only=True)
|
||||
completed_estimates = serializers.IntegerField(read_only=True)
|
||||
started_estimates = serializers.IntegerField(read_only=True)
|
||||
@@ -50,11 +49,10 @@ class CycleSerializer(BaseSerializer):
|
||||
members = [
|
||||
{
|
||||
"avatar": assignee.avatar,
|
||||
"first_name": assignee.first_name,
|
||||
"display_name": assignee.display_name,
|
||||
"id": assignee.id,
|
||||
}
|
||||
for issue_cycle in obj.issue_cycle.all()
|
||||
for issue_cycle in obj.issue_cycle.prefetch_related("issue__assignees").all()
|
||||
for assignee in issue_cycle.issue.assignees.all()
|
||||
]
|
||||
# Use a set comprehension to return only the unique objects
|
||||
@@ -64,24 +62,6 @@ class CycleSerializer(BaseSerializer):
|
||||
unique_list = [dict(item) for item in unique_objects]
|
||||
|
||||
return unique_list
|
||||
|
||||
def get_labels(self, obj):
|
||||
labels = [
|
||||
{
|
||||
"name": label.name,
|
||||
"color": label.color,
|
||||
"id": label.id,
|
||||
}
|
||||
for issue_cycle in obj.issue_cycle.all()
|
||||
for label in issue_cycle.issue.labels.all()
|
||||
]
|
||||
# Use a set comprehension to return only the unique objects
|
||||
unique_objects = {frozenset(item.items()) for item in labels}
|
||||
|
||||
# Convert the set back to a list of dictionaries
|
||||
unique_list = [dict(item) for item in unique_objects]
|
||||
|
||||
return unique_list
|
||||
|
||||
class Meta:
|
||||
model = Cycle
|
||||
|
||||
@@ -49,6 +49,7 @@ class IssueFlatSerializer(BaseSerializer):
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"sort_order",
|
||||
"is_draft",
|
||||
]
|
||||
|
||||
|
||||
@@ -292,12 +293,12 @@ class IssueLabelSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueRelationSerializer(BaseSerializer):
|
||||
related_issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
|
||||
issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = [
|
||||
"related_issue_detail",
|
||||
"issue_detail",
|
||||
"relation_type",
|
||||
"related_issue",
|
||||
"issue",
|
||||
|
||||
@@ -5,10 +5,39 @@ from rest_framework import serializers
|
||||
from .base import BaseSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from plane.db.models import IssueView, IssueViewFavorite
|
||||
from plane.db.models import GlobalView, IssueView, IssueViewFavorite
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class GlobalViewSerializer(BaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = GlobalView
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"query",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
query_params = validated_data.get("query_data", {})
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
validated_data["query"] = dict()
|
||||
return GlobalView.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
query_params = validated_data.get("query_data", {})
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
validated_data["query"] = dict()
|
||||
validated_data["query"] = issue_filters(query_params, "PATCH")
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class IssueViewSerializer(BaseSerializer):
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
|
||||
@@ -70,6 +70,7 @@ from plane.api.views import (
|
||||
ProjectIdentifierEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
LeaveProjectEndpoint,
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
## End Projects
|
||||
# Issues
|
||||
IssueViewSet,
|
||||
@@ -102,6 +103,8 @@ from plane.api.views import (
|
||||
BulkEstimatePointEndpoint,
|
||||
## End Estimates
|
||||
# Views
|
||||
GlobalViewViewSet,
|
||||
GlobalViewIssuesViewSet,
|
||||
IssueViewViewSet,
|
||||
ViewIssuesEndpoint,
|
||||
IssueViewFavoriteViewSet,
|
||||
@@ -148,12 +151,11 @@ from plane.api.views import (
|
||||
GlobalSearchEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
## End Search
|
||||
# Gpt
|
||||
# External
|
||||
GPTIntegrationEndpoint,
|
||||
## End Gpt
|
||||
# Release Notes
|
||||
ReleaseNotesEndpoint,
|
||||
## End Release Notes
|
||||
UnsplashEndpoint,
|
||||
## End External
|
||||
# Inbox
|
||||
InboxViewSet,
|
||||
InboxIssueViewSet,
|
||||
@@ -184,7 +186,9 @@ from plane.api.views import (
|
||||
## Exporter
|
||||
ExportIssuesEndpoint,
|
||||
## End Exporter
|
||||
|
||||
# Configuration
|
||||
ConfigurationEndpoint,
|
||||
## End Configuration
|
||||
)
|
||||
|
||||
|
||||
@@ -241,7 +245,11 @@ urlpatterns = [
|
||||
UpdateUserTourCompletedEndpoint.as_view(),
|
||||
name="user-tour",
|
||||
),
|
||||
path("users/workspaces/<str:slug>/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
|
||||
path(
|
||||
"users/workspaces/<str:slug>/activities/",
|
||||
UserActivityEndpoint.as_view(),
|
||||
name="user-activities",
|
||||
),
|
||||
# user workspaces
|
||||
path(
|
||||
"users/me/workspaces/",
|
||||
@@ -568,6 +576,11 @@ urlpatterns = [
|
||||
LeaveProjectEndpoint.as_view(),
|
||||
name="project",
|
||||
),
|
||||
path(
|
||||
"project-covers/",
|
||||
ProjectPublicCoverImagesEndpoint.as_view(),
|
||||
name="project-covers",
|
||||
),
|
||||
# End Projects
|
||||
# States
|
||||
path(
|
||||
@@ -649,6 +662,37 @@ urlpatterns = [
|
||||
ViewIssuesEndpoint.as_view(),
|
||||
name="project-view-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/views/",
|
||||
GlobalViewViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="global-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/views/<uuid:pk>/",
|
||||
GlobalViewViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="global-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/",
|
||||
GlobalViewIssuesViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
name="global-view-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/",
|
||||
IssueViewFavoriteViewSet.as_view(
|
||||
@@ -767,11 +811,6 @@ urlpatterns = [
|
||||
),
|
||||
name="project-issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/",
|
||||
WorkSpaceIssuesEndpoint.as_view(),
|
||||
name="workspace-issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
|
||||
LabelViewSet.as_view(
|
||||
@@ -1038,6 +1077,7 @@ urlpatterns = [
|
||||
IssueDraftViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-issue-draft",
|
||||
@@ -1047,6 +1087,7 @@ urlpatterns = [
|
||||
IssueDraftViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
@@ -1413,20 +1454,23 @@ urlpatterns = [
|
||||
name="project-issue-search",
|
||||
),
|
||||
## End Search
|
||||
# Gpt
|
||||
# External
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
|
||||
GPTIntegrationEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
## End Gpt
|
||||
# Release Notes
|
||||
path(
|
||||
"release-notes/",
|
||||
ReleaseNotesEndpoint.as_view(),
|
||||
name="release-notes",
|
||||
),
|
||||
## End Release Notes
|
||||
path(
|
||||
"unsplash/",
|
||||
UnsplashEndpoint.as_view(),
|
||||
name="release-notes",
|
||||
),
|
||||
## End External
|
||||
# Inbox
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/",
|
||||
@@ -1695,4 +1739,11 @@ urlpatterns = [
|
||||
name="workspace-project-boards",
|
||||
),
|
||||
## End Public Boards
|
||||
# Configuration
|
||||
path(
|
||||
"configs/",
|
||||
ConfigurationEndpoint.as_view(),
|
||||
name="configuration",
|
||||
),
|
||||
## End Configuration
|
||||
]
|
||||
|
||||
@@ -17,6 +17,7 @@ from .project import (
|
||||
ProjectMemberEndpoint,
|
||||
WorkspaceProjectDeployBoardEndpoint,
|
||||
LeaveProjectEndpoint,
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
)
|
||||
from .user import (
|
||||
UserEndpoint,
|
||||
@@ -56,7 +57,7 @@ from .workspace import (
|
||||
LeaveWorkspaceEndpoint,
|
||||
)
|
||||
from .state import StateViewSet
|
||||
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
|
||||
from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
|
||||
from .cycle import (
|
||||
CycleViewSet,
|
||||
CycleIssueViewSet,
|
||||
@@ -147,16 +148,13 @@ from .page import (
|
||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||
|
||||
|
||||
from .gpt import GPTIntegrationEndpoint
|
||||
from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint
|
||||
|
||||
from .estimate import (
|
||||
ProjectEstimatePointEndpoint,
|
||||
BulkEstimatePointEndpoint,
|
||||
)
|
||||
|
||||
|
||||
from .release import ReleaseNotesEndpoint
|
||||
|
||||
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
|
||||
|
||||
from .analytic import (
|
||||
@@ -169,4 +167,6 @@ from .analytic import (
|
||||
|
||||
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
|
||||
|
||||
from .exporter import ExportIssuesEndpoint
|
||||
from .exporter import ExportIssuesEndpoint
|
||||
|
||||
from .config import ConfigurationEndpoint
|
||||
40
apiserver/plane/api/views/config.py
Normal file
40
apiserver/plane/api/views/config.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Python imports
|
||||
import os
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
|
||||
|
||||
class ConfigurationEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def get(self, request):
|
||||
try:
|
||||
data = {}
|
||||
data["google"] = os.environ.get("GOOGLE_CLIENT_ID", None)
|
||||
data["github"] = os.environ.get("GITHUB_CLIENT_ID", None)
|
||||
data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None)
|
||||
data["magic_login"] = (
|
||||
bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD)
|
||||
) and os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0") == "1"
|
||||
data["email_password_login"] = (
|
||||
os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1"
|
||||
)
|
||||
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"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -80,6 +80,7 @@ class CycleViewSet(BaseViewSet):
|
||||
issue_id=str(self.kwargs.get("pk", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
return super().perform_destroy(instance)
|
||||
@@ -101,48 +102,84 @@ class CycleViewSet(BaseViewSet):
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
@@ -195,17 +232,30 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(total_issues=Count("assignee_id"))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
@@ -221,17 +271,30 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("label_id"))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
@@ -384,17 +447,30 @@ class CycleViewSet(BaseViewSet):
|
||||
.values(
|
||||
"first_name", "last_name", "assignee_id", "avatar", "display_name"
|
||||
)
|
||||
.annotate(total_issues=Count("assignee_id"))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("first_name", "last_name")
|
||||
@@ -411,17 +487,30 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("label_id"))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
@@ -487,6 +576,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
issue_id=str(self.kwargs.get("pk", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
@@ -517,6 +607,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
try:
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
|
||||
@@ -555,9 +646,15 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
|
||||
issues_data = IssueStateSerializer(issues, many=True).data
|
||||
|
||||
if sub_group_by and sub_group_by == group_by:
|
||||
return Response(
|
||||
{"error": "Group by and sub group by cannot be same"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
return Response(
|
||||
group_results(issues_data, group_by),
|
||||
group_results(issues_data, group_by, sub_group_by),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -655,6 +752,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
# Return all Cycle Issues
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import requests
|
||||
|
||||
# Third party imports
|
||||
import openai
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
import openai
|
||||
from rest_framework.permissions import AllowAny
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Django imports
|
||||
@@ -15,6 +16,7 @@ from .base import BaseAPIView
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Workspace, Project
|
||||
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
|
||||
from plane.utils.integrations.github import get_release_notes
|
||||
|
||||
|
||||
class GPTIntegrationEndpoint(BaseAPIView):
|
||||
@@ -73,3 +75,44 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ReleaseNotesEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
try:
|
||||
release_notes = get_release_notes()
|
||||
return Response(release_notes, 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,
|
||||
)
|
||||
|
||||
|
||||
class UnsplashEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request):
|
||||
try:
|
||||
query = request.GET.get("query", False)
|
||||
page = request.GET.get("page", 1)
|
||||
per_page = request.GET.get("per_page", 20)
|
||||
|
||||
url = (
|
||||
f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}"
|
||||
if query
|
||||
else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}"
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
resp = requests.get(url=url, headers=headers)
|
||||
return Response(resp.json(), 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,
|
||||
)
|
||||
@@ -384,7 +384,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
sort_order=largest_sort_order,
|
||||
start_date=issue_data.get("start_date", None),
|
||||
target_date=issue_data.get("target_date", None),
|
||||
priority=issue_data.get("priority", None),
|
||||
priority=issue_data.get("priority", "none"),
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -173,12 +173,12 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# Check for valid priority
|
||||
if not request.data.get("issue", {}).get("priority", None) in [
|
||||
if not request.data.get("issue", {}).get("priority", "none") in [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"urgent",
|
||||
None,
|
||||
"none",
|
||||
]:
|
||||
return Response(
|
||||
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
||||
@@ -213,6 +213,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
# create an inbox issue
|
||||
InboxIssue.objects.create(
|
||||
@@ -277,6 +278,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
IssueSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
issue_serializer.save()
|
||||
else:
|
||||
@@ -368,6 +370,11 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id):
|
||||
return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Check the issue status
|
||||
if inbox_issue.status in [-2, -1, 0, 2]:
|
||||
# Delete the issue also
|
||||
Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id).delete()
|
||||
|
||||
inbox_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except InboxIssue.DoesNotExist:
|
||||
@@ -478,12 +485,12 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# Check for valid priority
|
||||
if not request.data.get("issue", {}).get("priority", None) in [
|
||||
if not request.data.get("issue", {}).get("priority", "none") in [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"urgent",
|
||||
None,
|
||||
"none",
|
||||
]:
|
||||
return Response(
|
||||
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
||||
@@ -518,6 +525,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
# create an inbox issue
|
||||
InboxIssue.objects.create(
|
||||
@@ -582,6 +590,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
IssueSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
issue_serializer.save()
|
||||
return Response(issue_serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -4,6 +4,7 @@ import random
|
||||
from itertools import chain
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
OuterRef,
|
||||
@@ -23,7 +24,6 @@ from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.db import IntegrityError
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third Party imports
|
||||
@@ -53,10 +53,10 @@ from plane.api.serializers import (
|
||||
CommentReactionSerializer,
|
||||
IssueVoteSerializer,
|
||||
IssueRelationSerializer,
|
||||
RelatedIssueSerializer,
|
||||
IssuePublicSerializer,
|
||||
)
|
||||
from plane.api.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
ProjectEntityPermission,
|
||||
WorkSpaceAdminPermission,
|
||||
ProjectMemberPermission,
|
||||
@@ -128,6 +128,7 @@ class IssueViewSet(BaseViewSet):
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
return super().perform_update(serializer)
|
||||
@@ -148,6 +149,7 @@ class IssueViewSet(BaseViewSet):
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
@@ -314,6 +316,7 @@ class IssueViewSet(BaseViewSet):
|
||||
issue_id=str(serializer.data.get("id", None)),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -325,7 +328,12 @@ class IssueViewSet(BaseViewSet):
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
try:
|
||||
issue = Issue.issue_objects.get(
|
||||
issue = Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
).get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
@@ -453,9 +461,16 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
|
||||
## Grouping the results
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
if sub_group_by and sub_group_by == group_by:
|
||||
return Response(
|
||||
{"error": "Group by and sub group by cannot be same"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
return Response(
|
||||
group_results(issues, group_by), status=status.HTTP_200_OK
|
||||
group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
@@ -501,7 +516,7 @@ class IssueActivityEndpoint(BaseAPIView):
|
||||
issue_activities = (
|
||||
IssueActivity.objects.filter(issue_id=issue_id)
|
||||
.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction"]),
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
)
|
||||
.select_related("actor", "workspace", "issue", "project")
|
||||
@@ -560,6 +575,7 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
issue_id=str(self.kwargs.get("issue_id")),
|
||||
project_id=str(self.kwargs.get("project_id")),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
@@ -578,6 +594,7 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
IssueCommentSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
return super().perform_update(serializer)
|
||||
@@ -599,6 +616,7 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
IssueCommentSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
@@ -694,10 +712,18 @@ class LabelViewSet(BaseViewSet):
|
||||
ProjectMemberPermission,
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
serializer = LabelSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError:
|
||||
return Response({"error": "Label with the same name already exists in the project"}, 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 get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
@@ -882,6 +908,7 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
issue_id=str(self.kwargs.get("issue_id")),
|
||||
project_id=str(self.kwargs.get("project_id")),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
@@ -900,6 +927,7 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
IssueLinkSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
return super().perform_update(serializer)
|
||||
@@ -921,6 +949,7 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
IssueLinkSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
@@ -999,6 +1028,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
serializer.data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -1021,6 +1051,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1223,6 +1254,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
@@ -1427,6 +1459,7 @@ class IssueReactionViewSet(BaseViewSet):
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, issue_id, reaction_code):
|
||||
@@ -1450,6 +1483,7 @@ class IssueReactionViewSet(BaseViewSet):
|
||||
"identifier": str(issue_reaction.id),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
issue_reaction.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1498,6 +1532,7 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
issue_id=None,
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, comment_id, reaction_code):
|
||||
@@ -1522,6 +1557,7 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
"comment_id": str(comment_id),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
comment_reaction.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1618,6 +1654,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
if not ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
@@ -1667,6 +1704,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
IssueCommentSerializer(comment).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -1700,6 +1738,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
IssueCommentSerializer(comment).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
comment.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1774,6 +1813,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -1818,6 +1858,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
|
||||
"identifier": str(issue_reaction.id),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
issue_reaction.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1891,6 +1932,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
|
||||
issue_id=None,
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -1942,6 +1984,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
|
||||
"comment_id": str(comment_id),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
comment_reaction.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -2005,6 +2048,7 @@ class IssueVotePublicViewSet(BaseViewSet):
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
serializer = IssueVoteSerializer(issue_vote)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
@@ -2039,6 +2083,7 @@ class IssueVotePublicViewSet(BaseViewSet):
|
||||
"identifier": str(issue_vote.id),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
issue_vote.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -2072,15 +2117,17 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
IssueRelationSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
def create(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
related_list = request.data.get("related_list", [])
|
||||
relation = request.data.get("relation", None)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
issueRelation = IssueRelation.objects.bulk_create(
|
||||
issue_relation = IssueRelation.objects.bulk_create(
|
||||
[
|
||||
IssueRelation(
|
||||
issue_id=related_issue["issue"],
|
||||
@@ -2104,12 +2151,19 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
return Response(
|
||||
IssueRelationSerializer(issueRelation, many=True).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
if relation == "blocking":
|
||||
return Response(
|
||||
RelatedIssueSerializer(issue_relation, many=True).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
IssueRelationSerializer(issue_relation, many=True).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
@@ -2142,6 +2196,8 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
.select_related("issue")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
class IssueRetrievePublicEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
@@ -2350,6 +2406,28 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
]
|
||||
serializer_class = IssueFlatSerializer
|
||||
model = Issue
|
||||
|
||||
|
||||
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_draft.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
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
@@ -2376,6 +2454,7 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
@@ -2485,6 +2564,82 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
serializer = IssueCreateSerializer(
|
||||
data=request.data,
|
||||
context={
|
||||
"project_id": project_id,
|
||||
"workspace_id": project.workspace_id,
|
||||
"default_assignee_id": project.default_assignee_id,
|
||||
},
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save(is_draft=True)
|
||||
|
||||
# Track the issue
|
||||
issue_activity.delay(
|
||||
type="issue_draft.activity.created",
|
||||
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(serializer.data.get("id", None)),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
except Project.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
try:
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
serializer = IssueSerializer(
|
||||
issue, data=request.data, partial=True
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
if(request.data.get("is_draft") is not None and not request.data.get("is_draft")):
|
||||
serializer.save(created_at=timezone.now(), updated_at=timezone.now())
|
||||
else:
|
||||
serializer.save()
|
||||
issue_activity.delay(
|
||||
type="issue_draft.activity.updated",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
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(issue).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Issue.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "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,
|
||||
)
|
||||
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
try:
|
||||
issue = Issue.objects.get(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import json
|
||||
|
||||
# Django Imports
|
||||
from django.utils import timezone
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q
|
||||
from django.core import serializers
|
||||
@@ -39,6 +40,7 @@ from plane.utils.grouper import group_results
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
|
||||
class ModuleViewSet(BaseViewSet):
|
||||
model = Module
|
||||
permission_classes = [
|
||||
@@ -77,35 +79,63 @@ class ModuleViewSet(BaseViewSet):
|
||||
queryset=ModuleLink.objects.select_related("module", "created_by"),
|
||||
)
|
||||
)
|
||||
.annotate(total_issues=Count("issue_module"))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_module",
|
||||
filter=Q(
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="completed"),
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="completed",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="cancelled"),
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="cancelled",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="started"),
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="started",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="unstarted"),
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="unstarted",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="backlog"),
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="backlog",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(order_by, "name")
|
||||
@@ -129,6 +159,7 @@ class ModuleViewSet(BaseViewSet):
|
||||
issue_id=str(self.kwargs.get("pk", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
return super().perform_destroy(instance)
|
||||
@@ -177,18 +208,36 @@ class ModuleViewSet(BaseViewSet):
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
|
||||
.annotate(total_issues=Count("assignee_id"))
|
||||
.values(
|
||||
"first_name", "last_name", "assignee_id", "avatar", "display_name"
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("first_name", "last_name")
|
||||
@@ -204,17 +253,33 @@ class ModuleViewSet(BaseViewSet):
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("label_id"))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
@@ -277,6 +342,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
issue_id=str(self.kwargs.get("pk", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
@@ -308,6 +374,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
try:
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.issue_objects.filter(issue_module__module_id=module_id)
|
||||
@@ -346,9 +413,15 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
|
||||
issues_data = IssueStateSerializer(issues, many=True).data
|
||||
|
||||
if sub_group_by and sub_group_by == group_by:
|
||||
return Response(
|
||||
{"error": "Group by and sub group by cannot be same"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
return Response(
|
||||
group_results(issues_data, group_by),
|
||||
group_results(issues_data, group_by, sub_group_by),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -437,6 +510,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
return Response(
|
||||
@@ -483,7 +557,6 @@ class ModuleLinkViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class ModuleFavoriteViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = ModuleFavoriteSerializer
|
||||
model = ModuleFavorite
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Python imports
|
||||
import jwt
|
||||
import boto3
|
||||
from datetime import datetime
|
||||
|
||||
# Django imports
|
||||
@@ -495,7 +496,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
serializer_class = ProjectMemberAdminSerializer
|
||||
model = ProjectMember
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
ProjectMemberPermission,
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
@@ -617,7 +618,8 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except ProjectMember.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project Member does not exist"}, status=status.HTTP_400_BAD_REQUEST
|
||||
{"error": "Project Member does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
@@ -1094,7 +1096,7 @@ class ProjectMemberEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
).select_related("project", "member")
|
||||
).select_related("project", "member", "workspace")
|
||||
serializer = ProjectMemberSerializer(project_members, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
@@ -1209,3 +1211,38 @@ class LeaveProjectEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ProjectPublicCoverImagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def get(self, request):
|
||||
try:
|
||||
files = []
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
)
|
||||
params = {
|
||||
"Bucket": settings.AWS_S3_BUCKET_NAME,
|
||||
"Prefix": "static/project-cover/",
|
||||
}
|
||||
|
||||
response = s3.list_objects_v2(**params)
|
||||
# Extracting file keys from the response
|
||||
if "Contents" in response:
|
||||
for content in response["Contents"]:
|
||||
if not content["Key"].endswith(
|
||||
"/"
|
||||
): # This line ensures we're only getting files, not "sub-folders"
|
||||
files.append(
|
||||
f"https://{settings.AWS_S3_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
|
||||
)
|
||||
|
||||
return Response(files, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response([], status=status.HTTP_200_OK)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.integrations.github import get_release_notes
|
||||
|
||||
|
||||
class ReleaseNotesEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
try:
|
||||
release_notes = get_release_notes()
|
||||
return Response(release_notes, 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,
|
||||
)
|
||||
@@ -1,4 +1,18 @@
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Exists,
|
||||
Max,
|
||||
)
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Prefetch, OuterRef, Exists
|
||||
|
||||
@@ -10,18 +24,192 @@ from sentry_sdk import capture_exception
|
||||
# Module imports
|
||||
from . import BaseViewSet, BaseAPIView
|
||||
from plane.api.serializers import (
|
||||
GlobalViewSerializer,
|
||||
IssueViewSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueViewFavoriteSerializer,
|
||||
)
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.api.permissions import WorkspaceEntityPermission, ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
GlobalView,
|
||||
IssueView,
|
||||
Issue,
|
||||
IssueViewFavorite,
|
||||
IssueReaction,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.grouper import group_results
|
||||
|
||||
|
||||
class GlobalViewViewSet(BaseViewSet):
|
||||
serializer_class = GlobalViewSerializer
|
||||
model = GlobalView
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
|
||||
serializer.save(workspace_id=workspace.id)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace")
|
||||
.order_by(self.request.GET.get("order_by", "-created_at"))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug):
|
||||
try:
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
priority_order = (
|
||||
priority_order
|
||||
if order_by_param == "priority"
|
||||
else priority_order[::-1]
|
||||
)
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
When(priority=p, then=Value(i))
|
||||
for i, p in enumerate(priority_order)
|
||||
],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
# State Ordering
|
||||
elif order_by_param in [
|
||||
"state__name",
|
||||
"state__group",
|
||||
"-state__name",
|
||||
"-state__group",
|
||||
]:
|
||||
state_order = (
|
||||
state_order
|
||||
if order_by_param in ["state__name", "state__group"]
|
||||
else state_order[::-1]
|
||||
)
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
state_order=Case(
|
||||
*[
|
||||
When(state__group=state_group, then=Value(i))
|
||||
for i, state_group in enumerate(state_order)
|
||||
],
|
||||
default=Value(len(state_order)),
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("state_order")
|
||||
# assignee and label ordering
|
||||
elif order_by_param in [
|
||||
"labels__name",
|
||||
"-labels__name",
|
||||
"assignees__first_name",
|
||||
"-assignees__first_name",
|
||||
]:
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
max_values=Max(
|
||||
order_by_param[1::]
|
||||
if order_by_param.startswith("-")
|
||||
else order_by_param
|
||||
)
|
||||
).order_by(
|
||||
"-max_values" if order_by_param.startswith("-") else "max_values"
|
||||
)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
issues = IssueLiteSerializer(issue_queryset, many=True).data
|
||||
|
||||
## Grouping the results
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
if sub_group_by and sub_group_by == group_by:
|
||||
return Response(
|
||||
{"error": "Group by and sub group by cannot be same"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
return Response(
|
||||
group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response(issues, 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,
|
||||
)
|
||||
|
||||
|
||||
class IssueViewViewSet(BaseViewSet):
|
||||
|
||||
@@ -1197,7 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
|
||||
projects = request.query_params.getlist("project", [])
|
||||
|
||||
queryset = IssueActivity.objects.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction"]),
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
actor=user_id,
|
||||
@@ -1239,13 +1239,21 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
|
||||
.annotate(
|
||||
created_issues=Count(
|
||||
"project_issue",
|
||||
filter=Q(project_issue__created_by_id=user_id),
|
||||
filter=Q(
|
||||
project_issue__created_by_id=user_id,
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
assigned_issues=Count(
|
||||
"project_issue",
|
||||
filter=Q(project_issue__assignees__in=[user_id]),
|
||||
filter=Q(
|
||||
project_issue__assignees__in=[user_id],
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
@@ -1254,6 +1262,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
|
||||
filter=Q(
|
||||
project_issue__completed_at__isnull=False,
|
||||
project_issue__assignees__in=[user_id],
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -1267,6 +1277,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
|
||||
"started",
|
||||
],
|
||||
project_issue__assignees__in=[user_id],
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -1317,6 +1329,11 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, user_id):
|
||||
try:
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
issue_queryset = (
|
||||
Issue.issue_objects.filter(
|
||||
|
||||
@@ -32,7 +32,7 @@ def delete_old_s3_link():
|
||||
else:
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
region_name="ap-south-1",
|
||||
region_name=settings.AWS_REGION,
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
|
||||
@@ -39,6 +39,7 @@ def track_name(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch
|
||||
):
|
||||
if current_instance.get("name") != requested_data.get("name"):
|
||||
issue_activities.append(
|
||||
@@ -52,6 +53,7 @@ def track_name(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the name to {requested_data.get('name')}",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -64,6 +66,7 @@ def track_parent(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch
|
||||
):
|
||||
if current_instance.get("parent") != requested_data.get("parent"):
|
||||
if requested_data.get("parent") == None:
|
||||
@@ -81,6 +84,7 @@ def track_parent(
|
||||
comment=f"updated the parent issue to None",
|
||||
old_identifier=old_parent.id,
|
||||
new_identifier=None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -101,6 +105,7 @@ def track_parent(
|
||||
comment=f"updated the parent issue to {new_parent.name}",
|
||||
old_identifier=old_parent.id if old_parent is not None else None,
|
||||
new_identifier=new_parent.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -113,36 +118,23 @@ def track_priority(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch
|
||||
):
|
||||
if current_instance.get("priority") != requested_data.get("priority"):
|
||||
if requested_data.get("priority") == None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("priority"),
|
||||
new_value=None,
|
||||
field="priority",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the priority to None",
|
||||
)
|
||||
)
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("priority"),
|
||||
new_value=requested_data.get("priority"),
|
||||
field="priority",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the priority to {requested_data.get('priority')}",
|
||||
)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("priority"),
|
||||
new_value=requested_data.get("priority"),
|
||||
field="priority",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the priority to {requested_data.get('priority')}",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Track chnages in state of the issue
|
||||
@@ -153,6 +145,7 @@ def track_state(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch
|
||||
):
|
||||
if current_instance.get("state") != requested_data.get("state"):
|
||||
new_state = State.objects.get(pk=requested_data.get("state", None))
|
||||
@@ -171,6 +164,7 @@ def track_state(
|
||||
comment=f"updated the state to {new_state.name}",
|
||||
old_identifier=old_state.id,
|
||||
new_identifier=new_state.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -183,6 +177,7 @@ def track_description(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch
|
||||
):
|
||||
if current_instance.get("description_html") != requested_data.get(
|
||||
"description_html"
|
||||
@@ -203,6 +198,7 @@ def track_description(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the description to {requested_data.get('description_html')}",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -215,6 +211,7 @@ def track_target_date(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch
|
||||
):
|
||||
if current_instance.get("target_date") != requested_data.get("target_date"):
|
||||
if requested_data.get("target_date") == None:
|
||||
@@ -229,6 +226,7 @@ def track_target_date(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the target date to None",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -243,6 +241,7 @@ def track_target_date(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the target date to {requested_data.get('target_date')}",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -255,6 +254,7 @@ def track_start_date(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch
|
||||
):
|
||||
if current_instance.get("start_date") != requested_data.get("start_date"):
|
||||
if requested_data.get("start_date") == None:
|
||||
@@ -269,6 +269,7 @@ def track_start_date(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the start date to None",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -283,6 +284,7 @@ def track_start_date(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the start date to {requested_data.get('start_date')}",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -295,6 +297,7 @@ def track_labels(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch
|
||||
):
|
||||
# Label Addition
|
||||
if len(requested_data.get("labels_list")) > len(current_instance.get("labels")):
|
||||
@@ -314,6 +317,7 @@ def track_labels(
|
||||
comment=f"added label {label.name}",
|
||||
new_identifier=label.id,
|
||||
old_identifier=None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -335,6 +339,7 @@ def track_labels(
|
||||
comment=f"removed label {label.name}",
|
||||
old_identifier=label.id,
|
||||
new_identifier=None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -347,6 +352,7 @@ def track_assignees(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch
|
||||
):
|
||||
# Assignee Addition
|
||||
if len(requested_data.get("assignees_list")) > len(
|
||||
@@ -367,6 +373,7 @@ def track_assignees(
|
||||
workspace=project.workspace,
|
||||
comment=f"added assignee {assignee.display_name}",
|
||||
new_identifier=assignee.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -389,27 +396,29 @@ def track_assignees(
|
||||
workspace=project.workspace,
|
||||
comment=f"removed assignee {assignee.display_name}",
|
||||
old_identifier=assignee.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"created the issue",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"created the issue",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def track_estimate_points(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
if current_instance.get("estimate_point") != requested_data.get("estimate_point"):
|
||||
if requested_data.get("estimate_point") == None:
|
||||
@@ -424,6 +433,7 @@ def track_estimate_points(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the estimate point to None",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -438,12 +448,13 @@ def track_estimate_points(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the estimate point to {requested_data.get('estimate_point')}",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def track_archive_at(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
if requested_data.get("archived_at") is None:
|
||||
issue_activities.append(
|
||||
@@ -457,6 +468,7 @@ def track_archive_at(
|
||||
field="archived_at",
|
||||
old_value="archive",
|
||||
new_value="restore",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -471,12 +483,13 @@ def track_archive_at(
|
||||
field="archived_at",
|
||||
old_value=None,
|
||||
new_value="archive",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def track_closed_to(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
if requested_data.get("closed_to") is not None:
|
||||
updated_state = State.objects.get(
|
||||
@@ -496,12 +509,13 @@ def track_closed_to(
|
||||
comment=f"Plane updated the state to {updated_state.name}",
|
||||
old_identifier=None,
|
||||
new_identifier=updated_state.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
ISSUE_ACTIVITY_MAPPER = {
|
||||
"name": track_name,
|
||||
@@ -533,11 +547,12 @@ def update_issue_activity(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch
|
||||
)
|
||||
|
||||
|
||||
def delete_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -547,12 +562,13 @@ def delete_issue_activity(
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="issue",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_comment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
@@ -571,12 +587,13 @@ def create_comment_activity(
|
||||
new_value=requested_data.get("comment_html", ""),
|
||||
new_identifier=requested_data.get("id", None),
|
||||
issue_comment_id=requested_data.get("id", None),
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_comment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
@@ -598,12 +615,13 @@ def update_comment_activity(
|
||||
new_value=requested_data.get("comment_html", ""),
|
||||
new_identifier=current_instance.get("id", None),
|
||||
issue_comment_id=current_instance.get("id", None),
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_comment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -614,12 +632,13 @@ def delete_comment_activity(
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="comment",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_cycle_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
@@ -651,6 +670,7 @@ def create_cycle_issue_activity(
|
||||
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
|
||||
old_identifier=old_cycle.id,
|
||||
new_identifier=new_cycle.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -671,12 +691,13 @@ def create_cycle_issue_activity(
|
||||
workspace=project.workspace,
|
||||
comment=f"added cycle {cycle.name}",
|
||||
new_identifier=cycle.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_cycle_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
@@ -700,12 +721,13 @@ def delete_cycle_issue_activity(
|
||||
workspace=project.workspace,
|
||||
comment=f"removed this issue from {cycle.name if cycle is not None else None}",
|
||||
old_identifier=cycle.id if cycle is not None else None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_module_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
@@ -737,6 +759,7 @@ def create_module_issue_activity(
|
||||
comment=f"updated module from {old_module.name} to {new_module.name}",
|
||||
old_identifier=old_module.id,
|
||||
new_identifier=new_module.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -756,12 +779,13 @@ def create_module_issue_activity(
|
||||
workspace=project.workspace,
|
||||
comment=f"added module {module.name}",
|
||||
new_identifier=module.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_module_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
@@ -785,12 +809,13 @@ def delete_module_issue_activity(
|
||||
workspace=project.workspace,
|
||||
comment=f"removed this issue from {module.name if module is not None else None}",
|
||||
old_identifier=module.id if module is not None else None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_link_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
@@ -808,12 +833,13 @@ def create_link_activity(
|
||||
field="link",
|
||||
new_value=requested_data.get("url", ""),
|
||||
new_identifier=requested_data.get("id", None),
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_link_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
@@ -834,12 +860,13 @@ def update_link_activity(
|
||||
old_identifier=current_instance.get("id"),
|
||||
new_value=requested_data.get("url", ""),
|
||||
new_identifier=current_instance.get("id", None),
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_link_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
|
||||
current_instance = (
|
||||
@@ -856,13 +883,14 @@ def delete_link_activity(
|
||||
actor=actor,
|
||||
field="link",
|
||||
old_value=current_instance.get("url", ""),
|
||||
new_value=""
|
||||
new_value="",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_attachment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
@@ -880,12 +908,13 @@ def create_attachment_activity(
|
||||
field="attachment",
|
||||
new_value=current_instance.get("asset", ""),
|
||||
new_identifier=current_instance.get("id", None),
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_attachment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -896,11 +925,12 @@ def delete_attachment_activity(
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="attachment",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
def create_issue_reaction_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
if requested_data and requested_data.get("reaction") is not None:
|
||||
@@ -919,12 +949,13 @@ def create_issue_reaction_activity(
|
||||
comment="added the reaction",
|
||||
old_identifier=None,
|
||||
new_identifier=issue_reaction,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_issue_reaction_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
@@ -943,12 +974,13 @@ def delete_issue_reaction_activity(
|
||||
comment="removed the reaction",
|
||||
old_identifier=current_instance.get("identifier"),
|
||||
new_identifier=None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_comment_reaction_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
if requested_data and requested_data.get("reaction") is not None:
|
||||
@@ -968,12 +1000,13 @@ def create_comment_reaction_activity(
|
||||
comment="added the reaction",
|
||||
old_identifier=None,
|
||||
new_identifier=comment_reaction_id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_comment_reaction_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
@@ -994,12 +1027,13 @@ def delete_comment_reaction_activity(
|
||||
comment="removed the reaction",
|
||||
old_identifier=current_instance.get("identifier"),
|
||||
new_identifier=None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_issue_vote_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
if requested_data and requested_data.get("vote") is not None:
|
||||
@@ -1016,12 +1050,13 @@ def create_issue_vote_activity(
|
||||
comment="added the vote",
|
||||
old_identifier=None,
|
||||
new_identifier=None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_issue_vote_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
@@ -1040,12 +1075,13 @@ def delete_issue_vote_activity(
|
||||
comment="removed the vote",
|
||||
old_identifier=current_instance.get("identifier"),
|
||||
new_identifier=None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_issue_relation_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
@@ -1053,6 +1089,25 @@ def create_issue_relation_activity(
|
||||
)
|
||||
if current_instance is None and requested_data.get("related_list") is not None:
|
||||
for issue_relation in requested_data.get("related_list"):
|
||||
if issue_relation.get("relation_type") == "blocked_by":
|
||||
relation_type = "blocking"
|
||||
else:
|
||||
relation_type = issue_relation.get("relation_type")
|
||||
issue = Issue.objects.get(pk=issue_relation.get("issue"))
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_relation.get("related_issue"),
|
||||
actor=actor,
|
||||
verb="created",
|
||||
old_value="",
|
||||
new_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
field=relation_type,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f'added {relation_type} relation',
|
||||
old_identifier=issue_relation.get("issue"),
|
||||
)
|
||||
)
|
||||
issue = Issue.objects.get(pk=issue_relation.get("related_issue"))
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -1065,36 +1120,125 @@ def create_issue_relation_activity(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f'added {issue_relation.get("relation_type")} relation',
|
||||
old_identifier=issue_relation.get("issue"),
|
||||
old_identifier=issue_relation.get("related_issue"),
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_issue_relation_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
if current_instance is not None and requested_data.get("related_list") is None:
|
||||
issue = Issue.objects.get(pk=current_instance.get("issue"))
|
||||
if current_instance.get("relation_type") == "blocked_by":
|
||||
relation_type = "blocking"
|
||||
else:
|
||||
relation_type = current_instance.get("relation_type")
|
||||
issue = Issue.objects.get(pk=current_instance.get("issue"))
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=current_instance.get("related_issue"),
|
||||
actor=actor,
|
||||
verb="deleted",
|
||||
old_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
new_value="",
|
||||
field=relation_type,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f'deleted {relation_type} relation',
|
||||
old_identifier=current_instance.get("issue"),
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
issue = Issue.objects.get(pk=current_instance.get("related_issue"))
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=current_instance.get("issue"),
|
||||
actor=actor,
|
||||
verb="deleted",
|
||||
old_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
new_value="",
|
||||
field=f'{current_instance.get("relation_type")}',
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f'deleted {current_instance.get("relation_type")} relation',
|
||||
old_identifier=current_instance.get("related_issue"),
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_draft_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=current_instance.get("issue"),
|
||||
actor=actor,
|
||||
verb="deleted",
|
||||
old_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
new_value="",
|
||||
field=f'{current_instance.get("relation_type")}',
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f'deleted the {current_instance.get("relation_type")} relation',
|
||||
old_identifier=current_instance.get("issue"),
|
||||
comment=f"drafted the issue",
|
||||
field="draft",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_draft_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
if requested_data.get("is_draft") is not None and requested_data.get("is_draft") == False:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"created the issue",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the draft issue",
|
||||
field="draft",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
def delete_draft_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"deleted the draft issue",
|
||||
field="draft",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
# Receive message from room group
|
||||
@shared_task
|
||||
def issue_activity(
|
||||
@@ -1104,6 +1248,7 @@ def issue_activity(
|
||||
issue_id,
|
||||
actor_id,
|
||||
project_id,
|
||||
epoch,
|
||||
subscriber=True,
|
||||
):
|
||||
try:
|
||||
@@ -1166,6 +1311,9 @@ def issue_activity(
|
||||
"comment_reaction.activity.deleted": delete_comment_reaction_activity,
|
||||
"issue_vote.activity.created": create_issue_vote_activity,
|
||||
"issue_vote.activity.deleted": delete_issue_vote_activity,
|
||||
"issue_draft.activity.created": create_draft_issue_activity,
|
||||
"issue_draft.activity.updated": update_draft_issue_activity,
|
||||
"issue_draft.activity.deleted": delete_draft_issue_activity,
|
||||
}
|
||||
|
||||
func = ACTIVITY_MAPPER.get(type)
|
||||
@@ -1177,6 +1325,7 @@ def issue_activity(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch,
|
||||
)
|
||||
|
||||
# Save all the values to database
|
||||
@@ -1240,7 +1389,7 @@ def issue_activity(
|
||||
):
|
||||
issue_subscribers = issue_subscribers + [issue.created_by_id]
|
||||
|
||||
for subscriber in issue_subscribers:
|
||||
for subscriber in list(set(issue_subscribers)):
|
||||
for issue_activity in issue_activities_created:
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
|
||||
@@ -58,27 +58,31 @@ def archive_old_issues():
|
||||
|
||||
# Check if Issues
|
||||
if issues:
|
||||
# Set the archive time to current time
|
||||
archive_at = timezone.now()
|
||||
|
||||
issues_to_update = []
|
||||
for issue in issues:
|
||||
issue.archived_at = timezone.now()
|
||||
issue.archived_at = archive_at
|
||||
issues_to_update.append(issue)
|
||||
|
||||
# Bulk Update the issues and log the activity
|
||||
if issues_to_update:
|
||||
updated_issues = Issue.objects.bulk_update(
|
||||
Issue.objects.bulk_update(
|
||||
issues_to_update, ["archived_at"], batch_size=100
|
||||
)
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"archived_at": str(issue.archived_at)}),
|
||||
requested_data=json.dumps({"archived_at": str(archive_at)}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
for issue in updated_issues
|
||||
for issue in issues_to_update
|
||||
]
|
||||
return
|
||||
except Exception as e:
|
||||
@@ -138,7 +142,7 @@ def close_old_issues():
|
||||
|
||||
# Bulk Update the issues and log the activity
|
||||
if issues_to_update:
|
||||
updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
|
||||
Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
@@ -148,8 +152,9 @@ def close_old_issues():
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
)
|
||||
for issue in updated_issues
|
||||
for issue in issues_to_update
|
||||
]
|
||||
return
|
||||
except Exception as e:
|
||||
|
||||
@@ -33,9 +33,8 @@ def create_issue_relation(apps, schema_editor):
|
||||
def update_issue_priority_choice(apps, schema_editor):
|
||||
IssueModel = apps.get_model("db", "Issue")
|
||||
updated_issues = []
|
||||
for obj in IssueModel.objects.all():
|
||||
if obj.priority is None:
|
||||
obj.priority = "none"
|
||||
for obj in IssueModel.objects.filter(priority=None):
|
||||
obj.priority = "none"
|
||||
updated_issues.append(obj)
|
||||
IssueModel.objects.bulk_update(updated_issues, ["priority"], batch_size=100)
|
||||
|
||||
|
||||
@@ -26,19 +26,19 @@ def workspace_member_props(old_props):
|
||||
"calendar_date_range": old_props.get("calendarDateRange", ""),
|
||||
},
|
||||
"display_properties": {
|
||||
"assignee": old_props.get("properties", {}).get("assignee",None),
|
||||
"attachment_count": old_props.get("properties", {}).get("attachment_count", None),
|
||||
"created_on": old_props.get("properties", {}).get("created_on", None),
|
||||
"due_date": old_props.get("properties", {}).get("due_date", None),
|
||||
"estimate": old_props.get("properties", {}).get("estimate", None),
|
||||
"key": old_props.get("properties", {}).get("key", None),
|
||||
"labels": old_props.get("properties", {}).get("labels", None),
|
||||
"link": old_props.get("properties", {}).get("link", None),
|
||||
"priority": old_props.get("properties", {}).get("priority", None),
|
||||
"start_date": old_props.get("properties", {}).get("start_date", None),
|
||||
"state": old_props.get("properties", {}).get("state", None),
|
||||
"sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", None),
|
||||
"updated_on": old_props.get("properties", {}).get("updated_on", None),
|
||||
"assignee": old_props.get("properties", {}).get("assignee", True),
|
||||
"attachment_count": old_props.get("properties", {}).get("attachment_count", True),
|
||||
"created_on": old_props.get("properties", {}).get("created_on", True),
|
||||
"due_date": old_props.get("properties", {}).get("due_date", True),
|
||||
"estimate": old_props.get("properties", {}).get("estimate", True),
|
||||
"key": old_props.get("properties", {}).get("key", True),
|
||||
"labels": old_props.get("properties", {}).get("labels", True),
|
||||
"link": old_props.get("properties", {}).get("link", True),
|
||||
"priority": old_props.get("properties", {}).get("priority", True),
|
||||
"start_date": old_props.get("properties", {}).get("start_date", True),
|
||||
"state": old_props.get("properties", {}).get("state", True),
|
||||
"sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", True),
|
||||
"updated_on": old_props.get("properties", {}).get("updated_on", True),
|
||||
},
|
||||
}
|
||||
return new_props
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Generated by Django 4.2.5 on 2023-09-29 10:14
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import plane.db.models.workspace
|
||||
import uuid
|
||||
|
||||
|
||||
def update_issue_activity_priority(apps, schema_editor):
|
||||
IssueActivity = apps.get_model("db", "IssueActivity")
|
||||
updated_issue_activity = []
|
||||
for obj in IssueActivity.objects.filter(field="priority"):
|
||||
# Set the old and new value to none if it is empty for Priority
|
||||
obj.new_value = obj.new_value or "none"
|
||||
obj.old_value = obj.old_value or "none"
|
||||
updated_issue_activity.append(obj)
|
||||
IssueActivity.objects.bulk_update(
|
||||
updated_issue_activity,
|
||||
["new_value", "old_value"],
|
||||
batch_size=2000,
|
||||
)
|
||||
|
||||
def update_issue_activity_blocked(apps, schema_editor):
|
||||
IssueActivity = apps.get_model("db", "IssueActivity")
|
||||
updated_issue_activity = []
|
||||
for obj in IssueActivity.objects.filter(field="blocks"):
|
||||
# Set the field to blocked_by
|
||||
obj.field = "blocked_by"
|
||||
updated_issue_activity.append(obj)
|
||||
IssueActivity.objects.bulk_update(
|
||||
updated_issue_activity,
|
||||
["field"],
|
||||
batch_size=1000,
|
||||
)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0044_auto_20230913_0709'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GlobalView',
|
||||
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=255, verbose_name='View Name')),
|
||||
('description', models.TextField(blank=True, verbose_name='View Description')),
|
||||
('query', models.JSONField(verbose_name='View Query')),
|
||||
('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)),
|
||||
('query_data', models.JSONField(default=dict)),
|
||||
('sort_order', models.FloatField(default=65535)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_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='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_views', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Global View',
|
||||
'verbose_name_plural': 'Global Views',
|
||||
'db_table': 'global_views',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspacemember',
|
||||
name='issue_props',
|
||||
field=models.JSONField(default=plane.db.models.workspace.get_issue_props),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issueactivity',
|
||||
name='epoch',
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
migrations.RunPython(update_issue_activity_priority),
|
||||
migrations.RunPython(update_issue_activity_blocked),
|
||||
]
|
||||
@@ -50,7 +50,7 @@ from .state import State
|
||||
|
||||
from .cycle import Cycle, CycleIssue, CycleFavorite
|
||||
|
||||
from .view import IssueView, IssueViewFavorite
|
||||
from .view import GlobalView, IssueView, IssueViewFavorite
|
||||
|
||||
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite
|
||||
|
||||
|
||||
@@ -309,6 +309,7 @@ class IssueActivity(ProjectBaseModel):
|
||||
)
|
||||
old_identifier = models.UUIDField(null=True)
|
||||
new_identifier = models.UUIDField(null=True)
|
||||
epoch = models.FloatField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Activity"
|
||||
|
||||
@@ -25,13 +25,26 @@ ROLE_CHOICES = (
|
||||
|
||||
def get_default_props():
|
||||
return {
|
||||
"filters": {"type": None},
|
||||
"orderBy": "-created_at",
|
||||
"collapsed": True,
|
||||
"issueView": "list",
|
||||
"filterIssue": None,
|
||||
"groupByProperty": None,
|
||||
"showEmptyGroups": True,
|
||||
"filters": {
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
},
|
||||
"display_filters": {
|
||||
"group_by": None,
|
||||
"order_by": '-created_at',
|
||||
"type": None,
|
||||
"sub_issue": True,
|
||||
"show_empty_groups": True,
|
||||
"layout": "list",
|
||||
"calendar_date_range": "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,41 @@ from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
# Module import
|
||||
from . import ProjectBaseModel
|
||||
from . import ProjectBaseModel, BaseModel
|
||||
|
||||
|
||||
class GlobalView(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="global_views"
|
||||
)
|
||||
name = models.CharField(max_length=255, verbose_name="View Name")
|
||||
description = models.TextField(verbose_name="View Description", blank=True)
|
||||
query = models.JSONField(verbose_name="View Query")
|
||||
access = models.PositiveSmallIntegerField(
|
||||
default=1, choices=((0, "Private"), (1, "Public"))
|
||||
)
|
||||
query_data = models.JSONField(default=dict)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Global View"
|
||||
verbose_name_plural = "Global Views"
|
||||
db_table = "global_views"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
largest_sort_order = GlobalView.objects.filter(
|
||||
workspace=self.workspace
|
||||
).aggregate(largest=models.Max("sort_order"))["largest"]
|
||||
if largest_sort_order is not None:
|
||||
self.sort_order = largest_sort_order + 10000
|
||||
|
||||
super(GlobalView, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the View"""
|
||||
return f"{self.name} <{self.workspace.name}>"
|
||||
|
||||
|
||||
class IssueView(ProjectBaseModel):
|
||||
|
||||
@@ -16,26 +16,50 @@ ROLE_CHOICES = (
|
||||
|
||||
def get_default_props():
|
||||
return {
|
||||
"filters": {"type": None},
|
||||
"groupByProperty": None,
|
||||
"issueView": "list",
|
||||
"orderBy": "-created_at",
|
||||
"properties": {
|
||||
"filters": {
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
},
|
||||
"display_filters": {
|
||||
"group_by": None,
|
||||
"order_by": "-created_at",
|
||||
"type": None,
|
||||
"sub_issue": True,
|
||||
"show_empty_groups": True,
|
||||
"layout": "list",
|
||||
"calendar_date_range": "",
|
||||
},
|
||||
"display_properties": {
|
||||
"assignee": True,
|
||||
"attachment_count": True,
|
||||
"created_on": True,
|
||||
"due_date": True,
|
||||
"estimate": True,
|
||||
"key": True,
|
||||
"labels": True,
|
||||
"link": True,
|
||||
"priority": True,
|
||||
"start_date": True,
|
||||
"state": True,
|
||||
"sub_issue_count": True,
|
||||
"attachment_count": True,
|
||||
"link": True,
|
||||
"estimate": True,
|
||||
"created_on": True,
|
||||
"updated_on": True,
|
||||
"start_date": True,
|
||||
},
|
||||
"showEmptyGroups": True,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_issue_props():
|
||||
return {
|
||||
"subscribed": True,
|
||||
"assigned": True,
|
||||
"created": True,
|
||||
"all_issues": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +98,7 @@ class WorkspaceMember(BaseModel):
|
||||
company_role = models.TextField(null=True, blank=True)
|
||||
view_props = models.JSONField(default=get_default_props)
|
||||
default_props = models.JSONField(default=get_default_props)
|
||||
issue_props = models.JSONField(default=get_issue_props)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "member"]
|
||||
|
||||
@@ -114,3 +114,6 @@ CELERY_BROKER_URL = os.environ.get("REDIS_URL")
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
||||
|
||||
# Unsplash Access key
|
||||
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""Production settings and globals."""
|
||||
from urllib.parse import urlparse
|
||||
import ssl
|
||||
import certifi
|
||||
|
||||
import dj_database_url
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .common import * # noqa
|
||||
|
||||
@@ -197,7 +196,6 @@ else:
|
||||
STORAGES["default"] = {
|
||||
"BACKEND": "django_s3_storage.storage.S3Storage",
|
||||
}
|
||||
|
||||
# AWS Settings End
|
||||
|
||||
# Enable Connection Pooling (if desired)
|
||||
@@ -265,15 +263,19 @@ if DOCKERIZED:
|
||||
CELERY_BROKER_URL = REDIS_URL
|
||||
CELERY_RESULT_BACKEND = REDIS_URL
|
||||
else:
|
||||
CELERY_RESULT_BACKEND = broker_url
|
||||
CELERY_BROKER_URL = broker_url
|
||||
CELERY_RESULT_BACKEND = broker_url
|
||||
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
|
||||
# Enable or Disable signups
|
||||
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
||||
|
||||
# Scout Settings
|
||||
SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
|
||||
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
|
||||
SCOUT_NAME = "Plane"
|
||||
|
||||
# Unsplash Access key
|
||||
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
|
||||
|
||||
|
||||
129
apiserver/plane/settings/selfhosted.py
Normal file
129
apiserver/plane/settings/selfhosted.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Self hosted settings and globals."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import dj_database_url
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
from .common import * # noqa
|
||||
|
||||
# Database
|
||||
DEBUG = int(os.environ.get("DEBUG", 0)) == 1
|
||||
|
||||
# Docker configurations
|
||||
DOCKERIZED = 1
|
||||
USE_MINIO = 1
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "plane",
|
||||
"USER": os.environ.get("PGUSER", ""),
|
||||
"PASSWORD": os.environ.get("PGPASSWORD", ""),
|
||||
"HOST": os.environ.get("PGHOST", ""),
|
||||
}
|
||||
}
|
||||
|
||||
# Parse database configuration from $DATABASE_URL
|
||||
DATABASES["default"] = dj_database_url.config()
|
||||
SITE_ID = 1
|
||||
|
||||
# File size limit
|
||||
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
|
||||
|
||||
CORS_ALLOW_METHODS = [
|
||||
"DELETE",
|
||||
"GET",
|
||||
"OPTIONS",
|
||||
"PATCH",
|
||||
"POST",
|
||||
"PUT",
|
||||
]
|
||||
|
||||
CORS_ALLOW_HEADERS = [
|
||||
"accept",
|
||||
"accept-encoding",
|
||||
"authorization",
|
||||
"content-type",
|
||||
"dnt",
|
||||
"origin",
|
||||
"user-agent",
|
||||
"x-csrftoken",
|
||||
"x-requested-with",
|
||||
]
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
|
||||
STORAGES = {
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
INSTALLED_APPS += ("storages",)
|
||||
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
|
||||
# The AWS access key to use.
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
|
||||
# The AWS secret access key to use.
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
|
||||
# The name of the bucket to store files in.
|
||||
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
|
||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||
AWS_S3_ENDPOINT_URL = os.environ.get(
|
||||
"AWS_S3_ENDPOINT_URL", "http://plane-minio:9000"
|
||||
)
|
||||
# Default permissions
|
||||
AWS_DEFAULT_ACL = "public-read"
|
||||
AWS_QUERYSTRING_AUTH = False
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
|
||||
# Custom Domain settings
|
||||
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
|
||||
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
|
||||
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
|
||||
|
||||
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
# Allow all host headers
|
||||
ALLOWED_HOSTS = [
|
||||
"*",
|
||||
]
|
||||
|
||||
# Security settings
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
|
||||
# Redis URL
|
||||
REDIS_URL = os.environ.get("REDIS_URL")
|
||||
|
||||
# Caches
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": REDIS_URL,
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# URL used for email redirects
|
||||
WEB_URL = os.environ.get("WEB_URL", "http://localhost")
|
||||
|
||||
# Celery settings
|
||||
CELERY_BROKER_URL = REDIS_URL
|
||||
CELERY_RESULT_BACKEND = REDIS_URL
|
||||
|
||||
# Enable or Disable signups
|
||||
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
||||
|
||||
# Analytics
|
||||
ANALYTICS_BASE_API = False
|
||||
|
||||
# OPEN AI Settings
|
||||
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
|
||||
|
||||
@@ -218,3 +218,7 @@ CELERY_BROKER_URL = broker_url
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
||||
|
||||
|
||||
# Unsplash Access key
|
||||
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
|
||||
|
||||
@@ -74,10 +74,10 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
|
||||
|
||||
sorted_data = grouped_data
|
||||
if temp_axis == "priority":
|
||||
order = ["low", "medium", "high", "urgent", "None"]
|
||||
order = ["low", "medium", "high", "urgent", "none"]
|
||||
sorted_data = {key: grouped_data[key] for key in order if key in grouped_data}
|
||||
else:
|
||||
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0])))
|
||||
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "none", x[0])))
|
||||
return sorted_data
|
||||
|
||||
|
||||
|
||||
@@ -39,14 +39,90 @@ def group_results(results_data, group_by, sub_group_by=False):
|
||||
|
||||
for value in results_data:
|
||||
main_group_attribute = resolve_keys(sub_group_by, value)
|
||||
if str(main_group_attribute) not in main_responsive_dict:
|
||||
main_responsive_dict[str(main_group_attribute)] = {}
|
||||
group_attribute = resolve_keys(group_by, value)
|
||||
if str(group_attribute) in main_responsive_dict:
|
||||
main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value)
|
||||
if isinstance(main_group_attribute, list) and not isinstance(group_attribute, list):
|
||||
if len(main_group_attribute):
|
||||
for attrib in main_group_attribute:
|
||||
if str(attrib) not in main_responsive_dict:
|
||||
main_responsive_dict[str(attrib)] = {}
|
||||
if str(group_attribute) in main_responsive_dict[str(attrib)]:
|
||||
main_responsive_dict[str(attrib)][str(group_attribute)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(attrib)][str(group_attribute)] = []
|
||||
main_responsive_dict[str(attrib)][str(group_attribute)].append(value)
|
||||
else:
|
||||
if str(None) not in main_responsive_dict:
|
||||
main_responsive_dict[str(None)] = {}
|
||||
|
||||
if str(group_attribute) in main_responsive_dict[str(None)]:
|
||||
main_responsive_dict[str(None)][str(group_attribute)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(None)][str(group_attribute)] = []
|
||||
main_responsive_dict[str(None)][str(group_attribute)].append(value)
|
||||
|
||||
elif isinstance(group_attribute, list) and not isinstance(main_group_attribute, list):
|
||||
if str(main_group_attribute) not in main_responsive_dict:
|
||||
main_responsive_dict[str(main_group_attribute)] = {}
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
if str(attrib) in main_responsive_dict[str(main_group_attribute)]:
|
||||
main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_group_attribute)][str(attrib)] = []
|
||||
main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value)
|
||||
else:
|
||||
if str(None) in main_responsive_dict[str(main_group_attribute)]:
|
||||
main_responsive_dict[str(main_group_attribute)][str(None)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_group_attribute)][str(None)] = []
|
||||
main_responsive_dict[str(main_group_attribute)][str(None)].append(value)
|
||||
|
||||
elif isinstance(group_attribute, list) and isinstance(main_group_attribute, list):
|
||||
if len(main_group_attribute):
|
||||
for main_attrib in main_group_attribute:
|
||||
if str(main_attrib) not in main_responsive_dict:
|
||||
main_responsive_dict[str(main_attrib)] = {}
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
if str(attrib) in main_responsive_dict[str(main_attrib)]:
|
||||
main_responsive_dict[str(main_attrib)][str(attrib)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_attrib)][str(attrib)] = []
|
||||
main_responsive_dict[str(main_attrib)][str(attrib)].append(value)
|
||||
else:
|
||||
if str(None) in main_responsive_dict[str(main_attrib)]:
|
||||
main_responsive_dict[str(main_attrib)][str(None)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_attrib)][str(None)] = []
|
||||
main_responsive_dict[str(main_attrib)][str(None)].append(value)
|
||||
else:
|
||||
if str(None) not in main_responsive_dict:
|
||||
main_responsive_dict[str(None)] = {}
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
if str(attrib) in main_responsive_dict[str(None)]:
|
||||
main_responsive_dict[str(None)][str(attrib)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(None)][str(attrib)] = []
|
||||
main_responsive_dict[str(None)][str(attrib)].append(value)
|
||||
else:
|
||||
if str(None) in main_responsive_dict[str(None)]:
|
||||
main_responsive_dict[str(None)][str(None)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(None)][str(None)] = []
|
||||
main_responsive_dict[str(None)][str(None)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_group_attribute)][str(group_attribute)] = []
|
||||
main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value)
|
||||
main_group_attribute = resolve_keys(sub_group_by, value)
|
||||
group_attribute = resolve_keys(group_by, value)
|
||||
|
||||
if str(main_group_attribute) not in main_responsive_dict:
|
||||
main_responsive_dict[str(main_group_attribute)] = {}
|
||||
|
||||
if str(group_attribute) in main_responsive_dict[str(main_group_attribute)]:
|
||||
main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_group_attribute)][str(group_attribute)] = []
|
||||
main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value)
|
||||
|
||||
return main_responsive_dict
|
||||
|
||||
|
||||
@@ -40,9 +40,6 @@ def filter_priority(params, filter, method):
|
||||
priorities = params.get("priority").split(",")
|
||||
if len(priorities) and "" not in priorities:
|
||||
filter["priority__in"] = priorities
|
||||
else:
|
||||
if params.get("priority", None) and len(params.get("priority")):
|
||||
filter["priority__in"] = params.get("priority")
|
||||
return filter
|
||||
|
||||
|
||||
@@ -166,17 +163,17 @@ def filter_target_date(params, filter, method):
|
||||
for query in target_dates:
|
||||
target_date_query = query.split(";")
|
||||
if len(target_date_query) == 2 and "after" in target_date_query:
|
||||
filter["target_date__gt"] = target_date_query[0]
|
||||
filter["target_date__gte"] = target_date_query[0]
|
||||
else:
|
||||
filter["target_date__lt"] = target_date_query[0]
|
||||
filter["target_date__lte"] = target_date_query[0]
|
||||
else:
|
||||
if params.get("target_date", None) and len(params.get("target_date")):
|
||||
for query in params.get("target_date"):
|
||||
target_date_query = query.split(";")
|
||||
if len(target_date_query) == 2 and "after" in target_date_query:
|
||||
filter["target_date__gt"] = target_date_query[0]
|
||||
filter["target_date__gte"] = target_date_query[0]
|
||||
else:
|
||||
filter["target_date__lt"] = target_date_query[0]
|
||||
filter["target_date__lte"] = target_date_query[0]
|
||||
|
||||
return filter
|
||||
|
||||
|
||||
@@ -1,113 +1,171 @@
|
||||
version: "3.8"
|
||||
|
||||
x-api-and-worker-env:
|
||||
&api-and-worker-env
|
||||
DEBUG: ${DEBUG}
|
||||
SENTRY_DSN: ${SENTRY_DSN}
|
||||
DJANGO_SETTINGS_MODULE: plane.settings.production
|
||||
DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE}
|
||||
REDIS_URL: redis://plane-redis:6379/
|
||||
EMAIL_HOST: ${EMAIL_HOST}
|
||||
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
|
||||
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
|
||||
EMAIL_PORT: ${EMAIL_PORT}
|
||||
EMAIL_FROM: ${EMAIL_FROM}
|
||||
EMAIL_USE_TLS: ${EMAIL_USE_TLS}
|
||||
EMAIL_USE_SSL: ${EMAIL_USE_SSL}
|
||||
AWS_REGION: ${AWS_REGION}
|
||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||||
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
|
||||
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL}
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT}
|
||||
WEB_URL: ${WEB_URL}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
|
||||
DISABLE_COLLECTSTATIC: 1
|
||||
DOCKERIZED: 1
|
||||
OPENAI_API_BASE: ${OPENAI_API_BASE}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
GPT_ENGINE: ${GPT_ENGINE}
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
DEFAULT_EMAIL: ${DEFAULT_EMAIL}
|
||||
DEFAULT_PASSWORD: ${DEFAULT_PASSWORD}
|
||||
USE_MINIO: ${USE_MINIO}
|
||||
ENABLE_SIGNUP: ${ENABLE_SIGNUP}
|
||||
|
||||
services:
|
||||
plane-web:
|
||||
container_name: planefrontend
|
||||
web:
|
||||
container_name: web
|
||||
platform: linux/amd64
|
||||
image: makeplane/plane-frontend:latest
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh web/server.js web
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
|
||||
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
|
||||
NEXT_PUBLIC_GITHUB_ID: "0"
|
||||
NEXT_PUBLIC_SENTRY_DSN: "0"
|
||||
NEXT_PUBLIC_ENABLE_OAUTH: "0"
|
||||
NEXT_PUBLIC_ENABLE_SENTRY: "0"
|
||||
NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0"
|
||||
NEXT_PUBLIC_TRACK_EVENTS: "0"
|
||||
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0}
|
||||
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces}
|
||||
depends_on:
|
||||
- plane-api
|
||||
- plane-worker
|
||||
- api
|
||||
- worker
|
||||
|
||||
plane-deploy:
|
||||
container_name: planedeploy
|
||||
image: makeplane/plane-deploy:latest
|
||||
space:
|
||||
container_name: space
|
||||
platform: linux/amd64
|
||||
image: makeplane/plane-space:latest
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh space/server.js space
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
|
||||
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0}
|
||||
depends_on:
|
||||
- plane-api
|
||||
- plane-worker
|
||||
- plane-web
|
||||
- api
|
||||
- worker
|
||||
- web
|
||||
|
||||
plane-api:
|
||||
container_name: planebackend
|
||||
api:
|
||||
container_name: api
|
||||
platform: linux/amd64
|
||||
image: makeplane/plane-backend:latest
|
||||
restart: always
|
||||
command: ./bin/takeoff
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *api-and-worker-env
|
||||
- DEBUG=${DEBUG:-0}
|
||||
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted}
|
||||
- SENTRY_DSN=${SENTRY_DSN:-""}
|
||||
- PGUSER=${PGUSER:-plane}
|
||||
- PGPASSWORD=${PGPASSWORD:-plane}
|
||||
- PGHOST=${PGHOST:-plane-db}
|
||||
- PGDATABASE=${PGDATABASE:-plane}
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}}
|
||||
- REDIS_HOST=${REDIS_HOST:-plane-redis}
|
||||
- REDIS_PORT=${REDIS_PORT:-6379}
|
||||
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
|
||||
- EMAIL_HOST=${EMAIL_HOST:-""}
|
||||
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
|
||||
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
|
||||
- EMAIL_PORT=${EMAIL_PORT:-587}
|
||||
- EMAIL_FROM=${EMAIL_FROM:-Team Plane <team@mailer.plane.so>}
|
||||
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
|
||||
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
|
||||
- AWS_REGION=${AWS_REGION:-""}
|
||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
|
||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
|
||||
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
|
||||
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
|
||||
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
|
||||
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-sk-}
|
||||
- GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
|
||||
- DOCKERIZED=${DOCKERIZED:-1}
|
||||
- USE_MINIO=${USE_MINIO:-1}
|
||||
- NGINX_PORT=${NGINX_PORT:-80}
|
||||
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
|
||||
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
|
||||
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
|
||||
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
|
||||
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
|
||||
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
plane-worker:
|
||||
container_name: planebgworker
|
||||
worker:
|
||||
container_name: bgworker
|
||||
platform: linux/amd64
|
||||
image: makeplane/plane-backend:latest
|
||||
restart: always
|
||||
command: ./bin/worker
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *api-and-worker-env
|
||||
- DEBUG=${DEBUG:-0}
|
||||
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted}
|
||||
- SENTRY_DSN=${SENTRY_DSN:-""}
|
||||
- PGUSER=${PGUSER:-plane}
|
||||
- PGPASSWORD=${PGPASSWORD:-plane}
|
||||
- PGHOST=${PGHOST:-plane-db}
|
||||
- PGDATABASE=${PGDATABASE:-plane}
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}}
|
||||
- REDIS_HOST=${REDIS_HOST:-plane-redis}
|
||||
- REDIS_PORT=${REDIS_PORT:-6379}
|
||||
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
|
||||
- EMAIL_HOST=${EMAIL_HOST:-""}
|
||||
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
|
||||
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
|
||||
- EMAIL_PORT=${EMAIL_PORT:-587}
|
||||
- EMAIL_FROM=${EMAIL_FROM:-Team Plane <team@mailer.plane.so>}
|
||||
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
|
||||
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
|
||||
- AWS_REGION=${AWS_REGION:-""}
|
||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
|
||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
|
||||
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
|
||||
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
|
||||
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
|
||||
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-sk-}
|
||||
- GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
|
||||
- DOCKERIZED=${DOCKERIZED:-1}
|
||||
- USE_MINIO=${USE_MINIO:-1}
|
||||
- NGINX_PORT=${NGINX_PORT:-80}
|
||||
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
|
||||
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
|
||||
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
|
||||
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||
depends_on:
|
||||
- plane-api
|
||||
- api
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
plane-beat-worker:
|
||||
container_name: planebeatworker
|
||||
beat-worker:
|
||||
container_name: beatworker
|
||||
platform: linux/amd64
|
||||
image: makeplane/plane-backend:latest
|
||||
restart: always
|
||||
command: ./bin/beat
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *api-and-worker-env
|
||||
- DEBUG=${DEBUG:-0}
|
||||
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted}
|
||||
- SENTRY_DSN=${SENTRY_DSN:-""}
|
||||
- PGUSER=${PGUSER:-plane}
|
||||
- PGPASSWORD=${PGPASSWORD:-plane}
|
||||
- PGHOST=${PGHOST:-plane-db}
|
||||
- PGDATABASE=${PGDATABASE:-plane}
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}}
|
||||
- REDIS_HOST=${REDIS_HOST:-plane-redis}
|
||||
- REDIS_PORT=${REDIS_PORT:-6379}
|
||||
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
|
||||
- EMAIL_HOST=${EMAIL_HOST:-""}
|
||||
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
|
||||
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
|
||||
- EMAIL_PORT=${EMAIL_PORT:-587}
|
||||
- EMAIL_FROM=${EMAIL_FROM:-Team Plane <team@mailer.plane.so>}
|
||||
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
|
||||
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
|
||||
- AWS_REGION=${AWS_REGION:-""}
|
||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
|
||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
|
||||
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
|
||||
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
|
||||
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
|
||||
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-sk-}
|
||||
- GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
|
||||
- DOCKERIZED=${DOCKERIZED:-1}
|
||||
- USE_MINIO=${USE_MINIO:-1}
|
||||
- NGINX_PORT=${NGINX_PORT:-80}
|
||||
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
|
||||
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
|
||||
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
|
||||
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||
depends_on:
|
||||
- plane-api
|
||||
- api
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
@@ -119,13 +177,11 @@ services:
|
||||
command: postgres -c 'max_connections=1000'
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_USER: ${PGUSER}
|
||||
POSTGRES_DB: ${PGDATABASE}
|
||||
POSTGRES_PASSWORD: ${PGPASSWORD}
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
- POSTGRES_USER=${POSTGRES_USER:-plane}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-plane}
|
||||
- PGDATA=${PGDATA:-/var/lib/postgresql/data}
|
||||
|
||||
plane-redis:
|
||||
container_name: plane-redis
|
||||
@@ -141,35 +197,36 @@ services:
|
||||
command: server /export --console-address ":9090"
|
||||
volumes:
|
||||
- uploads:/export
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID}
|
||||
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-access-key}
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-secret-key}
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
entrypoint: >
|
||||
/bin/sh -c " /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; /usr/bin/mc mb plane-minio/\$AWS_S3_BUCKET_NAME; /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; "
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
|
||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
|
||||
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
|
||||
depends_on:
|
||||
- plane-minio
|
||||
|
||||
# Comment this if you already have a reverse proxy running
|
||||
plane-proxy:
|
||||
container_name: planeproxy
|
||||
proxy:
|
||||
container_name: proxy
|
||||
platform: linux/amd64
|
||||
image: makeplane/plane-proxy:latest
|
||||
ports:
|
||||
- ${NGINX_PORT}:80
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
- NGINX_PORT=${NGINX_PORT:-80}
|
||||
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
|
||||
- BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
|
||||
depends_on:
|
||||
- plane-web
|
||||
- plane-api
|
||||
- web
|
||||
- api
|
||||
- space
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
@@ -1,88 +1,35 @@
|
||||
version: "3.8"
|
||||
|
||||
x-api-and-worker-env: &api-and-worker-env
|
||||
DEBUG: ${DEBUG}
|
||||
SENTRY_DSN: ${SENTRY_DSN}
|
||||
DJANGO_SETTINGS_MODULE: plane.settings.production
|
||||
DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE}
|
||||
REDIS_URL: redis://plane-redis:6379/
|
||||
EMAIL_HOST: ${EMAIL_HOST}
|
||||
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
|
||||
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
|
||||
EMAIL_PORT: ${EMAIL_PORT}
|
||||
EMAIL_FROM: ${EMAIL_FROM}
|
||||
EMAIL_USE_TLS: ${EMAIL_USE_TLS}
|
||||
EMAIL_USE_SSL: ${EMAIL_USE_SSL}
|
||||
AWS_REGION: ${AWS_REGION}
|
||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||||
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
|
||||
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL}
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT}
|
||||
WEB_URL: ${WEB_URL}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
|
||||
DISABLE_COLLECTSTATIC: 1
|
||||
DOCKERIZED: 1
|
||||
OPENAI_API_BASE: ${OPENAI_API_BASE}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
GPT_ENGINE: ${GPT_ENGINE}
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
DEFAULT_EMAIL: ${DEFAULT_EMAIL}
|
||||
DEFAULT_PASSWORD: ${DEFAULT_PASSWORD}
|
||||
USE_MINIO: ${USE_MINIO}
|
||||
ENABLE_SIGNUP: ${ENABLE_SIGNUP}
|
||||
|
||||
services:
|
||||
plane-web:
|
||||
container_name: planefrontend
|
||||
web:
|
||||
container_name: web
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./web/Dockerfile.web
|
||||
args:
|
||||
DOCKER_BUILDKIT: 1
|
||||
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
|
||||
NEXT_PUBLIC_DEPLOY_URL: http://localhost/spaces
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh web/server.js web
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
|
||||
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
|
||||
NEXT_PUBLIC_GITHUB_ID: "0"
|
||||
NEXT_PUBLIC_SENTRY_DSN: "0"
|
||||
NEXT_PUBLIC_ENABLE_OAUTH: "0"
|
||||
NEXT_PUBLIC_ENABLE_SENTRY: "0"
|
||||
NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0"
|
||||
NEXT_PUBLIC_TRACK_EVENTS: "0"
|
||||
depends_on:
|
||||
- plane-api
|
||||
- plane-worker
|
||||
- api
|
||||
- worker
|
||||
|
||||
plane-deploy:
|
||||
container_name: planedeploy
|
||||
space:
|
||||
container_name: space
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./space/Dockerfile.space
|
||||
args:
|
||||
DOCKER_BUILDKIT: 1
|
||||
NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1
|
||||
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh space/server.js space
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
|
||||
depends_on:
|
||||
- plane-api
|
||||
- plane-worker
|
||||
- plane-web
|
||||
- api
|
||||
- worker
|
||||
- web
|
||||
|
||||
plane-api:
|
||||
container_name: planebackend
|
||||
api:
|
||||
container_name: api
|
||||
build:
|
||||
context: ./apiserver
|
||||
dockerfile: Dockerfile.api
|
||||
@@ -91,15 +38,13 @@ services:
|
||||
restart: always
|
||||
command: ./bin/takeoff
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *api-and-worker-env
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
plane-worker:
|
||||
container_name: planebgworker
|
||||
worker:
|
||||
container_name: bgworker
|
||||
build:
|
||||
context: ./apiserver
|
||||
dockerfile: Dockerfile.api
|
||||
@@ -108,16 +53,14 @@ services:
|
||||
restart: always
|
||||
command: ./bin/worker
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *api-and-worker-env
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
- plane-api
|
||||
- api
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
plane-beat-worker:
|
||||
container_name: planebeatworker
|
||||
beat-worker:
|
||||
container_name: beatworker
|
||||
build:
|
||||
context: ./apiserver
|
||||
dockerfile: Dockerfile.api
|
||||
@@ -126,11 +69,9 @@ services:
|
||||
restart: always
|
||||
command: ./bin/beat
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *api-and-worker-env
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
- plane-api
|
||||
- api
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
@@ -163,8 +104,6 @@ services:
|
||||
command: server /export --console-address ":9090"
|
||||
volumes:
|
||||
- uploads:/export
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID}
|
||||
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
|
||||
@@ -179,22 +118,21 @@ services:
|
||||
- plane-minio
|
||||
|
||||
# Comment this if you already have a reverse proxy running
|
||||
plane-proxy:
|
||||
container_name: planeproxy
|
||||
proxy:
|
||||
container_name: proxy
|
||||
build:
|
||||
context: ./nginx
|
||||
dockerfile: Dockerfile
|
||||
restart: always
|
||||
ports:
|
||||
- ${NGINX_PORT}:80
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
depends_on:
|
||||
- plane-web
|
||||
- plane-api
|
||||
- web
|
||||
- api
|
||||
- space
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
events { }
|
||||
|
||||
events {
|
||||
}
|
||||
|
||||
http {
|
||||
sendfile on;
|
||||
sendfile on;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
root /www/data/;
|
||||
access_log /var/log/nginx/access.log;
|
||||
server {
|
||||
listen 80;
|
||||
root /www/data/;
|
||||
access_log /var/log/nginx/access.log;
|
||||
|
||||
client_max_body_size ${FILE_SIZE_LIMIT};
|
||||
client_max_body_size ${FILE_SIZE_LIMIT};
|
||||
|
||||
location / {
|
||||
proxy_pass http://planefrontend:3000/;
|
||||
}
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Permissions-Policy "interest-cohort=()" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://planebackend:8000/api/;
|
||||
}
|
||||
location / {
|
||||
proxy_pass http://web:3000/;
|
||||
}
|
||||
|
||||
location /spaces/ {
|
||||
proxy_pass http://planedeploy:3000/spaces/;
|
||||
}
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000/api/;
|
||||
}
|
||||
|
||||
location /${BUCKET_NAME}/ {
|
||||
proxy_pass http://plane-minio:9000/uploads/;
|
||||
location /spaces/ {
|
||||
rewrite ^/spaces/?$ /spaces/login break;
|
||||
proxy_pass http://space:3000/spaces/;
|
||||
}
|
||||
|
||||
location /${BUCKET_NAME}/ {
|
||||
proxy_pass http://plane-minio:9000/uploads/;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.13.2",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eslint-config-custom",
|
||||
"version": "0.0.0",
|
||||
"version": "0.13.2",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tailwind-config-custom",
|
||||
"version": "0.0.1",
|
||||
"version": "0.13.2",
|
||||
"description": "common tailwind configuration across monorepo",
|
||||
"main": "index.js",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tsconfig",
|
||||
"version": "0.0.0",
|
||||
"version": "0.13.2",
|
||||
"private": true,
|
||||
"files": [
|
||||
"base.json",
|
||||
|
||||
1
packages/ui/README.md
Normal file
1
packages/ui/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# UI Package
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"version": "0.0.0",
|
||||
"version": "0.13.2",
|
||||
"main": "./index.tsx",
|
||||
"types": "./index.tsx",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
FROM=$1
|
||||
TO=$2
|
||||
DIRECTORY=$3
|
||||
|
||||
if [ "${FROM}" = "${TO}" ]; then
|
||||
echo "Nothing to replace, the value is already set to ${TO}."
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only perform action if $FROM and $TO are different.
|
||||
echo "Replacing all statically built instances of $FROM with this string $TO ."
|
||||
|
||||
grep -R -la "${FROM}" $DIRECTORY/.next | xargs -I{} sed -i "s|$FROM|$TO|g" "{}"
|
||||
24
setup.sh
24
setup.sh
@@ -5,25 +5,9 @@ cp ./.env.example ./.env
|
||||
export LC_ALL=C
|
||||
export LC_CTYPE=C
|
||||
|
||||
|
||||
# Generate the NEXT_PUBLIC_API_BASE_URL with given IP
|
||||
echo -e "\nNEXT_PUBLIC_API_BASE_URL=$1" >> ./.env
|
||||
cp ./web/.env.example ./web/.env
|
||||
cp ./space/.env.example ./space/.env
|
||||
cp ./apiserver/.env.example ./apiserver/.env
|
||||
|
||||
# Generate the SECRET_KEY that will be used by django
|
||||
echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./.env
|
||||
|
||||
# WEB_URL for email redirection and image saving
|
||||
echo -e "WEB_URL=$1" >> ./.env
|
||||
|
||||
# Generate Prompt for taking tiptap auth key
|
||||
echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token \e[0m \n"
|
||||
|
||||
echo -e "\e[1;38m 1. Head over to TipTap cloud's Pro Extensions Page, https://collab.tiptap.dev/pro-extensions \e[0m"
|
||||
echo -e "\e[1;38m 2. Copy the token given to you under the first paragraph, after 'Here it is' \e[0m \n"
|
||||
|
||||
read -p $'\e[1;32m Please Enter Your TipTap Pro Extensions Authentication Token: \e[0m \e[1;36m' authToken
|
||||
|
||||
|
||||
echo "@tiptap-pro:registry=https://registry.tiptap.dev/
|
||||
//registry.tiptap.dev/:_authToken=${authToken}" > .npmrc
|
||||
|
||||
echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env
|
||||
@@ -1,8 +1,2 @@
|
||||
# Base url for the API requests
|
||||
NEXT_PUBLIC_API_BASE_URL=""
|
||||
# Public boards deploy URL
|
||||
NEXT_PUBLIC_DEPLOY_URL=""
|
||||
# Google Client ID for Google OAuth
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
||||
# Flag to toggle OAuth
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=1
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
@@ -1,7 +1,6 @@
|
||||
FROM node:18-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
|
||||
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
@@ -20,19 +19,16 @@ RUN yarn install --network-timeout 500000
|
||||
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
USER root
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
|
||||
|
||||
RUN yarn turbo run build --filter=space
|
||||
|
||||
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} space
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -48,14 +44,14 @@ COPY --from=installer --chown=captain:plane /app/space/.next/standalone ./
|
||||
COPY --from=installer --chown=captain:plane /app/space/.next ./space/.next
|
||||
COPY --from=installer --chown=captain:plane /app/space/public ./space/public
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
|
||||
|
||||
USER root
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
COPY start.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
RUN chmod +x /usr/local/bin/start.sh
|
||||
|
||||
USER captain
|
||||
|
||||
@@ -10,9 +10,12 @@ import githubWhiteImage from "public/logos/github-white.svg";
|
||||
|
||||
export interface GithubLoginButtonProps {
|
||||
handleSignIn: React.Dispatch<string>;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn }) => {
|
||||
export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
|
||||
const { handleSignIn, clientId } = props;
|
||||
// states
|
||||
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||
const [gitCode, setGitCode] = useState<null | string>(null);
|
||||
|
||||
@@ -38,7 +41,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn })
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<Link
|
||||
className="w-full"
|
||||
href={`https://github.com/login/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||
href={`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||
>
|
||||
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
|
||||
<Image
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
|
||||
|
||||
import { FC, useEffect, useRef, useCallback, useState } from "react";
|
||||
import Script from "next/script";
|
||||
|
||||
export interface IGoogleLoginButton {
|
||||
text?: string;
|
||||
clientId: string;
|
||||
handleSignIn: React.Dispatch<any>;
|
||||
styles?: CSSProperties;
|
||||
}
|
||||
|
||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
|
||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
const { handleSignIn, clientId } = props;
|
||||
// refs
|
||||
const googleSignInButton = useRef<HTMLDivElement>(null);
|
||||
// states
|
||||
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
||||
|
||||
const loadScript = useCallback(() => {
|
||||
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
||||
|
||||
(window as any)?.google?.accounts.id.initialize({
|
||||
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
|
||||
client_id: clientId,
|
||||
callback: handleSignIn,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import React from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// services
|
||||
import authenticationService from "services/authentication.service";
|
||||
import { AppConfigService } from "services/app-config.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
|
||||
import { EmailPasswordForm, GoogleLoginButton, EmailCodeForm } from "components/accounts";
|
||||
// images
|
||||
const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces/" : "";
|
||||
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
|
||||
|
||||
const appConfig = new AppConfigService();
|
||||
|
||||
export const SignInView = observer(() => {
|
||||
const { user: userStore } = useMobxStore();
|
||||
|
||||
// router
|
||||
const router = useRouter();
|
||||
|
||||
const { next_path } = router.query as { next_path: string };
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
// fetch app config
|
||||
const { data } = useSWR("APP_CONFIG", () => appConfig.envConfig());
|
||||
|
||||
const onSignInError = (error: any) => {
|
||||
setToastAlert({
|
||||
@@ -31,17 +35,17 @@ export const SignInView = observer(() => {
|
||||
};
|
||||
|
||||
const onSignInSuccess = (response: any) => {
|
||||
const isOnboarded = response?.user?.onboarding_step?.profile_complete || false;
|
||||
|
||||
const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/";
|
||||
|
||||
userStore.setCurrentUser(response?.user);
|
||||
|
||||
if (!isOnboarded) {
|
||||
router.push(`/onboarding?next_path=${nextPath}`);
|
||||
return;
|
||||
const isOnboard = response?.user?.onboarding_step?.profile_complete || false;
|
||||
|
||||
if (isOnboard) {
|
||||
if (next_path) router.push(next_path);
|
||||
else router.push("/login");
|
||||
} else {
|
||||
if (next_path) router.push(`/onboarding?next_path=${next_path}`);
|
||||
else router.push("/onboarding");
|
||||
}
|
||||
router.push((nextPath ?? "/").toString());
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
|
||||
@@ -63,24 +67,6 @@ export const SignInView = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitHubSignIn = async (credential: string) => {
|
||||
try {
|
||||
if (process.env.NEXT_PUBLIC_GITHUB_ID && credential) {
|
||||
const socialAuthPayload = {
|
||||
medium: "github",
|
||||
credential,
|
||||
clientId: process.env.NEXT_PUBLIC_GITHUB_ID,
|
||||
};
|
||||
const response = await authenticationService.socialAuth(socialAuthPayload);
|
||||
onSignInSuccess(response);
|
||||
} else {
|
||||
throw Error("Cant find credentials");
|
||||
}
|
||||
} catch (err: any) {
|
||||
onSignInError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSignIn = async (formData: any) => {
|
||||
await authenticationService
|
||||
.emailLogin(formData)
|
||||
@@ -118,38 +104,32 @@ export const SignInView = observer(() => {
|
||||
</div>
|
||||
<div className="grid place-items-center h-full overflow-y-auto py-5 px-7">
|
||||
<div>
|
||||
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
|
||||
<>
|
||||
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
|
||||
Sign in to Plane
|
||||
</h1>
|
||||
<div className="flex flex-col divide-y divide-custom-border-200">
|
||||
<div className="pb-7">
|
||||
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
|
||||
<GoogleLoginButton handleSignIn={handleGoogleSignIn} />
|
||||
{/* <GithubLoginButton handleSignIn={handleGitHubSignIn} /> */}
|
||||
</div>
|
||||
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">Sign in to Plane</h1>
|
||||
{data?.email_password_login && <EmailPasswordForm onSubmit={handlePasswordSignIn} />}
|
||||
|
||||
{data?.magic_login && (
|
||||
<div className="flex flex-col divide-y divide-custom-border-200">
|
||||
<div className="pb-7">
|
||||
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmailPasswordForm onSubmit={handlePasswordSignIn} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
|
||||
<p className="pt-16 text-custom-text-200 text-sm text-center">
|
||||
By signing up, you agree to the{" "}
|
||||
<a
|
||||
href="https://plane.so/terms-and-conditions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline"
|
||||
>
|
||||
Terms & Conditions
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
|
||||
{data?.google && <GoogleLoginButton clientId={data.google} handleSignIn={handleGoogleSignIn} />}
|
||||
</div>
|
||||
|
||||
<p className="pt-16 text-custom-text-200 text-sm text-center">
|
||||
By signing up, you agree to the{" "}
|
||||
<a
|
||||
href="https://plane.so/terms-and-conditions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline"
|
||||
>
|
||||
Terms & Conditions
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,19 +44,43 @@ const IssueNavbar = observer(() => {
|
||||
}, [projectStore, workspace_slug, project_slug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace_slug && project_slug) {
|
||||
if (!board) {
|
||||
router.push({
|
||||
pathname: `/${workspace_slug}/${project_slug}`,
|
||||
query: {
|
||||
board: "list",
|
||||
},
|
||||
});
|
||||
return projectStore.setActiveBoard("list");
|
||||
if (workspace_slug && project_slug && projectStore?.deploySettings) {
|
||||
const viewsAcceptable: string[] = [];
|
||||
let currentBoard: string | null = null;
|
||||
|
||||
if (projectStore?.deploySettings?.views?.list) viewsAcceptable.push("list");
|
||||
if (projectStore?.deploySettings?.views?.kanban) viewsAcceptable.push("kanban");
|
||||
if (projectStore?.deploySettings?.views?.calendar) viewsAcceptable.push("calendar");
|
||||
if (projectStore?.deploySettings?.views?.gantt) viewsAcceptable.push("gantt");
|
||||
if (projectStore?.deploySettings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet");
|
||||
|
||||
if (board) {
|
||||
if (viewsAcceptable.includes(board.toString())) {
|
||||
currentBoard = board.toString();
|
||||
} else {
|
||||
if (viewsAcceptable && viewsAcceptable.length > 0) {
|
||||
currentBoard = viewsAcceptable[0];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (viewsAcceptable && viewsAcceptable.length > 0) {
|
||||
currentBoard = viewsAcceptable[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBoard) {
|
||||
if (projectStore?.activeBoard === null || projectStore?.activeBoard !== currentBoard) {
|
||||
projectStore.setActiveBoard(currentBoard);
|
||||
router.push({
|
||||
pathname: `/${workspace_slug}/${project_slug}`,
|
||||
query: {
|
||||
board: currentBoard,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
projectStore.setActiveBoard(board.toString());
|
||||
}
|
||||
}, [board, workspace_slug, project_slug]);
|
||||
}, [board, workspace_slug, project_slug, router, projectStore, projectStore?.deploySettings]);
|
||||
|
||||
return (
|
||||
<div className="px-5 relative w-full flex items-center gap-4">
|
||||
@@ -105,7 +129,7 @@ const IssueNavbar = observer(() => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-shrink-0">
|
||||
<Link href={`/?next_path=${router.asPath}`}>
|
||||
<Link href={`/login/?next_path=${router.asPath}`}>
|
||||
<a>
|
||||
<PrimaryButton className="flex-shrink-0" outline>
|
||||
Sign in
|
||||
|
||||
@@ -18,7 +18,6 @@ import Gapcursor from "@tiptap/extension-gapcursor";
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import UniqueID from "@tiptap-pro/extension-unique-id";
|
||||
import UpdatedImage from "./updated-image";
|
||||
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
|
||||
import { CustomTableCell } from "./table/table-cell";
|
||||
@@ -121,9 +120,6 @@ export const TiptapExtensions = (
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
UniqueID.configure({
|
||||
types: ["image"],
|
||||
}),
|
||||
SlashCommand(workspaceSlug, setIsSubmitting),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { SignInView, UserLoggedIn } from "components/accounts";
|
||||
|
||||
export const HomeView = observer(() => {
|
||||
const { user: userStore } = useMobxStore();
|
||||
|
||||
if (!userStore.currentUser) return <SignInView />;
|
||||
|
||||
return <UserLoggedIn />;
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
export * from "./home";
|
||||
export * from "./login";
|
||||
|
||||
19
space/components/views/login.tsx
Normal file
19
space/components/views/login.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { SignInView, UserLoggedIn } from "components/accounts";
|
||||
|
||||
export const LoginView = observer(() => {
|
||||
const { user: userStore } = useMobxStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
{userStore?.loader ? (
|
||||
<div className="relative w-screen h-screen flex justify-center items-center">Loading</div>
|
||||
) : (
|
||||
<>{userStore.currentUser ? <UserLoggedIn /> : <SignInView />}</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
1
space/helpers/common.helper.ts
Normal file
1
space/helpers/common.helper.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ? process.env.NEXT_PUBLIC_API_BASE_URL : "";
|
||||
@@ -3,12 +3,14 @@
|
||||
import { useEffect } from "react";
|
||||
// next imports
|
||||
import { useRouter } from "next/router";
|
||||
// js cookie
|
||||
import Cookie from "js-cookie";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { RootStore } from "store/root";
|
||||
|
||||
const MobxStoreInit = () => {
|
||||
const store: RootStore = useMobxStore();
|
||||
const { user: userStore }: RootStore = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const { states, labels, priorities } = router.query as { states: string[]; labels: string[]; priorities: string[] };
|
||||
@@ -19,6 +21,11 @@ const MobxStoreInit = () => {
|
||||
// store.issue.userSelectedStates = states || [];
|
||||
// }, [store.issue]);
|
||||
|
||||
useEffect(() => {
|
||||
const authToken = Cookie.get("accessToken") || null;
|
||||
if (authToken) userStore.fetchCurrentUser();
|
||||
}, [userStore]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "0.0.1",
|
||||
"version": "0.13.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4000",
|
||||
@@ -17,7 +17,6 @@
|
||||
"@heroicons/react": "^2.0.12",
|
||||
"@mui/icons-material": "^5.14.1",
|
||||
"@mui/material": "^5.14.1",
|
||||
"@tiptap-pro/extension-unique-id": "^2.1.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.0.4",
|
||||
"@tiptap/extension-color": "^2.0.4",
|
||||
"@tiptap/extension-gapcursor": "^2.1.7",
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import React from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// components
|
||||
import { HomeView } from "components/views";
|
||||
// next
|
||||
import { NextPage } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const HomePage = () => <HomeView />;
|
||||
const Index: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const { next_path } = router.query as { next_path: string };
|
||||
|
||||
export default HomePage;
|
||||
useEffect(() => {
|
||||
if (next_path) router.push(`/login?next_path=${next_path}`);
|
||||
else router.push(`/login`);
|
||||
}, [router, next_path]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Index;
|
||||
|
||||
8
space/pages/login/index.tsx
Normal file
8
space/pages/login/index.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from "react";
|
||||
|
||||
// components
|
||||
import { LoginView } from "components/views";
|
||||
|
||||
const LoginPage = () => <LoginView />;
|
||||
|
||||
export default LoginPage;
|
||||
@@ -5,7 +5,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { OnBoardingForm } from "components/accounts/onboarding-form";
|
||||
|
||||
const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces/" : "";
|
||||
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
|
||||
|
||||
const OnBoardingPage = () => {
|
||||
const { user: userStore } = useMobxStore();
|
||||
|
||||
30
space/services/app-config.service.ts
Normal file
30
space/services/app-config.service.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
// helper
|
||||
import { API_BASE_URL } from "helpers/common.helper";
|
||||
|
||||
export interface IEnvConfig {
|
||||
github: string;
|
||||
google: string;
|
||||
github_app_name: string | null;
|
||||
email_password_login: boolean;
|
||||
magic_login: boolean;
|
||||
}
|
||||
|
||||
export class AppConfigService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async envConfig(): Promise<IEnvConfig> {
|
||||
return this.get("/api/configs/", {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
import { API_BASE_URL } from "helpers/common.helper";
|
||||
|
||||
class AuthService extends APIService {
|
||||
constructor() {
|
||||
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async emailLogin(data: any) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
import { API_BASE_URL } from "helpers/common.helper";
|
||||
|
||||
interface UnSplashImage {
|
||||
id: string;
|
||||
@@ -29,7 +27,7 @@ interface UnSplashImageUrls {
|
||||
|
||||
class FileServices extends APIService {
|
||||
constructor() {
|
||||
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async uploadFile(workspaceSlug: string, file: FormData): Promise<any> {
|
||||
@@ -76,24 +74,6 @@ class FileServices extends APIService {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getUnsplashImages(page: number = 1, query?: string): Promise<UnSplashImage[]> {
|
||||
const url = "/api/unsplash";
|
||||
|
||||
return this.request({
|
||||
method: "get",
|
||||
url,
|
||||
params: {
|
||||
page,
|
||||
per_page: 20,
|
||||
query,
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data?.results ?? response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fileServices = new FileServices();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
import { API_BASE_URL } from "helpers/common.helper";
|
||||
|
||||
class IssueService extends APIService {
|
||||
constructor() {
|
||||
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async getPublicIssues(workspace_slug: string, project_slug: string, params: any): Promise<any> {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
import { API_BASE_URL } from "helpers/common.helper";
|
||||
|
||||
class ProjectService extends APIService {
|
||||
constructor() {
|
||||
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async getProjectSettings(workspace_slug: string, project_slug: string): Promise<any> {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
import { API_BASE_URL } from "helpers/common.helper";
|
||||
|
||||
class UserService extends APIService {
|
||||
constructor() {
|
||||
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async currentUser(): Promise<any> {
|
||||
|
||||
@@ -7,12 +7,17 @@ import { ActorDetail } from "types/issue";
|
||||
import { IUser } from "types/user";
|
||||
|
||||
export interface IUserStore {
|
||||
loader: boolean;
|
||||
error: any | null;
|
||||
currentUser: any | null;
|
||||
fetchCurrentUser: () => void;
|
||||
currentActor: () => any;
|
||||
}
|
||||
|
||||
class UserStore implements IUserStore {
|
||||
loader: boolean = false;
|
||||
error: any | null = null;
|
||||
|
||||
currentUser: IUser | null = null;
|
||||
// root store
|
||||
rootStore;
|
||||
@@ -22,6 +27,9 @@ class UserStore implements IUserStore {
|
||||
constructor(_rootStore: any) {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
loader: observable.ref,
|
||||
error: observable.ref,
|
||||
|
||||
currentUser: observable.ref,
|
||||
// actions
|
||||
setCurrentUser: action,
|
||||
@@ -73,14 +81,19 @@ class UserStore implements IUserStore {
|
||||
|
||||
fetchCurrentUser = async () => {
|
||||
try {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
const response = await this.userService.currentUser();
|
||||
if (response) {
|
||||
runInAction(() => {
|
||||
this.loader = false;
|
||||
this.currentUser = response;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch current user", error);
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
4
start.sh
4
start.sh
@@ -1,9 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -x
|
||||
|
||||
# Replace the statically built BUILT_NEXT_PUBLIC_API_BASE_URL with run-time NEXT_PUBLIC_API_BASE_URL
|
||||
# NOTE: if these values are the same, this will be skipped.
|
||||
/usr/local/bin/replace-env-vars.sh "$BUILT_NEXT_PUBLIC_API_BASE_URL" "$NEXT_PUBLIC_API_BASE_URL" $2
|
||||
|
||||
echo "Starting Plane Frontend.."
|
||||
node $1
|
||||
|
||||
21
turbo.json
21
turbo.json
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalEnv": [
|
||||
"NEXT_PUBLIC_GITHUB_ID",
|
||||
"NEXT_PUBLIC_GOOGLE_CLIENTID",
|
||||
"NEXT_PUBLIC_API_BASE_URL",
|
||||
"NEXT_PUBLIC_DEPLOY_URL",
|
||||
"API_BASE_URL",
|
||||
@@ -12,20 +10,21 @@
|
||||
"NEXT_PUBLIC_GITHUB_APP_NAME",
|
||||
"NEXT_PUBLIC_ENABLE_SENTRY",
|
||||
"NEXT_PUBLIC_ENABLE_OAUTH",
|
||||
"NEXT_PUBLIC_UNSPLASH_ACCESS",
|
||||
"NEXT_PUBLIC_UNSPLASH_ENABLED",
|
||||
"NEXT_PUBLIC_TRACK_EVENTS",
|
||||
"TRACKER_ACCESS_KEY",
|
||||
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
|
||||
"NEXT_PUBLIC_CRISP_ID",
|
||||
"NEXT_PUBLIC_ENABLE_SESSION_RECORDER",
|
||||
"NEXT_PUBLIC_SESSION_RECORDER_KEY",
|
||||
"NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS",
|
||||
"NEXT_PUBLIC_SLACK_CLIENT_ID",
|
||||
"NEXT_PUBLIC_SLACK_CLIENT_SECRET",
|
||||
"NEXT_PUBLIC_SUPABASE_URL",
|
||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
|
||||
"NEXT_PUBLIC_DEPLOY_WITH_NGINX"
|
||||
"NEXT_PUBLIC_DEPLOY_WITH_NGINX",
|
||||
"NEXT_PUBLIC_POSTHOG_KEY",
|
||||
"NEXT_PUBLIC_POSTHOG_HOST",
|
||||
"SLACK_OAUTH_URL",
|
||||
"SLACK_CLIENT_ID",
|
||||
"SLACK_CLIENT_SECRET",
|
||||
"JITSU_TRACKER_ACCESS_KEY",
|
||||
"JITSU_TRACKER_HOST",
|
||||
"UNSPLASH_ACCESS_KEY"
|
||||
],
|
||||
"pipeline": {
|
||||
"build": {
|
||||
|
||||
@@ -1,26 +1,4 @@
|
||||
# Base url for the API requests
|
||||
NEXT_PUBLIC_API_BASE_URL=""
|
||||
# Extra image domains that need to be added for Next Image
|
||||
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
|
||||
# Google Client ID for Google OAuth
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
||||
# GitHub App ID for GitHub OAuth
|
||||
NEXT_PUBLIC_GITHUB_ID=""
|
||||
# GitHub App Name for GitHub Integration
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME=""
|
||||
# Sentry DSN for error monitoring
|
||||
NEXT_PUBLIC_SENTRY_DSN=""
|
||||
# Enable/Disable OAUTH - default 0 for selfhosted instance
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
# Enable/Disable Sentry
|
||||
NEXT_PUBLIC_ENABLE_SENTRY=0
|
||||
# Enable/Disable session recording
|
||||
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
|
||||
# Enable/Disable event tracking
|
||||
NEXT_PUBLIC_TRACK_EVENTS=0
|
||||
# Slack Client ID for Slack Integration
|
||||
NEXT_PUBLIC_SLACK_CLIENT_ID=""
|
||||
# For Telemetry, set it to "app.plane.so"
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
|
||||
# Public boards deploy URL
|
||||
NEXT_PUBLIC_DEPLOY_URL=""
|
||||
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
|
||||
@@ -2,7 +2,6 @@ FROM node:18-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
|
||||
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
@@ -14,8 +13,8 @@ FROM node:18-alpine AS installer
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ARG NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ARG NEXT_PUBLIC_DEPLOY_URL=""
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
@@ -26,18 +25,12 @@ RUN yarn install --network-timeout 500000
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
USER root
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
|
||||
|
||||
RUN yarn turbo run build --filter=web
|
||||
|
||||
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} web
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -52,20 +45,15 @@ COPY --from=installer /app/web/package.json .
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=installer --chown=captain:plane /app/web/.next/standalone ./
|
||||
|
||||
COPY --from=installer --chown=captain:plane /app/web/.next ./web/.next
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ARG NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ARG NEXT_PUBLIC_DEPLOY_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
|
||||
|
||||
USER root
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
COPY start.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
RUN chmod +x /usr/local/bin/start.sh
|
||||
|
||||
USER captain
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
// react hook form
|
||||
import React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
// components
|
||||
import { EmailResetPasswordForm } from "components/account";
|
||||
// ui
|
||||
import { Input, PrimaryButton } from "components/ui";
|
||||
// types
|
||||
@@ -18,14 +11,12 @@ type EmailPasswordFormValues = {
|
||||
|
||||
type Props = {
|
||||
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
|
||||
setIsResettingPassword: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const isSignUpPage = router.pathname === "/sign-up";
|
||||
|
||||
export const EmailPasswordForm: React.FC<Props> = (props) => {
|
||||
const { onSubmit, setIsResettingPassword } = props;
|
||||
// form info
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -42,94 +33,62 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
|
||||
{isResettingPassword
|
||||
? "Reset your password"
|
||||
: isSignUpPage
|
||||
? "Sign up on Plane"
|
||||
: "Sign in to Plane"}
|
||||
</h1>
|
||||
{isResettingPassword ? (
|
||||
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} />
|
||||
) : (
|
||||
<form
|
||||
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Email address is required",
|
||||
validate: (value) =>
|
||||
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
||||
value
|
||||
) || "Email address is not valid",
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter your email address..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Password is required",
|
||||
}}
|
||||
error={errors.password}
|
||||
placeholder="Enter your password..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-xs">
|
||||
{isSignUpPage ? (
|
||||
<Link href="/">
|
||||
<a className="text-custom-text-200 hover:text-custom-primary-100">
|
||||
Already have an account? Sign in.
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsResettingPassword(true)}
|
||||
className="text-custom-text-200 hover:text-custom-primary-100"
|
||||
>
|
||||
Forgot your password?
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center h-[46px]"
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSignUpPage
|
||||
? isSubmitting
|
||||
? "Signing up..."
|
||||
: "Sign up"
|
||||
: isSubmitting
|
||||
? "Signing in..."
|
||||
: "Sign in"}
|
||||
</PrimaryButton>
|
||||
{!isSignUpPage && (
|
||||
<Link href="/sign-up">
|
||||
<a className="block text-custom-text-200 hover:text-custom-primary-100 text-xs mt-4">
|
||||
Don{"'"}t have an account? Sign up.
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<form
|
||||
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Email address is required",
|
||||
validate: (value) =>
|
||||
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
||||
value
|
||||
) || "Email address is not valid",
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter your email address..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Password is required",
|
||||
}}
|
||||
error={errors.password}
|
||||
placeholder="Enter your password..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsResettingPassword(true)}
|
||||
className="text-custom-text-200 hover:text-custom-primary-100"
|
||||
>
|
||||
Forgot your password?
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center h-[46px]"
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
114
web/components/account/email-signup-form.tsx
Normal file
114
web/components/account/email-signup-form.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Input, PrimaryButton } from "components/ui";
|
||||
// types
|
||||
type EmailPasswordFormValues = {
|
||||
email: string;
|
||||
password?: string;
|
||||
confirm_password: string;
|
||||
medium?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
|
||||
};
|
||||
|
||||
export const EmailSignUpForm: React.FC<Props> = (props) => {
|
||||
const { onSubmit } = props;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<EmailPasswordFormValues>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
confirm_password: "",
|
||||
medium: "email",
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Email address is required",
|
||||
validate: (value) =>
|
||||
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
||||
value
|
||||
) || "Email address is not valid",
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter your email address..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Password is required",
|
||||
}}
|
||||
error={errors.password}
|
||||
placeholder="Enter your password..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Password is required",
|
||||
validate: (val: string) => {
|
||||
if (watch("password") != val) {
|
||||
return "Your passwords do no match";
|
||||
}
|
||||
},
|
||||
}}
|
||||
error={errors.confirm_password}
|
||||
placeholder="Confirm your password..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-xs">
|
||||
<Link href="/">
|
||||
<a className="text-custom-text-200 hover:text-custom-primary-100">
|
||||
Already have an account? Sign in.
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center h-[46px]"
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Signing up..." : "Sign up"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,29 +1,27 @@
|
||||
import { useEffect, useState, FC } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// next-themes
|
||||
import { useTheme } from "next-themes";
|
||||
// images
|
||||
import githubBlackImage from "/public/logos/github-black.png";
|
||||
import githubWhiteImage from "/public/logos/github-white.png";
|
||||
|
||||
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
|
||||
|
||||
export interface GithubLoginButtonProps {
|
||||
handleSignIn: React.Dispatch<string>;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn }) => {
|
||||
export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
|
||||
const { handleSignIn, clientId } = props;
|
||||
// states
|
||||
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||
const [gitCode, setGitCode] = useState<null | string>(null);
|
||||
|
||||
// router
|
||||
const {
|
||||
query: { code },
|
||||
} = useRouter();
|
||||
|
||||
// theme
|
||||
const { theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -42,7 +40,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn })
|
||||
return (
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<Link
|
||||
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||
href={`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||
>
|
||||
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
|
||||
<Image
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
|
||||
|
||||
import { FC, useEffect, useRef, useCallback, useState } from "react";
|
||||
import Script from "next/script";
|
||||
|
||||
export interface IGoogleLoginButton {
|
||||
text?: string;
|
||||
handleSignIn: React.Dispatch<any>;
|
||||
styles?: CSSProperties;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
|
||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
const { handleSignIn, clientId } = props;
|
||||
// refs
|
||||
const googleSignInButton = useRef<HTMLDivElement>(null);
|
||||
// states
|
||||
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
||||
|
||||
const loadScript = useCallback(() => {
|
||||
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
||||
|
||||
window?.google?.accounts.id.initialize({
|
||||
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
|
||||
client_id: clientId,
|
||||
callback: handleSignIn,
|
||||
});
|
||||
|
||||
@@ -39,7 +40,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
|
||||
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
|
||||
|
||||
setGsiScriptLoaded(true);
|
||||
}, [handleSignIn, gsiScriptLoaded]);
|
||||
}, [handleSignIn, gsiScriptLoaded, clientId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (window?.google?.accounts?.id) {
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from "./email-password-form";
|
||||
export * from "./email-reset-password-form";
|
||||
export * from "./github-login-button";
|
||||
export * from "./google-login";
|
||||
export * from "./email-signup-form";
|
||||
|
||||
@@ -9,7 +9,6 @@ import { findStringWithMostCharacters } from "helpers/array.helper";
|
||||
import { generateBarColor } from "helpers/analytics.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse;
|
||||
|
||||
@@ -15,17 +15,19 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
<div className="divide-y divide-custom-border-200">
|
||||
<div>
|
||||
<h6 className="px-3 text-base font-medium">Pending issues</h6>
|
||||
{defaultAnalytics.pending_issue_user.length > 0 ? (
|
||||
{defaultAnalytics.pending_issue_user && defaultAnalytics.pending_issue_user.length > 0 ? (
|
||||
<BarGraph
|
||||
data={defaultAnalytics.pending_issue_user}
|
||||
indexBy="assignees__display_name"
|
||||
indexBy="assignees__id"
|
||||
keys={["count"]}
|
||||
height="250px"
|
||||
colors={() => `#f97316`}
|
||||
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)}
|
||||
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) =>
|
||||
d.count > 0 ? d.count : 50
|
||||
)}
|
||||
tooltip={(datum) => {
|
||||
const assignee = defaultAnalytics.pending_issue_user.find(
|
||||
(a) => a.assignees__display_name === `${datum.indexValue}`
|
||||
(a) => a.assignees__id === `${datum.indexValue}`
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -39,10 +41,9 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
}}
|
||||
axisBottom={{
|
||||
renderTick: (datum) => {
|
||||
const avatar =
|
||||
defaultAnalytics.pending_issue_user[datum.tickIndex]?.assignees__avatar ?? "";
|
||||
const assignee = defaultAnalytics.pending_issue_user[datum.tickIndex] ?? "";
|
||||
|
||||
if (avatar && avatar !== "")
|
||||
if (assignee && assignee?.assignees__avatar && assignee?.assignees__avatar !== "")
|
||||
return (
|
||||
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||
<image
|
||||
@@ -50,7 +51,7 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
y={10}
|
||||
width={16}
|
||||
height={16}
|
||||
xlinkHref={avatar}
|
||||
xlinkHref={assignee?.assignees__avatar}
|
||||
style={{ clipPath: "circle(50%)" }}
|
||||
/>
|
||||
</g>
|
||||
@@ -60,7 +61,7 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||
<circle cy={18} r={8} fill="#374151" />
|
||||
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
|
||||
{datum.value ? `${datum.value}`.toUpperCase()[0] : "?"}
|
||||
{datum.value ? `${assignee.assignees__display_name}`.toUpperCase()[0] : "?"}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,8 @@ import React, { useState } from "react";
|
||||
// component
|
||||
import { CustomSelect, ToggleSwitch } from "components/ui";
|
||||
import { SelectMonthModal } from "components/automation";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
// icon
|
||||
import { ArchiveRestore } from "lucide-react";
|
||||
// constants
|
||||
import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
|
||||
// types
|
||||
@@ -13,9 +13,14 @@ import { IProject } from "types";
|
||||
type Props = {
|
||||
projectDetails: IProject | undefined;
|
||||
handleChange: (formData: Partial<IProject>) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleChange }) => {
|
||||
export const AutoArchiveAutomation: React.FC<Props> = ({
|
||||
projectDetails,
|
||||
handleChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [monthModal, setmonthModal] = useState(false);
|
||||
|
||||
const initialValues: Partial<IProject> = { archive_in: 1 };
|
||||
@@ -28,14 +33,18 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
|
||||
handleClose={() => setmonthModal(false)}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-custom-border-300 bg-custom-background-90">
|
||||
<div className="flex items-center justify-between gap-x-8 gap-y-2">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h4 className="text-lg font-semibold">Auto-archive closed issues</h4>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Plane will automatically archive issues that have been completed or cancelled for the
|
||||
configured time period.
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 border-b border-custom-border-200 px-4 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center p-3 rounded bg-custom-background-90">
|
||||
<ArchiveRestore className="h-4 w-4 text-custom-text-100 flex-shrink-0" />
|
||||
</div>
|
||||
<div className="">
|
||||
<h4 className="text-sm font-medium">Auto-archive closed issues</h4>
|
||||
<p className="text-sm text-custom-text-200 tracking-tight">
|
||||
Plane will auto archive issues that have been completed or canceled.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={projectDetails?.archive_in !== 0}
|
||||
@@ -45,42 +54,47 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
|
||||
: handleChange({ archive_in: 0 })
|
||||
}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{projectDetails?.archive_in !== 0 && (
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="w-1/2 text-base font-medium">
|
||||
Auto-archive issues that are closed for
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<CustomSelect
|
||||
value={projectDetails?.archive_in}
|
||||
label={`${projectDetails?.archive_in} ${
|
||||
projectDetails?.archive_in === 1 ? "Month" : "Months"
|
||||
}`}
|
||||
onChange={(val: number) => {
|
||||
handleChange({ archive_in: val });
|
||||
}}
|
||||
input
|
||||
verticalPosition="top"
|
||||
width="w-full"
|
||||
>
|
||||
<>
|
||||
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
||||
<CustomSelect.Option key={month.label} value={month.value}>
|
||||
{month.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={() => setmonthModal(true)}
|
||||
>
|
||||
Customize Time Range
|
||||
</button>
|
||||
</>
|
||||
</CustomSelect>
|
||||
{projectDetails?.archive_in !== 0 && (
|
||||
<div className="ml-12">
|
||||
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
|
||||
<div className="w-1/2 text-sm font-medium">
|
||||
Auto-archive issues that are closed for
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<CustomSelect
|
||||
value={projectDetails?.archive_in}
|
||||
label={`${projectDetails?.archive_in} ${
|
||||
projectDetails?.archive_in === 1 ? "Month" : "Months"
|
||||
}`}
|
||||
onChange={(val: number) => {
|
||||
handleChange({ archive_in: val });
|
||||
}}
|
||||
input
|
||||
verticalPosition="bottom"
|
||||
width="w-full"
|
||||
disabled={disabled}
|
||||
>
|
||||
<>
|
||||
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
||||
<CustomSelect.Option key={month.label} value={month.value}>
|
||||
<span className="text-sm">{month.label}</span>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full text-sm select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={() => setmonthModal(true)}
|
||||
>
|
||||
Customise Time Range
|
||||
</button>
|
||||
</>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,11 +5,12 @@ import useSWR from "swr";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// component
|
||||
import { CustomSearchSelect, CustomSelect, ToggleSwitch } from "components/ui";
|
||||
import { CustomSearchSelect, CustomSelect, Icon, ToggleSwitch } from "components/ui";
|
||||
import { SelectMonthModal } from "components/automation";
|
||||
// icons
|
||||
import { ChevronDownIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
import { Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
import { StateGroupIcon } from "components/icons";
|
||||
import { ArchiveX } from "lucide-react";
|
||||
// services
|
||||
import stateService from "services/state.service";
|
||||
// constants
|
||||
@@ -23,9 +24,14 @@ import { getStatesList } from "helpers/state.helper";
|
||||
type Props = {
|
||||
projectDetails: IProject | undefined;
|
||||
handleChange: (formData: Partial<IProject>) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleChange }) => {
|
||||
export const AutoCloseAutomation: React.FC<Props> = ({
|
||||
projectDetails,
|
||||
handleChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [monthModal, setmonthModal] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
@@ -76,14 +82,18 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-custom-border-300 bg-custom-background-90">
|
||||
<div className="flex items-center justify-between gap-x-8 gap-y-2 ">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h4 className="text-lg font-semibold">Auto-close inactive issues</h4>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Plane will automatically close the issues that have not been updated for the
|
||||
configured time period.
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 border-b border-custom-border-200 px-4 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center p-3 rounded bg-custom-background-90">
|
||||
<ArchiveX className="h-4 w-4 text-red-500 flex-shrink-0" />
|
||||
</div>
|
||||
<div className="">
|
||||
<h4 className="text-sm font-medium">Auto-close issues</h4>
|
||||
<p className="text-sm text-custom-text-200 tracking-tight">
|
||||
Plane will automatically close issue that haven’t been completed or canceled.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={projectDetails?.close_in !== 0}
|
||||
@@ -93,84 +103,90 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
|
||||
: handleChange({ close_in: 0, default_state: null })
|
||||
}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{projectDetails?.close_in !== 0 && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="w-1/2 text-base font-medium">
|
||||
Auto-close issues that are inactive for
|
||||
<div className="ml-12">
|
||||
<div className="flex flex-col rounded bg-custom-background-90 border border-custom-border-200 p-2">
|
||||
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
|
||||
<div className="w-1/2 text-sm font-medium">
|
||||
Auto-close issues that are inactive for
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<CustomSelect
|
||||
value={projectDetails?.close_in}
|
||||
label={`${projectDetails?.close_in} ${
|
||||
projectDetails?.close_in === 1 ? "Month" : "Months"
|
||||
}`}
|
||||
onChange={(val: number) => {
|
||||
handleChange({ close_in: val });
|
||||
}}
|
||||
input
|
||||
width="w-full"
|
||||
disabled={disabled}
|
||||
>
|
||||
<>
|
||||
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
||||
<CustomSelect.Option key={month.label} value={month.value}>
|
||||
{month.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={() => setmonthModal(true)}
|
||||
>
|
||||
Customise Time Range
|
||||
</button>
|
||||
</>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<CustomSelect
|
||||
value={projectDetails?.close_in}
|
||||
label={`${projectDetails?.close_in} ${
|
||||
projectDetails?.close_in === 1 ? "Month" : "Months"
|
||||
}`}
|
||||
onChange={(val: number) => {
|
||||
handleChange({ close_in: val });
|
||||
}}
|
||||
input
|
||||
width="w-full"
|
||||
>
|
||||
<>
|
||||
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
||||
<CustomSelect.Option key={month.label} value={month.value}>
|
||||
{month.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={() => setmonthModal(true)}
|
||||
>
|
||||
Customize Time Range
|
||||
</button>
|
||||
</>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="w-1/2 text-base font-medium">Auto-close Status</div>
|
||||
<div className="w-1/2 ">
|
||||
<CustomSearchSelect
|
||||
value={
|
||||
projectDetails?.default_state ? projectDetails?.default_state : defaultState
|
||||
}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedOption ? (
|
||||
<StateGroupIcon
|
||||
stateGroup={selectedOption.group}
|
||||
color={selectedOption.color}
|
||||
height="16px"
|
||||
width="16px"
|
||||
/>
|
||||
) : currentDefaultState ? (
|
||||
<StateGroupIcon
|
||||
stateGroup={currentDefaultState.group}
|
||||
color={currentDefaultState.color}
|
||||
height="16px"
|
||||
width="16px"
|
||||
/>
|
||||
) : (
|
||||
<Squares2X2Icon className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
)}
|
||||
{selectedOption?.name
|
||||
? selectedOption.name
|
||||
: currentDefaultState?.name ?? (
|
||||
<span className="text-custom-text-200">State</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
onChange={(val: string) => {
|
||||
handleChange({ default_state: val });
|
||||
}}
|
||||
options={options}
|
||||
disabled={!multipleOptions}
|
||||
width="w-full"
|
||||
input
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
|
||||
<div className="w-1/2 text-sm font-medium">Auto-close Status</div>
|
||||
<div className="w-1/2 ">
|
||||
<CustomSearchSelect
|
||||
value={
|
||||
projectDetails?.default_state ? projectDetails?.default_state : defaultState
|
||||
}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedOption ? (
|
||||
<StateGroupIcon
|
||||
stateGroup={selectedOption.group}
|
||||
color={selectedOption.color}
|
||||
height="16px"
|
||||
width="16px"
|
||||
/>
|
||||
) : currentDefaultState ? (
|
||||
<StateGroupIcon
|
||||
stateGroup={currentDefaultState.group}
|
||||
color={currentDefaultState.color}
|
||||
height="16px"
|
||||
width="16px"
|
||||
/>
|
||||
) : (
|
||||
<Squares2X2Icon className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
)}
|
||||
{selectedOption?.name
|
||||
? selectedOption.name
|
||||
: currentDefaultState?.name ?? (
|
||||
<span className="text-custom-text-200">State</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
onChange={(val: string) => {
|
||||
handleChange({ default_state: val });
|
||||
}}
|
||||
options={options}
|
||||
disabled={!multipleOptions}
|
||||
width="w-full"
|
||||
input
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +104,7 @@ export const SelectMonthModal: React.FC<Props> = ({
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-custom-text-100"
|
||||
>
|
||||
Customize Time Range
|
||||
Customise Time Range
|
||||
</Dialog.Title>
|
||||
<div className="mt-8 flex items-center gap-2">
|
||||
<div className="flex w-full flex-col gap-1 justify-center">
|
||||
|
||||
@@ -41,7 +41,7 @@ export const CommandPalette: React.FC = observer(() => {
|
||||
const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId, inboxId } = router.query;
|
||||
const { workspaceSlug, projectId, issueId, inboxId, cycleId, moduleId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
@@ -183,6 +183,13 @@ export const CommandPalette: React.FC = observer(() => {
|
||||
isOpen={isIssueModalOpen}
|
||||
handleClose={() => setIsIssueModalOpen(false)}
|
||||
fieldsToShow={inboxId ? ["name", "description", "priority"] : ["all"]}
|
||||
prePopulateData={
|
||||
cycleId
|
||||
? { cycle: cycleId.toString() }
|
||||
: moduleId
|
||||
? { module: moduleId.toString() }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<BulkDeleteIssuesModal
|
||||
isOpen={isBulkDeleteIssuesModalOpen}
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// hook
|
||||
import useEstimateOption from "hooks/use-estimate-option";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// icons
|
||||
import { Icon, Tooltip } from "components/ui";
|
||||
import { Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
import { BlockedIcon, BlockerIcon } from "components/icons";
|
||||
import {
|
||||
TagIcon,
|
||||
CopyPlus,
|
||||
Calendar,
|
||||
Link2Icon,
|
||||
RocketIcon,
|
||||
Users2Icon,
|
||||
ArchiveIcon,
|
||||
PaperclipIcon,
|
||||
ContrastIcon,
|
||||
TriangleIcon,
|
||||
LayoutGridIcon,
|
||||
SignalMediumIcon,
|
||||
MessageSquareIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
BlockedIcon,
|
||||
BlockerIcon,
|
||||
RelatedIcon,
|
||||
StackedLayersHorizontalIcon,
|
||||
} from "components/icons";
|
||||
// helpers
|
||||
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssueActivity } from "types";
|
||||
// fetch-keys
|
||||
import { WORKSPACE_LABELS } from "constants/fetch-keys";
|
||||
|
||||
const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
const router = useRouter();
|
||||
@@ -29,7 +56,7 @@ const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
{activity.issue_detail
|
||||
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
|
||||
: "Issue"}
|
||||
<Icon iconName="launch" className="!text-xs" />
|
||||
<RocketIcon size={12} color="#6b7280" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -51,6 +78,38 @@ const UserLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const LabelPill = ({ labelId }: { labelId: string }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { data: labels } = useSWR(
|
||||
workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null,
|
||||
workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: labels?.find((l) => l.id === labelId)?.color ?? "#000000",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
const EstimatePoint = ({ point }: { point: string }) => {
|
||||
const { estimateValue, isEstimateActive } = useEstimateOption(Number(point));
|
||||
const currentPoint = Number(point) + 1;
|
||||
|
||||
return (
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{isEstimateActive
|
||||
? estimateValue
|
||||
: `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const activityDetails: {
|
||||
[key: string]: {
|
||||
message: (
|
||||
@@ -90,14 +149,14 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="group" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Users2Icon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
archived_at: {
|
||||
message: (activity) => {
|
||||
if (activity.new_value === "restore") return "restored the issue.";
|
||||
else return "archived the issue.";
|
||||
},
|
||||
icon: <Icon iconName="archive" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <ArchiveIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
attachment: {
|
||||
message: (activity, showIssue) => {
|
||||
@@ -112,7 +171,7 @@ const activityDetails: {
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
attachment
|
||||
<Icon iconName="launch" className="!text-xs" />
|
||||
<RocketIcon size={12} color="#6b7280" />
|
||||
</a>
|
||||
{showIssue && (
|
||||
<>
|
||||
@@ -136,7 +195,7 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="attach_file" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <PaperclipIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
blocking: {
|
||||
message: (activity) => {
|
||||
@@ -157,7 +216,7 @@ const activityDetails: {
|
||||
},
|
||||
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
|
||||
},
|
||||
blocks: {
|
||||
blocked_by: {
|
||||
message: (activity) => {
|
||||
if (activity.old_value === "")
|
||||
return (
|
||||
@@ -176,6 +235,44 @@ const activityDetails: {
|
||||
},
|
||||
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
|
||||
},
|
||||
duplicate: {
|
||||
message: (activity) => {
|
||||
if (activity.old_value === "")
|
||||
return (
|
||||
<>
|
||||
marked this issue as duplicate of{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed this issue as a duplicate of{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <CopyPlus size={12} color="#6b7280" />,
|
||||
},
|
||||
relates_to: {
|
||||
message: (activity) => {
|
||||
if (activity.old_value === "")
|
||||
return (
|
||||
<>
|
||||
marked that this issue relates to{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed the relation from{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <RelatedIcon height="12" width="12" color="#6b7280" />,
|
||||
},
|
||||
cycles: {
|
||||
message: (activity, showIssue, workspaceSlug) => {
|
||||
if (activity.verb === "created")
|
||||
@@ -189,7 +286,7 @@ const activityDetails: {
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{activity.new_value}
|
||||
<Icon iconName="launch" className="!text-xs" />
|
||||
<RocketIcon size={12} color="#6b7280" />
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
@@ -204,7 +301,7 @@ const activityDetails: {
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{activity.new_value}
|
||||
<Icon iconName="launch" className="!text-xs" />
|
||||
<RocketIcon size={12} color="#6b7280" />
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
@@ -219,12 +316,12 @@ const activityDetails: {
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{activity.old_value}
|
||||
<Icon iconName="launch" className="!text-xs" />
|
||||
<RocketIcon size={12} color="#6b7280" />
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="contrast" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <ContrastIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
description: {
|
||||
message: (activity, showIssue) => (
|
||||
@@ -239,7 +336,7 @@ const activityDetails: {
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <MessageSquareIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
estimate_point: {
|
||||
message: (activity, showIssue) => {
|
||||
@@ -259,8 +356,7 @@ const activityDetails: {
|
||||
else
|
||||
return (
|
||||
<>
|
||||
set the estimate point to{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
set the estimate point to <EstimatePoint point={activity.new_value} />
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
@@ -271,14 +367,14 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="change_history" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <TriangleIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
issue: {
|
||||
message: (activity) => {
|
||||
if (activity.verb === "created") return "created the issue.";
|
||||
else return "deleted an issue.";
|
||||
},
|
||||
icon: <Icon iconName="stack" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <StackedLayersHorizontalIcon width={12} height={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
labels: {
|
||||
message: (activity, showIssue) => {
|
||||
@@ -286,14 +382,8 @@ const activityDetails: {
|
||||
return (
|
||||
<>
|
||||
added a new label{" "}
|
||||
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: "#000000",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<LabelPill labelId={activity.new_identifier ?? ""} />
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
</span>
|
||||
{showIssue && (
|
||||
@@ -309,13 +399,7 @@ const activityDetails: {
|
||||
<>
|
||||
removed the label{" "}
|
||||
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: "#000000",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<LabelPill labelId={activity.old_identifier ?? ""} />
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
|
||||
</span>
|
||||
{showIssue && (
|
||||
@@ -327,7 +411,7 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="sell" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <TagIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
link: {
|
||||
message: (activity, showIssue) => {
|
||||
@@ -342,7 +426,7 @@ const activityDetails: {
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
link
|
||||
<Icon iconName="launch" className="!text-xs" />
|
||||
<RocketIcon size={12} color="#6b7280" />
|
||||
</a>
|
||||
{showIssue && (
|
||||
<>
|
||||
@@ -364,7 +448,7 @@ const activityDetails: {
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
link
|
||||
<Icon iconName="launch" className="!text-xs" />
|
||||
<RocketIcon size={12} color="#6b7280" />
|
||||
</a>
|
||||
{showIssue && (
|
||||
<>
|
||||
@@ -386,7 +470,7 @@ const activityDetails: {
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
link
|
||||
<Icon iconName="launch" className="!text-xs" />
|
||||
<RocketIcon size={12} color="#6b7280" />
|
||||
</a>
|
||||
{showIssue && (
|
||||
<>
|
||||
@@ -398,7 +482,7 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="link" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Link2Icon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
modules: {
|
||||
message: (activity, showIssue, workspaceSlug) => {
|
||||
@@ -413,7 +497,7 @@ const activityDetails: {
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{activity.new_value}
|
||||
<Icon iconName="launch" className="!text-xs" />
|
||||
<RocketIcon size={12} color="#6b7280" />
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
@@ -428,7 +512,7 @@ const activityDetails: {
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{activity.new_value}
|
||||
<Icon iconName="launch" className="!text-xs" />
|
||||
<RocketIcon size={12} color="#6b7280" />
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
@@ -443,12 +527,12 @@ const activityDetails: {
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{activity.old_value}
|
||||
<Icon iconName="launch" className="!text-xs" />
|
||||
<RocketIcon size={12} color="#6b7280" />
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="dataset" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="dataset" className="!text-xs !text-[#6b7280]" aria-hidden="true" />,
|
||||
},
|
||||
name: {
|
||||
message: (activity, showIssue) => (
|
||||
@@ -463,7 +547,7 @@ const activityDetails: {
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <MessageSquareIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
parent: {
|
||||
message: (activity, showIssue) => {
|
||||
@@ -496,7 +580,13 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="supervised_user_circle" className="!text-sm" aria-hidden="true" />,
|
||||
icon: (
|
||||
<Icon
|
||||
iconName="supervised_user_circle"
|
||||
className="!text-xs !text-[#6b7280]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
),
|
||||
},
|
||||
priority: {
|
||||
message: (activity, showIssue) => (
|
||||
@@ -514,7 +604,7 @@ const activityDetails: {
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <Icon iconName="signal_cellular_alt" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <SignalMediumIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
start_date: {
|
||||
message: (activity, showIssue) => {
|
||||
@@ -548,7 +638,7 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
state: {
|
||||
message: (activity, showIssue) => (
|
||||
@@ -564,7 +654,7 @@ const activityDetails: {
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <Squares2X2Icon className="h-3 w-3" aria-hidden="true" />,
|
||||
icon: <LayoutGridIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
target_date: {
|
||||
message: (activity, showIssue) => {
|
||||
@@ -598,7 +688,7 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -167,7 +167,9 @@ export const FiltersList: React.FC<Props> = ({
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
assignees: filters.assignees?.filter((p: any) => p !== memberId),
|
||||
subscriber: filters.subscriber?.filter(
|
||||
(p: any) => p !== memberId
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./date-filter-modal";
|
||||
export * from "./date-filter-select";
|
||||
export * from "./filters-list";
|
||||
export * from "./workspace-filters-list";
|
||||
export * from "./issues-view-filter";
|
||||
|
||||
@@ -52,10 +52,22 @@ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const issueViewForDraftIssues: { type: TIssueViewOptions; Icon: any }[] = [
|
||||
{
|
||||
type: "list",
|
||||
Icon: FormatListBulletedOutlined,
|
||||
},
|
||||
{
|
||||
type: "kanban",
|
||||
Icon: GridViewOutlined,
|
||||
},
|
||||
];
|
||||
|
||||
export const IssuesFilterView: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, viewId } = router.query;
|
||||
const isArchivedIssues = router.pathname.includes("archived-issues");
|
||||
const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues";
|
||||
|
||||
const {
|
||||
displayFilters,
|
||||
@@ -75,9 +87,41 @@ export const IssuesFilterView: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{!isArchivedIssues && (
|
||||
{!isArchivedIssues && !isDraftIssues && (
|
||||
<div className="flex items-center gap-x-1">
|
||||
{issueViewOptions.map((option) => (
|
||||
<Tooltip
|
||||
key={option.type}
|
||||
tooltipContent={
|
||||
<span className="capitalize">
|
||||
{replaceUnderscoreIfSnakeCase(option.type)} Layout
|
||||
</span>
|
||||
}
|
||||
position="bottom"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${
|
||||
displayFilters.layout === option.type
|
||||
? "bg-custom-sidebar-background-80"
|
||||
: "text-custom-sidebar-text-200"
|
||||
}`}
|
||||
onClick={() => setDisplayFilters({ layout: option.type })}
|
||||
>
|
||||
<option.Icon
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
}}
|
||||
className={option.type === "gantt_chart" ? "rotate-90" : ""}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isDraftIssues && (
|
||||
<div className="flex items-center gap-x-1">
|
||||
{issueViewForDraftIssues.map((option) => (
|
||||
<Tooltip
|
||||
key={option.type}
|
||||
tooltipContent={
|
||||
@@ -186,6 +230,9 @@ export const IssuesFilterView: React.FC = () => {
|
||||
return null;
|
||||
if (option.key === "project") return null;
|
||||
|
||||
if (isDraftIssues && option.key === "state_detail.group")
|
||||
return null;
|
||||
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
@@ -230,33 +277,35 @@ export const IssuesFilterView: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Issue type</h4>
|
||||
<div className="w-28">
|
||||
<CustomMenu
|
||||
label={
|
||||
FILTER_ISSUE_OPTIONS.find(
|
||||
(option) => option.key === displayFilters.type
|
||||
)?.name ?? "Select"
|
||||
}
|
||||
className="!w-full"
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
{FILTER_ISSUE_OPTIONS.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() =>
|
||||
setDisplayFilters({
|
||||
type: option.key,
|
||||
})
|
||||
}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
{!isArchivedIssues && (
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Issue type</h4>
|
||||
<div className="w-28">
|
||||
<CustomMenu
|
||||
label={
|
||||
FILTER_ISSUE_OPTIONS.find(
|
||||
(option) => option.key === displayFilters.type
|
||||
)?.name ?? "Select"
|
||||
}
|
||||
className="!w-full"
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
{FILTER_ISSUE_OPTIONS.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() =>
|
||||
setDisplayFilters({
|
||||
type: option.key,
|
||||
})
|
||||
}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayFilters.layout !== "calendar" &&
|
||||
displayFilters.layout !== "spreadsheet" && (
|
||||
@@ -276,7 +325,7 @@ export const IssuesFilterView: React.FC = () => {
|
||||
displayFilters.layout !== "spreadsheet" &&
|
||||
displayFilters.layout !== "gantt_chart" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Show empty states</h4>
|
||||
<h4 className="text-custom-text-200">Show empty groups</h4>
|
||||
<div className="w-28">
|
||||
<ToggleSwitch
|
||||
value={displayFilters.show_empty_groups ?? true}
|
||||
|
||||
364
web/components/core/filters/workspace-filters-list.tsx
Normal file
364
web/components/core/filters/workspace-filters-list.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import React from "react";
|
||||
|
||||
// icons
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { PriorityIcon, StateGroupIcon } from "components/icons";
|
||||
// ui
|
||||
import { Avatar } from "components/ui";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// helpers
|
||||
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||
// types
|
||||
import {
|
||||
IIssueLabels,
|
||||
IProject,
|
||||
IUserLite,
|
||||
IWorkspaceIssueFilterOptions,
|
||||
TStateGroups,
|
||||
} from "types";
|
||||
// constants
|
||||
import { STATE_GROUP_COLORS } from "constants/state";
|
||||
|
||||
type Props = {
|
||||
filters: Partial<IWorkspaceIssueFilterOptions>;
|
||||
setFilters: (updatedFilter: Partial<IWorkspaceIssueFilterOptions>) => void;
|
||||
clearAllFilters: (...args: any) => void;
|
||||
labels: IIssueLabels[] | undefined;
|
||||
members: IUserLite[] | undefined;
|
||||
stateGroup: string[] | undefined;
|
||||
project?: IProject[] | undefined;
|
||||
};
|
||||
|
||||
export const WorkspaceFiltersList: React.FC<Props> = ({
|
||||
filters,
|
||||
setFilters,
|
||||
clearAllFilters,
|
||||
labels,
|
||||
members,
|
||||
stateGroup,
|
||||
project,
|
||||
}) => {
|
||||
if (!filters) return <></>;
|
||||
|
||||
const nullFilters = Object.keys(filters).filter(
|
||||
(key) => filters[key as keyof IWorkspaceIssueFilterOptions] === null
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2 text-xs">
|
||||
{Object.keys(filters).map((filterKey) => {
|
||||
const key = filterKey as keyof typeof filters;
|
||||
|
||||
if (filters[key] === null || (filters[key]?.length ?? 0) <= 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-x-2 rounded-full border border-custom-border-200 bg-custom-background-80 px-2 py-1"
|
||||
>
|
||||
<span className="capitalize text-custom-text-200">
|
||||
{key === "target_date" ? "Due Date" : replaceUnderscoreIfSnakeCase(key)}:
|
||||
</span>
|
||||
{filters[key] === null || (filters[key]?.length ?? 0) <= 0 ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
|
||||
) : Array.isArray(filters[key]) ? (
|
||||
<div className="space-x-2">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{key === "state_group"
|
||||
? filters.state_group?.map((stateGroup) => {
|
||||
const group = stateGroup as TStateGroups;
|
||||
|
||||
return (
|
||||
<p
|
||||
key={group}
|
||||
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
|
||||
style={{
|
||||
color: STATE_GROUP_COLORS[group],
|
||||
backgroundColor: `${STATE_GROUP_COLORS[group]}20`,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<StateGroupIcon stateGroup={group} color={undefined} />
|
||||
</span>
|
||||
<span>{group}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
state_group: filters.state_group?.filter((g) => g !== group),
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
})
|
||||
: key === "priority"
|
||||
? filters.priority?.map((priority: any) => (
|
||||
<p
|
||||
key={priority}
|
||||
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize ${
|
||||
priority === "urgent"
|
||||
? "bg-red-500/20 text-red-500"
|
||||
: priority === "high"
|
||||
? "bg-orange-500/20 text-orange-500"
|
||||
: priority === "medium"
|
||||
? "bg-yellow-500/20 text-yellow-500"
|
||||
: priority === "low"
|
||||
? "bg-green-500/20 text-green-500"
|
||||
: "bg-custom-background-90 text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
<PriorityIcon priority={priority} />
|
||||
</span>
|
||||
<span>{priority === "null" ? "None" : priority}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
priority: filters.priority?.filter((p: any) => p !== priority),
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</p>
|
||||
))
|
||||
: key === "assignees"
|
||||
? filters.assignees?.map((memberId: string) => {
|
||||
const member = members?.find((m) => m.id === memberId);
|
||||
return (
|
||||
<div
|
||||
key={memberId}
|
||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
|
||||
>
|
||||
<Avatar user={member} />
|
||||
<span>{member?.display_name}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
assignees: filters.assignees?.filter((p: any) => p !== memberId),
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: key === "subscriber"
|
||||
? filters.subscriber?.map((memberId: string) => {
|
||||
const member = members?.find((m) => m.id === memberId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={memberId}
|
||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
|
||||
>
|
||||
<Avatar user={member} />
|
||||
<span>{member?.display_name}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
assignees: filters.assignees?.filter((p: any) => p !== memberId),
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: key === "created_by"
|
||||
? filters.created_by?.map((memberId: string) => {
|
||||
const member = members?.find((m) => m.id === memberId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${memberId}-${key}`}
|
||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
|
||||
>
|
||||
<Avatar user={member} />
|
||||
<span>{member?.display_name}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
created_by: filters.created_by?.filter(
|
||||
(p: any) => p !== memberId
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: key === "labels"
|
||||
? filters.labels?.map((labelId: string) => {
|
||||
const label = labels?.find((l) => l.id === labelId);
|
||||
|
||||
if (!label) return null;
|
||||
const color = label.color !== "" ? label.color : "#0f172a";
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5"
|
||||
style={{
|
||||
color: color,
|
||||
backgroundColor: `${color}20`, // add 20% opacity
|
||||
}}
|
||||
key={labelId}
|
||||
>
|
||||
<div
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
<span>{label.name}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
labels: filters.labels?.filter((l: any) => l !== labelId),
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon
|
||||
className="h-3 w-3"
|
||||
style={{
|
||||
color: color,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: key === "start_date"
|
||||
? filters.start_date?.map((date: string) => {
|
||||
if (filters.start_date && filters.start_date.length <= 0) return null;
|
||||
|
||||
const splitDate = date.split(";");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={date}
|
||||
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
|
||||
>
|
||||
<div className="h-1.5 w-1.5 rounded-full" />
|
||||
<span className="capitalize">
|
||||
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
|
||||
</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
start_date: filters.start_date?.filter((d: any) => d !== date),
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: key === "target_date"
|
||||
? filters.target_date?.map((date: string) => {
|
||||
if (filters.target_date && filters.target_date.length <= 0) return null;
|
||||
|
||||
const splitDate = date.split(";");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={date}
|
||||
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
|
||||
>
|
||||
<div className="h-1.5 w-1.5 rounded-full" />
|
||||
<span className="capitalize">
|
||||
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
|
||||
</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
target_date: filters.target_date?.filter((d: any) => d !== date),
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: key === "project"
|
||||
? filters.project?.map((projectId) => {
|
||||
const currentProject = project?.find((p) => p.id === projectId);
|
||||
return (
|
||||
<p
|
||||
key={currentProject?.id}
|
||||
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
|
||||
>
|
||||
<span>{currentProject?.name}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
project: filters.project?.filter((p) => p !== projectId),
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
})
|
||||
: (filters[key] as any)?.join(", ")}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
[key]: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-x-1 capitalize">
|
||||
{filters[key as keyof typeof filters]}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
[key]: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAllFilters}
|
||||
className="flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-80 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<span>Clear all filters</span>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +1,23 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
|
||||
// next
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-dropdown
|
||||
import { useDropzone } from "react-dropzone";
|
||||
|
||||
// headless ui
|
||||
import { Tab, Transition, Popover } from "@headlessui/react";
|
||||
|
||||
// services
|
||||
import fileService from "services/file.service";
|
||||
|
||||
// components
|
||||
import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// hooks
|
||||
import useWorkspaceDetails from "hooks/use-workspace-details";
|
||||
|
||||
const unsplashEnabled =
|
||||
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
|
||||
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "1";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// components
|
||||
import { Input, PrimaryButton, SecondaryButton, Loader } from "components/ui";
|
||||
|
||||
const tabOptions = [
|
||||
{
|
||||
key: "unsplash",
|
||||
title: "Unsplash",
|
||||
},
|
||||
{
|
||||
key: "images",
|
||||
title: "Images",
|
||||
@@ -63,10 +55,26 @@ export const ImagePickerPopover: React.FC<Props> = ({
|
||||
search: "",
|
||||
});
|
||||
|
||||
const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchParams}`, () =>
|
||||
fileService.getUnsplashImages(1, searchParams)
|
||||
const { data: unsplashImages, error: unsplashError } = useSWR(
|
||||
`UNSPLASH_IMAGES_${searchParams}`,
|
||||
() => fileService.getUnsplashImages(searchParams),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: projectCoverImages } = useSWR(
|
||||
`PROJECT_COVER_IMAGES`,
|
||||
() => fileService.getProjectCoverImages(),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
|
||||
const imagePickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { workspaceDetails } = useWorkspaceDetails();
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
@@ -112,16 +120,17 @@ export const ImagePickerPopover: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!images || value !== null) return;
|
||||
onChange(images[0].urls.regular);
|
||||
}, [value, onChange, images]);
|
||||
if (!unsplashImages || value !== null) return;
|
||||
|
||||
if (!unsplashEnabled) return null;
|
||||
onChange(unsplashImages[0].urls.regular);
|
||||
}, [value, onChange, unsplashImages]);
|
||||
|
||||
useOutsideClickDetector(imagePickerRef, () => setIsOpen(false));
|
||||
|
||||
return (
|
||||
<Popover className="relative z-[2]" ref={ref}>
|
||||
<Popover.Button
|
||||
className="rounded-md border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
|
||||
className="rounded border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
disabled={disabled}
|
||||
>
|
||||
@@ -136,12 +145,19 @@ export const ImagePickerPopover: React.FC<Props> = ({
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-80 shadow-lg">
|
||||
<div className="h-96 md:h-[28rem] w-80 md:w-[36rem] flex flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl">
|
||||
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-sm">
|
||||
<div
|
||||
ref={imagePickerRef}
|
||||
className="h-96 md:h-[28rem] w-80 md:w-[36rem] flex flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl"
|
||||
>
|
||||
<Tab.Group>
|
||||
<div>
|
||||
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
|
||||
{tabOptions.map((tab) => (
|
||||
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
|
||||
{tabOptions.map((tab) => {
|
||||
if (!unsplashImages && unsplashError && tab.key === "unsplash") return null;
|
||||
if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images")
|
||||
return null;
|
||||
|
||||
return (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
className={({ selected }) =>
|
||||
@@ -152,50 +168,106 @@ export const ImagePickerPopover: React.FC<Props> = ({
|
||||
>
|
||||
{tab.title}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<Tab.Panel className="h-full w-full space-y-4">
|
||||
<div className="flex gap-x-2 pt-7">
|
||||
<Input
|
||||
name="search"
|
||||
className="text-sm"
|
||||
id="search"
|
||||
value={formData.search}
|
||||
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
|
||||
placeholder="Search for images"
|
||||
/>
|
||||
<PrimaryButton onClick={() => setSearchParams(formData.search)} size="sm">
|
||||
Search
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
{images ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative col-span-2 aspect-video md:col-span-1"
|
||||
>
|
||||
<img
|
||||
src={image.urls.small}
|
||||
alt={image.alt_description}
|
||||
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onChange(image.urls.regular);
|
||||
}}
|
||||
/>
|
||||
{(unsplashImages || !unsplashError) && (
|
||||
<Tab.Panel className="h-full w-full space-y-4 mt-4">
|
||||
<div className="flex gap-x-2">
|
||||
<Input
|
||||
name="search"
|
||||
className="text-sm"
|
||||
id="search"
|
||||
value={formData.search}
|
||||
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
|
||||
placeholder="Search for images"
|
||||
/>
|
||||
<PrimaryButton onClick={() => setSearchParams(formData.search)} size="sm">
|
||||
Search
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
{unsplashImages ? (
|
||||
unsplashImages.length > 0 ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{unsplashImages.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative col-span-2 aspect-video md:col-span-1"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onChange(image.urls.regular);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image.urls.small}
|
||||
alt={image.alt_description}
|
||||
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center pt-20">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="h-full w-full pt-5">
|
||||
) : (
|
||||
<p className="text-center text-custom-text-300 text-xs pt-7">
|
||||
No images found.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="grid grid-cols-4 gap-4">
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
</Loader>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
)}
|
||||
{(!projectCoverImages || projectCoverImages.length !== 0) && (
|
||||
<Tab.Panel className="h-full w-full space-y-4 mt-4">
|
||||
{projectCoverImages ? (
|
||||
projectCoverImages.length > 0 ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{projectCoverImages.map((image, index) => (
|
||||
<div
|
||||
key={image}
|
||||
className="relative col-span-2 aspect-video md:col-span-1"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onChange(image);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={`Default project cover image- ${index}`}
|
||||
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-custom-text-300 text-xs pt-7">
|
||||
No images found.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="grid grid-cols-4 gap-4 pt-4">
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
</Loader>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
)}
|
||||
<Tab.Panel className="h-full w-full mt-4">
|
||||
<div className="w-full h-full flex flex-col gap-y-2">
|
||||
<div className="flex items-center gap-3 w-full flex-1">
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-dropzone
|
||||
@@ -12,7 +11,7 @@ import fileServices from "services/file.service";
|
||||
// hooks
|
||||
import useWorkspaceDetails from "hooks/use-workspace-details";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
import { DangerButton, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { UserCircleIcon } from "components/icons";
|
||||
|
||||
@@ -21,6 +20,8 @@ type Props = {
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
onSuccess: (url: string) => void;
|
||||
isRemoving: boolean;
|
||||
handleDelete: () => void;
|
||||
userImage?: boolean;
|
||||
};
|
||||
|
||||
@@ -29,6 +30,8 @@ export const ImageUploadModal: React.FC<Props> = ({
|
||||
onSuccess,
|
||||
isOpen,
|
||||
onClose,
|
||||
isRemoving,
|
||||
handleDelete,
|
||||
userImage,
|
||||
}) => {
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
@@ -148,12 +151,10 @@ export const ImageUploadModal: React.FC<Props> = ({
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<NextImage
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
<img
|
||||
src={image ? URL.createObjectURL(image) : value ? value : ""}
|
||||
alt="image"
|
||||
className="rounded-lg"
|
||||
className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -182,15 +183,22 @@ export const ImageUploadModal: React.FC<Props> = ({
|
||||
<p className="my-4 text-custom-text-200 text-sm">
|
||||
File formats supported- .jpeg, .jpg, .png, .webp, .svg
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleSubmit}
|
||||
disabled={!image}
|
||||
loading={isImageUploading}
|
||||
>
|
||||
{isImageUploading ? "Uploading..." : "Upload & Save"}
|
||||
</PrimaryButton>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<DangerButton onClick={handleDelete} outline disabled={!value}>
|
||||
{isRemoving ? "Removing..." : "Remove"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleSubmit}
|
||||
disabled={!image}
|
||||
loading={isImageUploading}
|
||||
>
|
||||
{isImageUploading ? "Uploading..." : "Upload & Save"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -12,6 +12,7 @@ import stateService from "services/state.service";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
||||
// components
|
||||
import {
|
||||
AllLists,
|
||||
@@ -50,6 +51,7 @@ type Props = {
|
||||
secondaryButton?: React.ReactNode;
|
||||
};
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
|
||||
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
|
||||
handleOnDragEnd: (result: DropResult) => Promise<void>;
|
||||
openIssuesListModal: (() => void) | null;
|
||||
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
|
||||
@@ -66,6 +68,7 @@ export const AllViews: React.FC<Props> = ({
|
||||
dragDisabled = false,
|
||||
emptyState,
|
||||
handleIssueAction,
|
||||
handleDraftIssueAction,
|
||||
handleOnDragEnd,
|
||||
openIssuesListModal,
|
||||
removeIssue,
|
||||
@@ -77,11 +80,15 @@ export const AllViews: React.FC<Props> = ({
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const [myIssueProjectId, setMyIssueProjectId] = useState<string | null>(null);
|
||||
|
||||
const { user } = useUser();
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
|
||||
const { groupedIssues, isEmpty, displayFilters } = viewProps;
|
||||
|
||||
const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView();
|
||||
|
||||
const { data: stateGroups } = useSWR(
|
||||
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
|
||||
workspaceSlug
|
||||
@@ -90,6 +97,10 @@ export const AllViews: React.FC<Props> = ({
|
||||
);
|
||||
const states = getStatesList(stateGroups);
|
||||
|
||||
const handleMyIssueOpen = (issue: IIssue) => {
|
||||
setMyIssueProjectId(issue.project);
|
||||
};
|
||||
|
||||
const handleTrashBox = useCallback(
|
||||
(isDragging: boolean) => {
|
||||
if (isDragging && !trashBox) setTrashBox(true);
|
||||
@@ -126,8 +137,11 @@ export const AllViews: React.FC<Props> = ({
|
||||
states={states}
|
||||
addIssueToGroup={addIssueToGroup}
|
||||
handleIssueAction={handleIssueAction}
|
||||
handleDraftIssueAction={handleDraftIssueAction}
|
||||
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
|
||||
removeIssue={removeIssue}
|
||||
myIssueProjectId={myIssueProjectId}
|
||||
handleMyIssueOpen={handleMyIssueOpen}
|
||||
disableUserActions={disableUserActions}
|
||||
disableAddIssueOption={disableAddIssueOption}
|
||||
user={user}
|
||||
@@ -141,8 +155,11 @@ export const AllViews: React.FC<Props> = ({
|
||||
disableAddIssueOption={disableAddIssueOption}
|
||||
dragDisabled={dragDisabled}
|
||||
handleIssueAction={handleIssueAction}
|
||||
handleDraftIssueAction={handleDraftIssueAction}
|
||||
handleTrashBox={handleTrashBox}
|
||||
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
|
||||
myIssueProjectId={myIssueProjectId}
|
||||
handleMyIssueOpen={handleMyIssueOpen}
|
||||
removeIssue={removeIssue}
|
||||
states={states}
|
||||
user={user}
|
||||
@@ -160,13 +177,17 @@ export const AllViews: React.FC<Props> = ({
|
||||
) : displayFilters?.layout === "spreadsheet" ? (
|
||||
<SpreadsheetView
|
||||
handleIssueAction={handleIssueAction}
|
||||
spreadsheetIssues={spreadsheetIssues}
|
||||
mutateIssues={mutateIssues}
|
||||
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
|
||||
disableUserActions={disableUserActions}
|
||||
user={user}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
) : (
|
||||
displayFilters?.layout === "gantt_chart" && <GanttChartView />
|
||||
displayFilters?.layout === "gantt_chart" && (
|
||||
<GanttChartView disableUserActions={disableUserActions} />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : router.pathname.includes("archived-issues") ? (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user