forked from github/plane
Compare commits
107 Commits
preview
...
dev/spread
Author | SHA1 | Date | |
---|---|---|---|
|
0c13d05e27 | ||
|
8aebf0bbd2 | ||
|
404e6a0cfc | ||
|
f2c3ad442d | ||
|
2a4e61f3b8 | ||
|
08d9f15fb9 | ||
|
fcfdd74d4f | ||
|
265e60a536 | ||
|
67b28214d0 | ||
|
d58d639190 | ||
|
faf0a641ff | ||
|
35a7d10b8f | ||
|
00b40fbde4 | ||
|
9f61d8bc06 | ||
|
991a6858ec | ||
|
4dec6761d0 | ||
|
57e2ed24ad | ||
|
f444309cae | ||
|
0ef2200465 | ||
|
e28919a964 | ||
|
13874f2357 | ||
|
71604bb5ac | ||
|
547a265169 | ||
|
0f47762e6d | ||
|
844a3e4b42 | ||
|
b5b809500d | ||
|
7be038ac5a | ||
|
41fd9ce6e8 | ||
|
b1448c947e | ||
|
9c2ea8a7ae | ||
|
a39aa80e76 | ||
|
569a6c3383 | ||
|
405a398c6b | ||
|
f22705846d | ||
|
727042468a | ||
|
9ad1e73666 | ||
|
479c145b02 | ||
|
b70047b1d5 | ||
|
f60dcdc599 | ||
|
2643de80af | ||
|
5af753f475 | ||
|
3bf590b67e | ||
|
b2d17e6ec9 | ||
|
ccf6bd4e32 | ||
|
151662a442 | ||
|
c342ab302e | ||
|
c48cd3ee6e | ||
|
7c0c0da0f8 | ||
|
1b8d58a9a6 | ||
|
43404bfcdf | ||
|
310a2ca904 | ||
|
2b419c02a5 | ||
|
9831418a11 | ||
|
9a8dcc349f | ||
|
27f78dd283 | ||
|
0ebe36bdb3 | ||
|
6a430ed480 | ||
|
daa3094911 | ||
|
9b41b5baf5 | ||
|
2dcaccd4ec | ||
|
f69d34698a | ||
|
6d52801ea7 | ||
|
e96bc77215 | ||
|
a328c530d0 | ||
|
908f6716fe | ||
|
50c330db65 | ||
|
491592614d | ||
|
906caa636b | ||
|
12ce6e78e2 | ||
|
a25e5accd1 | ||
|
12b6ec4b49 | ||
|
70fe830151 | ||
|
e9490314cc | ||
|
f6d4ac95ed | ||
|
cc9ebc58bc | ||
|
cf34d4afbc | ||
|
7b04adf03a | ||
|
9136258926 | ||
|
d88a0885d5 | ||
|
fce6907465 | ||
|
3c9e62d308 | ||
|
28ce96aaca | ||
|
66022ea478 | ||
|
60883baea7 | ||
|
c67f08fca4 | ||
|
f579712092 | ||
|
3ffbb6ac17 | ||
|
0ec0ad6aba | ||
|
698021ab8b | ||
|
ada1bc009b | ||
|
834e672245 | ||
|
3b85444e1f | ||
|
04242800c9 | ||
|
a8c5a4155b | ||
|
0445c610bf | ||
|
c0e3c81a9b | ||
|
8c04e770c0 | ||
|
aef71fbc45 | ||
|
b9a6a00470 | ||
|
7c5936e463 | ||
|
b86c30baed | ||
|
15ef2bc030 | ||
|
ef630ef663 | ||
|
731309a932 | ||
|
9d334cf3a3 | ||
|
e9b6f86882 | ||
|
8d86087fee |
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
|
@ -39,10 +39,10 @@ jobs:
|
|||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
|
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
|
||||||
id: metaDeploy
|
id: metaSpace
|
||||||
uses: docker/metadata-action@v4.3.0
|
uses: docker/metadata-action@v4.3.0
|
||||||
with:
|
with:
|
||||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-deploy
|
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
|
|
||||||
@ -87,7 +87,7 @@ jobs:
|
|||||||
file: ./space/Dockerfile.space
|
file: ./space/Dockerfile.space
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.metaDeploy.outputs.tags }}
|
tags: ${{ steps.metaSpace.outputs.tags }}
|
||||||
env:
|
env:
|
||||||
DOCKER_BUILDKIT: 1
|
DOCKER_BUILDKIT: 1
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -16,6 +16,7 @@ node_modules
|
|||||||
|
|
||||||
# Production
|
# Production
|
||||||
/build
|
/build
|
||||||
|
dist
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
@ -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:
|
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
|
- 3rd-party libraries being used and their versions
|
||||||
- a use-case that fails
|
- 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.
|
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
|
### Requirements
|
||||||
|
|
||||||
- Node.js version v16.18.0
|
- Node.js version v16.18.0
|
||||||
- Python version 3.8+
|
- Python version 3.8+
|
||||||
- Postgres version v14
|
- Postgres version v14
|
||||||
- Redis version v6.2.7
|
- Redis version v6.2.7
|
||||||
|
|
||||||
### Setup the project
|
### 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
|
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?
|
## 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.
|
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:
|
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).
|
- 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.
|
- 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
|
## 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
|
## Ways to contribute
|
||||||
|
|
||||||
- Try Plane Cloud and the self hosting platform and give feedback
|
- Try Plane Cloud and the self hosting platform and give feedback
|
||||||
- Add new integrations
|
- 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)
|
- 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
|
- Share your thoughts and suggestions with us
|
||||||
- Help create tutorials and blog posts
|
- Help create tutorials and blog posts
|
||||||
- Request a feature by submitting a proposal
|
- Request a feature by submitting a proposal
|
||||||
- Report a bug
|
- Report a bug
|
||||||
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
|
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
|
||||||
|
@ -54,7 +54,7 @@ chmod +x setup.sh
|
|||||||
- Run setup.sh
|
- Run setup.sh
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./setup.sh http://localhost
|
./setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
> If running in a cloud env replace localhost with public facing IP address of the VM
|
> If running in a cloud env replace localhost with public facing IP address of the VM
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# Backend
|
# Backend
|
||||||
# Debug value for api server use it as 0 for production use
|
# Debug value for api server use it as 0 for production use
|
||||||
DEBUG=0
|
DEBUG=0
|
||||||
DJANGO_SETTINGS_MODULE="plane.settings.selfhosted"
|
DJANGO_SETTINGS_MODULE="plane.settings.production"
|
||||||
|
|
||||||
# Error logs
|
# Error logs
|
||||||
SENTRY_DSN=""
|
SENTRY_DSN=""
|
||||||
@ -59,3 +59,14 @@ DEFAULT_PASSWORD="password123"
|
|||||||
|
|
||||||
# SignUps
|
# SignUps
|
||||||
ENABLE_SIGNUP="1"
|
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:
|
if request.user.is_anonymous:
|
||||||
return False
|
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(
|
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()
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@ -34,7 +34,6 @@ class CycleSerializer(BaseSerializer):
|
|||||||
unstarted_issues = serializers.IntegerField(read_only=True)
|
unstarted_issues = serializers.IntegerField(read_only=True)
|
||||||
backlog_issues = serializers.IntegerField(read_only=True)
|
backlog_issues = serializers.IntegerField(read_only=True)
|
||||||
assignees = serializers.SerializerMethodField(read_only=True)
|
assignees = serializers.SerializerMethodField(read_only=True)
|
||||||
labels = serializers.SerializerMethodField(read_only=True)
|
|
||||||
total_estimates = serializers.IntegerField(read_only=True)
|
total_estimates = serializers.IntegerField(read_only=True)
|
||||||
completed_estimates = serializers.IntegerField(read_only=True)
|
completed_estimates = serializers.IntegerField(read_only=True)
|
||||||
started_estimates = serializers.IntegerField(read_only=True)
|
started_estimates = serializers.IntegerField(read_only=True)
|
||||||
@ -50,11 +49,10 @@ class CycleSerializer(BaseSerializer):
|
|||||||
members = [
|
members = [
|
||||||
{
|
{
|
||||||
"avatar": assignee.avatar,
|
"avatar": assignee.avatar,
|
||||||
"first_name": assignee.first_name,
|
|
||||||
"display_name": assignee.display_name,
|
"display_name": assignee.display_name,
|
||||||
"id": assignee.id,
|
"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()
|
for assignee in issue_cycle.issue.assignees.all()
|
||||||
]
|
]
|
||||||
# Use a set comprehension to return only the unique objects
|
# Use a set comprehension to return only the unique objects
|
||||||
@ -65,24 +63,6 @@ class CycleSerializer(BaseSerializer):
|
|||||||
|
|
||||||
return unique_list
|
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:
|
class Meta:
|
||||||
model = Cycle
|
model = Cycle
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
@ -70,6 +70,7 @@ from plane.api.views import (
|
|||||||
ProjectIdentifierEndpoint,
|
ProjectIdentifierEndpoint,
|
||||||
ProjectFavoritesViewSet,
|
ProjectFavoritesViewSet,
|
||||||
LeaveProjectEndpoint,
|
LeaveProjectEndpoint,
|
||||||
|
ProjectPublicCoverImagesEndpoint,
|
||||||
## End Projects
|
## End Projects
|
||||||
# Issues
|
# Issues
|
||||||
IssueViewSet,
|
IssueViewSet,
|
||||||
@ -150,12 +151,11 @@ from plane.api.views import (
|
|||||||
GlobalSearchEndpoint,
|
GlobalSearchEndpoint,
|
||||||
IssueSearchEndpoint,
|
IssueSearchEndpoint,
|
||||||
## End Search
|
## End Search
|
||||||
# Gpt
|
# External
|
||||||
GPTIntegrationEndpoint,
|
GPTIntegrationEndpoint,
|
||||||
## End Gpt
|
|
||||||
# Release Notes
|
|
||||||
ReleaseNotesEndpoint,
|
ReleaseNotesEndpoint,
|
||||||
## End Release Notes
|
UnsplashEndpoint,
|
||||||
|
## End External
|
||||||
# Inbox
|
# Inbox
|
||||||
InboxViewSet,
|
InboxViewSet,
|
||||||
InboxIssueViewSet,
|
InboxIssueViewSet,
|
||||||
@ -186,6 +186,9 @@ from plane.api.views import (
|
|||||||
## Exporter
|
## Exporter
|
||||||
ExportIssuesEndpoint,
|
ExportIssuesEndpoint,
|
||||||
## End Exporter
|
## End Exporter
|
||||||
|
# Configuration
|
||||||
|
ConfigurationEndpoint,
|
||||||
|
## End Configuration
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -573,6 +576,11 @@ urlpatterns = [
|
|||||||
LeaveProjectEndpoint.as_view(),
|
LeaveProjectEndpoint.as_view(),
|
||||||
name="project",
|
name="project",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"project-covers/",
|
||||||
|
ProjectPublicCoverImagesEndpoint.as_view(),
|
||||||
|
name="project-covers",
|
||||||
|
),
|
||||||
# End Projects
|
# End Projects
|
||||||
# States
|
# States
|
||||||
path(
|
path(
|
||||||
@ -1446,20 +1454,23 @@ urlpatterns = [
|
|||||||
name="project-issue-search",
|
name="project-issue-search",
|
||||||
),
|
),
|
||||||
## End Search
|
## End Search
|
||||||
# Gpt
|
# External
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
|
||||||
GPTIntegrationEndpoint.as_view(),
|
GPTIntegrationEndpoint.as_view(),
|
||||||
name="importer",
|
name="importer",
|
||||||
),
|
),
|
||||||
## End Gpt
|
|
||||||
# Release Notes
|
|
||||||
path(
|
path(
|
||||||
"release-notes/",
|
"release-notes/",
|
||||||
ReleaseNotesEndpoint.as_view(),
|
ReleaseNotesEndpoint.as_view(),
|
||||||
name="release-notes",
|
name="release-notes",
|
||||||
),
|
),
|
||||||
## End Release Notes
|
path(
|
||||||
|
"unsplash/",
|
||||||
|
UnsplashEndpoint.as_view(),
|
||||||
|
name="release-notes",
|
||||||
|
),
|
||||||
|
## End External
|
||||||
# Inbox
|
# Inbox
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/",
|
||||||
@ -1728,4 +1739,11 @@ urlpatterns = [
|
|||||||
name="workspace-project-boards",
|
name="workspace-project-boards",
|
||||||
),
|
),
|
||||||
## End Public Boards
|
## End Public Boards
|
||||||
|
# Configuration
|
||||||
|
path(
|
||||||
|
"configs/",
|
||||||
|
ConfigurationEndpoint.as_view(),
|
||||||
|
name="configuration",
|
||||||
|
),
|
||||||
|
## End Configuration
|
||||||
]
|
]
|
||||||
|
@ -17,6 +17,7 @@ from .project import (
|
|||||||
ProjectMemberEndpoint,
|
ProjectMemberEndpoint,
|
||||||
WorkspaceProjectDeployBoardEndpoint,
|
WorkspaceProjectDeployBoardEndpoint,
|
||||||
LeaveProjectEndpoint,
|
LeaveProjectEndpoint,
|
||||||
|
ProjectPublicCoverImagesEndpoint,
|
||||||
)
|
)
|
||||||
from .user import (
|
from .user import (
|
||||||
UserEndpoint,
|
UserEndpoint,
|
||||||
@ -147,16 +148,13 @@ from .page import (
|
|||||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||||
|
|
||||||
|
|
||||||
from .gpt import GPTIntegrationEndpoint
|
from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint
|
||||||
|
|
||||||
from .estimate import (
|
from .estimate import (
|
||||||
ProjectEstimatePointEndpoint,
|
ProjectEstimatePointEndpoint,
|
||||||
BulkEstimatePointEndpoint,
|
BulkEstimatePointEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
from .release import ReleaseNotesEndpoint
|
|
||||||
|
|
||||||
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
|
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
|
||||||
|
|
||||||
from .analytic import (
|
from .analytic import (
|
||||||
@ -170,3 +168,5 @@ from .analytic import (
|
|||||||
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
|
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,7 +80,7 @@ class CycleViewSet(BaseViewSet):
|
|||||||
issue_id=str(self.kwargs.get("pk", None)),
|
issue_id=str(self.kwargs.get("pk", None)),
|
||||||
project_id=str(self.kwargs.get("project_id", None)),
|
project_id=str(self.kwargs.get("project_id", None)),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
return super().perform_destroy(instance)
|
return super().perform_destroy(instance)
|
||||||
@ -102,48 +102,84 @@ class CycleViewSet(BaseViewSet):
|
|||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("owned_by")
|
.select_related("owned_by")
|
||||||
.annotate(is_favorite=Exists(subquery))
|
.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(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"issue_cycle__issue__state__group",
|
"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(
|
.annotate(
|
||||||
cancelled_issues=Count(
|
cancelled_issues=Count(
|
||||||
"issue_cycle__issue__state__group",
|
"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(
|
.annotate(
|
||||||
started_issues=Count(
|
started_issues=Count(
|
||||||
"issue_cycle__issue__state__group",
|
"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(
|
.annotate(
|
||||||
unstarted_issues=Count(
|
unstarted_issues=Count(
|
||||||
"issue_cycle__issue__state__group",
|
"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(
|
.annotate(
|
||||||
backlog_issues=Count(
|
backlog_issues=Count(
|
||||||
"issue_cycle__issue__state__group",
|
"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(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_estimates=Sum(
|
completed_estimates=Sum(
|
||||||
"issue_cycle__issue__estimate_point",
|
"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(
|
.annotate(
|
||||||
started_estimates=Sum(
|
started_estimates=Sum(
|
||||||
"issue_cycle__issue__estimate_point",
|
"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(
|
.prefetch_related(
|
||||||
@ -196,17 +232,30 @@ class CycleViewSet(BaseViewSet):
|
|||||||
.annotate(assignee_id=F("assignees__id"))
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
.annotate(avatar=F("assignees__avatar"))
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
.values("display_name", "assignee_id", "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(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"assignee_id",
|
"assignee_id",
|
||||||
filter=Q(completed_at__isnull=False),
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
pending_issues=Count(
|
pending_issues=Count(
|
||||||
"assignee_id",
|
"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")
|
.order_by("display_name")
|
||||||
@ -222,17 +271,30 @@ class CycleViewSet(BaseViewSet):
|
|||||||
.annotate(color=F("labels__color"))
|
.annotate(color=F("labels__color"))
|
||||||
.annotate(label_id=F("labels__id"))
|
.annotate(label_id=F("labels__id"))
|
||||||
.values("label_name", "color", "label_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(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"label_id",
|
"label_id",
|
||||||
filter=Q(completed_at__isnull=False),
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
pending_issues=Count(
|
pending_issues=Count(
|
||||||
"label_id",
|
"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")
|
.order_by("label_name")
|
||||||
@ -385,17 +447,30 @@ class CycleViewSet(BaseViewSet):
|
|||||||
.values(
|
.values(
|
||||||
"first_name", "last_name", "assignee_id", "avatar", "display_name"
|
"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(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"assignee_id",
|
"assignee_id",
|
||||||
filter=Q(completed_at__isnull=False),
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
pending_issues=Count(
|
pending_issues=Count(
|
||||||
"assignee_id",
|
"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")
|
.order_by("first_name", "last_name")
|
||||||
@ -412,17 +487,30 @@ class CycleViewSet(BaseViewSet):
|
|||||||
.annotate(color=F("labels__color"))
|
.annotate(color=F("labels__color"))
|
||||||
.annotate(label_id=F("labels__id"))
|
.annotate(label_id=F("labels__id"))
|
||||||
.values("label_name", "color", "label_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(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"label_id",
|
"label_id",
|
||||||
filter=Q(completed_at__isnull=False),
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
pending_issues=Count(
|
pending_issues=Count(
|
||||||
"label_id",
|
"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")
|
.order_by("label_name")
|
||||||
@ -488,7 +576,7 @@ class CycleIssueViewSet(BaseViewSet):
|
|||||||
issue_id=str(self.kwargs.get("pk", None)),
|
issue_id=str(self.kwargs.get("pk", None)),
|
||||||
project_id=str(self.kwargs.get("project_id", None)),
|
project_id=str(self.kwargs.get("project_id", None)),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
return super().perform_destroy(instance)
|
return super().perform_destroy(instance)
|
||||||
|
|
||||||
@ -664,7 +752,7 @@ class CycleIssueViewSet(BaseViewSet):
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return all Cycle Issues
|
# Return all Cycle Issues
|
||||||
|
@ -2,9 +2,10 @@
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
|
import openai
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
import openai
|
from rest_framework.permissions import AllowAny
|
||||||
from sentry_sdk import capture_exception
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
@ -15,6 +16,7 @@ from .base import BaseAPIView
|
|||||||
from plane.api.permissions import ProjectEntityPermission
|
from plane.api.permissions import ProjectEntityPermission
|
||||||
from plane.db.models import Workspace, Project
|
from plane.db.models import Workspace, Project
|
||||||
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
|
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
|
||||||
|
from plane.utils.integrations.github import get_release_notes
|
||||||
|
|
||||||
|
|
||||||
class GPTIntegrationEndpoint(BaseAPIView):
|
class GPTIntegrationEndpoint(BaseAPIView):
|
||||||
@ -73,3 +75,44 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
|||||||
{"error": "Something went wrong please try again later"},
|
{"error": "Something went wrong please try again later"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
sort_order=largest_sort_order,
|
||||||
start_date=issue_data.get("start_date", None),
|
start_date=issue_data.get("start_date", None),
|
||||||
target_date=issue_data.get("target_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,
|
created_by=request.user,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -173,12 +173,12 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check for valid priority
|
# 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",
|
"low",
|
||||||
"medium",
|
"medium",
|
||||||
"high",
|
"high",
|
||||||
"urgent",
|
"urgent",
|
||||||
None,
|
"none",
|
||||||
]:
|
]:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
||||||
@ -213,7 +213,7 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
issue_id=str(issue.id),
|
issue_id=str(issue.id),
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
# create an inbox issue
|
# create an inbox issue
|
||||||
InboxIssue.objects.create(
|
InboxIssue.objects.create(
|
||||||
@ -278,7 +278,7 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
IssueSerializer(current_instance).data,
|
IssueSerializer(current_instance).data,
|
||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
issue_serializer.save()
|
issue_serializer.save()
|
||||||
else:
|
else:
|
||||||
@ -370,6 +370,11 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id):
|
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)
|
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()
|
inbox_issue.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
except InboxIssue.DoesNotExist:
|
except InboxIssue.DoesNotExist:
|
||||||
@ -480,12 +485,12 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check for valid priority
|
# 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",
|
"low",
|
||||||
"medium",
|
"medium",
|
||||||
"high",
|
"high",
|
||||||
"urgent",
|
"urgent",
|
||||||
None,
|
"none",
|
||||||
]:
|
]:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
||||||
@ -520,7 +525,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
|||||||
issue_id=str(issue.id),
|
issue_id=str(issue.id),
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
# create an inbox issue
|
# create an inbox issue
|
||||||
InboxIssue.objects.create(
|
InboxIssue.objects.create(
|
||||||
@ -585,7 +590,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
|||||||
IssueSerializer(current_instance).data,
|
IssueSerializer(current_instance).data,
|
||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
issue_serializer.save()
|
issue_serializer.save()
|
||||||
return Response(issue_serializer.data, status=status.HTTP_200_OK)
|
return Response(issue_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
@ -24,7 +24,6 @@ from django.core.serializers.json import DjangoJSONEncoder
|
|||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.gzip import gzip_page
|
from django.views.decorators.gzip import gzip_page
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.conf import settings
|
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
|
|
||||||
# Third Party imports
|
# Third Party imports
|
||||||
@ -58,7 +57,6 @@ from plane.api.serializers import (
|
|||||||
IssuePublicSerializer,
|
IssuePublicSerializer,
|
||||||
)
|
)
|
||||||
from plane.api.permissions import (
|
from plane.api.permissions import (
|
||||||
WorkspaceEntityPermission,
|
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
WorkSpaceAdminPermission,
|
WorkSpaceAdminPermission,
|
||||||
ProjectMemberPermission,
|
ProjectMemberPermission,
|
||||||
@ -130,7 +128,7 @@ class IssueViewSet(BaseViewSet):
|
|||||||
current_instance=json.dumps(
|
current_instance=json.dumps(
|
||||||
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
return super().perform_update(serializer)
|
return super().perform_update(serializer)
|
||||||
@ -151,7 +149,7 @@ class IssueViewSet(BaseViewSet):
|
|||||||
current_instance=json.dumps(
|
current_instance=json.dumps(
|
||||||
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
return super().perform_destroy(instance)
|
return super().perform_destroy(instance)
|
||||||
|
|
||||||
@ -318,7 +316,7 @@ class IssueViewSet(BaseViewSet):
|
|||||||
issue_id=str(serializer.data.get("id", None)),
|
issue_id=str(serializer.data.get("id", None)),
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -330,7 +328,12 @@ class IssueViewSet(BaseViewSet):
|
|||||||
|
|
||||||
def retrieve(self, request, slug, project_id, pk=None):
|
def retrieve(self, request, slug, project_id, pk=None):
|
||||||
try:
|
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
|
workspace__slug=slug, project_id=project_id, pk=pk
|
||||||
)
|
)
|
||||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||||
@ -572,7 +575,7 @@ class IssueCommentViewSet(BaseViewSet):
|
|||||||
issue_id=str(self.kwargs.get("issue_id")),
|
issue_id=str(self.kwargs.get("issue_id")),
|
||||||
project_id=str(self.kwargs.get("project_id")),
|
project_id=str(self.kwargs.get("project_id")),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
@ -591,7 +594,7 @@ class IssueCommentViewSet(BaseViewSet):
|
|||||||
IssueCommentSerializer(current_instance).data,
|
IssueCommentSerializer(current_instance).data,
|
||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
return super().perform_update(serializer)
|
return super().perform_update(serializer)
|
||||||
@ -613,7 +616,7 @@ class IssueCommentViewSet(BaseViewSet):
|
|||||||
IssueCommentSerializer(current_instance).data,
|
IssueCommentSerializer(current_instance).data,
|
||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
return super().perform_destroy(instance)
|
return super().perform_destroy(instance)
|
||||||
|
|
||||||
@ -709,10 +712,18 @@ class LabelViewSet(BaseViewSet):
|
|||||||
ProjectMemberPermission,
|
ProjectMemberPermission,
|
||||||
]
|
]
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def create(self, request, slug, project_id):
|
||||||
serializer.save(
|
try:
|
||||||
project_id=self.kwargs.get("project_id"),
|
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):
|
def get_queryset(self):
|
||||||
return self.filter_queryset(
|
return self.filter_queryset(
|
||||||
@ -897,7 +908,7 @@ class IssueLinkViewSet(BaseViewSet):
|
|||||||
issue_id=str(self.kwargs.get("issue_id")),
|
issue_id=str(self.kwargs.get("issue_id")),
|
||||||
project_id=str(self.kwargs.get("project_id")),
|
project_id=str(self.kwargs.get("project_id")),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
@ -916,7 +927,7 @@ class IssueLinkViewSet(BaseViewSet):
|
|||||||
IssueLinkSerializer(current_instance).data,
|
IssueLinkSerializer(current_instance).data,
|
||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
return super().perform_update(serializer)
|
return super().perform_update(serializer)
|
||||||
@ -938,7 +949,7 @@ class IssueLinkViewSet(BaseViewSet):
|
|||||||
IssueLinkSerializer(current_instance).data,
|
IssueLinkSerializer(current_instance).data,
|
||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
return super().perform_destroy(instance)
|
return super().perform_destroy(instance)
|
||||||
|
|
||||||
@ -1017,7 +1028,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||||||
serializer.data,
|
serializer.data,
|
||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -1040,7 +1051,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||||
project_id=str(self.kwargs.get("project_id", None)),
|
project_id=str(self.kwargs.get("project_id", None)),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@ -1243,7 +1254,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
|||||||
issue_id=str(issue.id),
|
issue_id=str(issue.id),
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||||
@ -1448,7 +1459,7 @@ class IssueReactionViewSet(BaseViewSet):
|
|||||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||||
project_id=str(self.kwargs.get("project_id", None)),
|
project_id=str(self.kwargs.get("project_id", None)),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, issue_id, reaction_code):
|
def destroy(self, request, slug, project_id, issue_id, reaction_code):
|
||||||
@ -1472,7 +1483,7 @@ class IssueReactionViewSet(BaseViewSet):
|
|||||||
"identifier": str(issue_reaction.id),
|
"identifier": str(issue_reaction.id),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
issue_reaction.delete()
|
issue_reaction.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@ -1521,7 +1532,7 @@ class CommentReactionViewSet(BaseViewSet):
|
|||||||
issue_id=None,
|
issue_id=None,
|
||||||
project_id=str(self.kwargs.get("project_id", None)),
|
project_id=str(self.kwargs.get("project_id", None)),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, comment_id, reaction_code):
|
def destroy(self, request, slug, project_id, comment_id, reaction_code):
|
||||||
@ -1546,7 +1557,7 @@ class CommentReactionViewSet(BaseViewSet):
|
|||||||
"comment_id": str(comment_id),
|
"comment_id": str(comment_id),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
comment_reaction.delete()
|
comment_reaction.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@ -1643,7 +1654,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
|||||||
issue_id=str(issue_id),
|
issue_id=str(issue_id),
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
if not ProjectMember.objects.filter(
|
if not ProjectMember.objects.filter(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
@ -1693,7 +1704,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
|||||||
IssueCommentSerializer(comment).data,
|
IssueCommentSerializer(comment).data,
|
||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -1727,7 +1738,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
|||||||
IssueCommentSerializer(comment).data,
|
IssueCommentSerializer(comment).data,
|
||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
comment.delete()
|
comment.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@ -1802,7 +1813,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
|
|||||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||||
project_id=str(self.kwargs.get("project_id", None)),
|
project_id=str(self.kwargs.get("project_id", None)),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -1847,7 +1858,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
|
|||||||
"identifier": str(issue_reaction.id),
|
"identifier": str(issue_reaction.id),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
issue_reaction.delete()
|
issue_reaction.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@ -1921,7 +1932,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
|
|||||||
issue_id=None,
|
issue_id=None,
|
||||||
project_id=str(self.kwargs.get("project_id", None)),
|
project_id=str(self.kwargs.get("project_id", None)),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -1973,7 +1984,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
|
|||||||
"comment_id": str(comment_id),
|
"comment_id": str(comment_id),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
comment_reaction.delete()
|
comment_reaction.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@ -2037,7 +2048,7 @@ class IssueVotePublicViewSet(BaseViewSet):
|
|||||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||||
project_id=str(self.kwargs.get("project_id", None)),
|
project_id=str(self.kwargs.get("project_id", None)),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
serializer = IssueVoteSerializer(issue_vote)
|
serializer = IssueVoteSerializer(issue_vote)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
@ -2072,7 +2083,7 @@ class IssueVotePublicViewSet(BaseViewSet):
|
|||||||
"identifier": str(issue_vote.id),
|
"identifier": str(issue_vote.id),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
issue_vote.delete()
|
issue_vote.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@ -2106,7 +2117,7 @@ class IssueRelationViewSet(BaseViewSet):
|
|||||||
IssueRelationSerializer(current_instance).data,
|
IssueRelationSerializer(current_instance).data,
|
||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
return super().perform_destroy(instance)
|
return super().perform_destroy(instance)
|
||||||
|
|
||||||
@ -2140,7 +2151,7 @@ class IssueRelationViewSet(BaseViewSet):
|
|||||||
issue_id=str(issue_id),
|
issue_id=str(issue_id),
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
if relation == "blocking":
|
if relation == "blocking":
|
||||||
@ -2397,27 +2408,6 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
model = Issue
|
model = Issue
|
||||||
|
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
|
||||||
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
|
||||||
current_instance = (
|
|
||||||
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
|
||||||
)
|
|
||||||
if current_instance is not None:
|
|
||||||
issue_activity.delay(
|
|
||||||
type="issue_draft.activity.updated",
|
|
||||||
requested_data=requested_data,
|
|
||||||
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_update(serializer)
|
|
||||||
|
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
current_instance = (
|
current_instance = (
|
||||||
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
||||||
@ -2434,6 +2424,7 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
current_instance=json.dumps(
|
current_instance=json.dumps(
|
||||||
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
||||||
),
|
),
|
||||||
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
return super().perform_destroy(instance)
|
return super().perform_destroy(instance)
|
||||||
|
|
||||||
@ -2597,7 +2588,7 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
issue_id=str(serializer.data.get("id", None)),
|
issue_id=str(serializer.data.get("id", None)),
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -2608,6 +2599,47 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
def retrieve(self, request, slug, project_id, pk=None):
|
||||||
try:
|
try:
|
||||||
issue = Issue.objects.get(
|
issue = Issue.objects.get(
|
||||||
|
@ -40,6 +40,7 @@ from plane.utils.grouper import group_results
|
|||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
from plane.utils.analytics_plot import burndown_plot
|
from plane.utils.analytics_plot import burndown_plot
|
||||||
|
|
||||||
|
|
||||||
class ModuleViewSet(BaseViewSet):
|
class ModuleViewSet(BaseViewSet):
|
||||||
model = Module
|
model = Module
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
@ -78,35 +79,63 @@ class ModuleViewSet(BaseViewSet):
|
|||||||
queryset=ModuleLink.objects.select_related("module", "created_by"),
|
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(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"issue_module__issue__state__group",
|
"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(
|
.annotate(
|
||||||
cancelled_issues=Count(
|
cancelled_issues=Count(
|
||||||
"issue_module__issue__state__group",
|
"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(
|
.annotate(
|
||||||
started_issues=Count(
|
started_issues=Count(
|
||||||
"issue_module__issue__state__group",
|
"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(
|
.annotate(
|
||||||
unstarted_issues=Count(
|
unstarted_issues=Count(
|
||||||
"issue_module__issue__state__group",
|
"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(
|
.annotate(
|
||||||
backlog_issues=Count(
|
backlog_issues=Count(
|
||||||
"issue_module__issue__state__group",
|
"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")
|
.order_by(order_by, "name")
|
||||||
@ -130,7 +159,7 @@ class ModuleViewSet(BaseViewSet):
|
|||||||
issue_id=str(self.kwargs.get("pk", None)),
|
issue_id=str(self.kwargs.get("pk", None)),
|
||||||
project_id=str(self.kwargs.get("project_id", None)),
|
project_id=str(self.kwargs.get("project_id", None)),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
return super().perform_destroy(instance)
|
return super().perform_destroy(instance)
|
||||||
@ -179,18 +208,36 @@ class ModuleViewSet(BaseViewSet):
|
|||||||
.annotate(assignee_id=F("assignees__id"))
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
.annotate(display_name=F("assignees__display_name"))
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
.annotate(avatar=F("assignees__avatar"))
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
|
.values(
|
||||||
.annotate(total_issues=Count("assignee_id"))
|
"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(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"assignee_id",
|
"assignee_id",
|
||||||
filter=Q(completed_at__isnull=False),
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
pending_issues=Count(
|
pending_issues=Count(
|
||||||
"assignee_id",
|
"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")
|
.order_by("first_name", "last_name")
|
||||||
@ -206,17 +253,33 @@ class ModuleViewSet(BaseViewSet):
|
|||||||
.annotate(color=F("labels__color"))
|
.annotate(color=F("labels__color"))
|
||||||
.annotate(label_id=F("labels__id"))
|
.annotate(label_id=F("labels__id"))
|
||||||
.values("label_name", "color", "label_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(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"label_id",
|
"label_id",
|
||||||
filter=Q(completed_at__isnull=False),
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
pending_issues=Count(
|
pending_issues=Count(
|
||||||
"label_id",
|
"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")
|
.order_by("label_name")
|
||||||
@ -279,7 +342,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
|||||||
issue_id=str(self.kwargs.get("pk", None)),
|
issue_id=str(self.kwargs.get("pk", None)),
|
||||||
project_id=str(self.kwargs.get("project_id", None)),
|
project_id=str(self.kwargs.get("project_id", None)),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
return super().perform_destroy(instance)
|
return super().perform_destroy(instance)
|
||||||
|
|
||||||
@ -447,7 +510,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
@ -494,7 +557,6 @@ class ModuleLinkViewSet(BaseViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class ModuleFavoriteViewSet(BaseViewSet):
|
class ModuleFavoriteViewSet(BaseViewSet):
|
||||||
|
|
||||||
serializer_class = ModuleFavoriteSerializer
|
serializer_class = ModuleFavoriteSerializer
|
||||||
model = ModuleFavorite
|
model = ModuleFavorite
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import jwt
|
import jwt
|
||||||
|
import boto3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
@ -495,7 +496,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||||||
serializer_class = ProjectMemberAdminSerializer
|
serializer_class = ProjectMemberAdminSerializer
|
||||||
model = ProjectMember
|
model = ProjectMember
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
ProjectBasePermission,
|
ProjectMemberPermission,
|
||||||
]
|
]
|
||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
@ -617,7 +618,8 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
except ProjectMember.DoesNotExist:
|
except ProjectMember.DoesNotExist:
|
||||||
return Response(
|
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:
|
except Exception as e:
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
@ -1094,7 +1096,7 @@ class ProjectMemberEndpoint(BaseAPIView):
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
member__is_bot=False,
|
member__is_bot=False,
|
||||||
).select_related("project", "member")
|
).select_related("project", "member", "workspace")
|
||||||
serializer = ProjectMemberSerializer(project_members, many=True)
|
serializer = ProjectMemberSerializer(project_members, many=True)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -1209,3 +1211,38 @@ class LeaveProjectEndpoint(BaseAPIView):
|
|||||||
{"error": "Something went wrong please try again later"},
|
{"error": "Something went wrong please try again later"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
|
||||||
)
|
|
@ -61,7 +61,7 @@ class GlobalViewViewSet(BaseViewSet):
|
|||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.order_by("-created_at")
|
.order_by(self.request.GET.get("order_by", "-created_at"))
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1197,7 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
|
|||||||
projects = request.query_params.getlist("project", [])
|
projects = request.query_params.getlist("project", [])
|
||||||
|
|
||||||
queryset = IssueActivity.objects.filter(
|
queryset = IssueActivity.objects.filter(
|
||||||
~Q(field__in=["comment", "vote", "reaction"]),
|
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
actor=user_id,
|
actor=user_id,
|
||||||
@ -1239,13 +1239,21 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
|
|||||||
.annotate(
|
.annotate(
|
||||||
created_issues=Count(
|
created_issues=Count(
|
||||||
"project_issue",
|
"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(
|
.annotate(
|
||||||
assigned_issues=Count(
|
assigned_issues=Count(
|
||||||
"project_issue",
|
"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(
|
.annotate(
|
||||||
@ -1254,6 +1262,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
|
|||||||
filter=Q(
|
filter=Q(
|
||||||
project_issue__completed_at__isnull=False,
|
project_issue__completed_at__isnull=False,
|
||||||
project_issue__assignees__in=[user_id],
|
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",
|
"started",
|
||||||
],
|
],
|
||||||
project_issue__assignees__in=[user_id],
|
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):
|
def get(self, request, slug, user_id):
|
||||||
try:
|
try:
|
||||||
filters = issue_filters(request.query_params, "GET")
|
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")
|
order_by_param = request.GET.get("order_by", "-created_at")
|
||||||
issue_queryset = (
|
issue_queryset = (
|
||||||
Issue.issue_objects.filter(
|
Issue.issue_objects.filter(
|
||||||
|
@ -32,7 +32,7 @@ def delete_old_s3_link():
|
|||||||
else:
|
else:
|
||||||
s3 = boto3.client(
|
s3 = boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
region_name="ap-south-1",
|
region_name=settings.AWS_REGION,
|
||||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||||
config=Config(signature_version="s3v4"),
|
config=Config(signature_version="s3v4"),
|
||||||
|
@ -121,36 +121,20 @@ def track_priority(
|
|||||||
epoch
|
epoch
|
||||||
):
|
):
|
||||||
if current_instance.get("priority") != requested_data.get("priority"):
|
if current_instance.get("priority") != requested_data.get("priority"):
|
||||||
if requested_data.get("priority") == None:
|
issue_activities.append(
|
||||||
issue_activities.append(
|
IssueActivity(
|
||||||
IssueActivity(
|
issue_id=issue_id,
|
||||||
issue_id=issue_id,
|
actor=actor,
|
||||||
actor=actor,
|
verb="updated",
|
||||||
verb="updated",
|
old_value=current_instance.get("priority"),
|
||||||
old_value=current_instance.get("priority"),
|
new_value=requested_data.get("priority"),
|
||||||
new_value=None,
|
field="priority",
|
||||||
field="priority",
|
project=project,
|
||||||
project=project,
|
workspace=project.workspace,
|
||||||
workspace=project.workspace,
|
comment=f"updated the priority to {requested_data.get('priority')}",
|
||||||
comment=f"updated the priority to None",
|
epoch=epoch,
|
||||||
epoch=epoch,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
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')}",
|
|
||||||
epoch=epoch,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Track chnages in state of the issue
|
# Track chnages in state of the issue
|
||||||
@ -1405,7 +1389,7 @@ def issue_activity(
|
|||||||
):
|
):
|
||||||
issue_subscribers = issue_subscribers + [issue.created_by_id]
|
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:
|
for issue_activity in issue_activities_created:
|
||||||
bulk_notifications.append(
|
bulk_notifications.append(
|
||||||
Notification(
|
Notification(
|
||||||
|
@ -58,28 +58,31 @@ def archive_old_issues():
|
|||||||
|
|
||||||
# Check if Issues
|
# Check if Issues
|
||||||
if issues:
|
if issues:
|
||||||
|
# Set the archive time to current time
|
||||||
|
archive_at = timezone.now()
|
||||||
|
|
||||||
issues_to_update = []
|
issues_to_update = []
|
||||||
for issue in issues:
|
for issue in issues:
|
||||||
issue.archived_at = timezone.now()
|
issue.archived_at = archive_at
|
||||||
issues_to_update.append(issue)
|
issues_to_update.append(issue)
|
||||||
|
|
||||||
# Bulk Update the issues and log the activity
|
# Bulk Update the issues and log the activity
|
||||||
if issues_to_update:
|
if issues_to_update:
|
||||||
updated_issues = Issue.objects.bulk_update(
|
Issue.objects.bulk_update(
|
||||||
issues_to_update, ["archived_at"], batch_size=100
|
issues_to_update, ["archived_at"], batch_size=100
|
||||||
)
|
)
|
||||||
[
|
[
|
||||||
issue_activity.delay(
|
issue_activity.delay(
|
||||||
type="issue.activity.updated",
|
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),
|
actor_id=str(project.created_by_id),
|
||||||
issue_id=issue.id,
|
issue_id=issue.id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
subscriber=False,
|
subscriber=False,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
for issue in updated_issues
|
for issue in issues_to_update
|
||||||
]
|
]
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -139,7 +142,7 @@ def close_old_issues():
|
|||||||
|
|
||||||
# Bulk Update the issues and log the activity
|
# Bulk Update the issues and log the activity
|
||||||
if issues_to_update:
|
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(
|
issue_activity.delay(
|
||||||
type="issue.activity.updated",
|
type="issue.activity.updated",
|
||||||
@ -149,9 +152,9 @@ def close_old_issues():
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
subscriber=False,
|
subscriber=False,
|
||||||
epoch = int(timezone.now().timestamp())
|
epoch=int(timezone.now().timestamp())
|
||||||
)
|
)
|
||||||
for issue in updated_issues
|
for issue in issues_to_update
|
||||||
]
|
]
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -33,9 +33,8 @@ def create_issue_relation(apps, schema_editor):
|
|||||||
def update_issue_priority_choice(apps, schema_editor):
|
def update_issue_priority_choice(apps, schema_editor):
|
||||||
IssueModel = apps.get_model("db", "Issue")
|
IssueModel = apps.get_model("db", "Issue")
|
||||||
updated_issues = []
|
updated_issues = []
|
||||||
for obj in IssueModel.objects.all():
|
for obj in IssueModel.objects.filter(priority=None):
|
||||||
if obj.priority is None:
|
obj.priority = "none"
|
||||||
obj.priority = "none"
|
|
||||||
updated_issues.append(obj)
|
updated_issues.append(obj)
|
||||||
IssueModel.objects.bulk_update(updated_issues, ["priority"], batch_size=100)
|
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", ""),
|
"calendar_date_range": old_props.get("calendarDateRange", ""),
|
||||||
},
|
},
|
||||||
"display_properties": {
|
"display_properties": {
|
||||||
"assignee": old_props.get("properties", {}).get("assignee",None),
|
"assignee": old_props.get("properties", {}).get("assignee", True),
|
||||||
"attachment_count": old_props.get("properties", {}).get("attachment_count", None),
|
"attachment_count": old_props.get("properties", {}).get("attachment_count", True),
|
||||||
"created_on": old_props.get("properties", {}).get("created_on", None),
|
"created_on": old_props.get("properties", {}).get("created_on", True),
|
||||||
"due_date": old_props.get("properties", {}).get("due_date", None),
|
"due_date": old_props.get("properties", {}).get("due_date", True),
|
||||||
"estimate": old_props.get("properties", {}).get("estimate", None),
|
"estimate": old_props.get("properties", {}).get("estimate", True),
|
||||||
"key": old_props.get("properties", {}).get("key", None),
|
"key": old_props.get("properties", {}).get("key", True),
|
||||||
"labels": old_props.get("properties", {}).get("labels", None),
|
"labels": old_props.get("properties", {}).get("labels", True),
|
||||||
"link": old_props.get("properties", {}).get("link", None),
|
"link": old_props.get("properties", {}).get("link", True),
|
||||||
"priority": old_props.get("properties", {}).get("priority", None),
|
"priority": old_props.get("properties", {}).get("priority", True),
|
||||||
"start_date": old_props.get("properties", {}).get("start_date", None),
|
"start_date": old_props.get("properties", {}).get("start_date", True),
|
||||||
"state": old_props.get("properties", {}).get("state", None),
|
"state": old_props.get("properties", {}).get("state", True),
|
||||||
"sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", None),
|
"sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", True),
|
||||||
"updated_on": old_props.get("properties", {}).get("updated_on", None),
|
"updated_on": old_props.get("properties", {}).get("updated_on", True),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return new_props
|
return new_props
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
# Generated by Django 4.2.3 on 2023-09-15 06:55
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
def update_issue_activity(apps, schema_editor):
|
|
||||||
IssueActivityModel = apps.get_model("db", "IssueActivity")
|
|
||||||
updated_issue_activity = []
|
|
||||||
for obj in IssueActivityModel.objects.all():
|
|
||||||
if obj.field == "blocks":
|
|
||||||
obj.field = "blocked_by"
|
|
||||||
updated_issue_activity.append(obj)
|
|
||||||
IssueActivityModel.objects.bulk_update(updated_issue_activity, ["field"], batch_size=100)
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('db', '0044_auto_20230913_0709'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RunPython(update_issue_activity),
|
|
||||||
]
|
|
@ -1,28 +1,47 @@
|
|||||||
# Generated by Django 4.2.3 on 2023-09-19 14:21
|
# Generated by Django 4.2.5 on 2023-09-29 10:14
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
import plane.db.models.workspace
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
def update_epoch(apps, schema_editor):
|
def update_issue_activity_priority(apps, schema_editor):
|
||||||
IssueActivity = apps.get_model('db', 'IssueActivity')
|
IssueActivity = apps.get_model("db", "IssueActivity")
|
||||||
updated_issue_activity = []
|
updated_issue_activity = []
|
||||||
for obj in IssueActivity.objects.all():
|
for obj in IssueActivity.objects.filter(field="priority"):
|
||||||
obj.epoch = int(obj.created_at.timestamp())
|
# 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)
|
updated_issue_activity.append(obj)
|
||||||
IssueActivity.objects.bulk_update(updated_issue_activity, ["epoch"], batch_size=100)
|
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):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('db', '0045_auto_20230915_0655'),
|
('db', '0044_auto_20230913_0709'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='GlobalView',
|
name='GlobalView',
|
||||||
fields=[
|
fields=[
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||||
@ -33,6 +52,7 @@ class Migration(migrations.Migration):
|
|||||||
('query', models.JSONField(verbose_name='View Query')),
|
('query', models.JSONField(verbose_name='View Query')),
|
||||||
('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)),
|
('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)),
|
||||||
('query_data', models.JSONField(default=dict)),
|
('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')),
|
('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')),
|
('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')),
|
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_views', to='db.workspace')),
|
||||||
@ -44,10 +64,16 @@ class Migration(migrations.Migration):
|
|||||||
'ordering': ('-created_at',),
|
'ordering': ('-created_at',),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workspacemember',
|
||||||
|
name='issue_props',
|
||||||
|
field=models.JSONField(default=plane.db.models.workspace.get_issue_props),
|
||||||
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='issueactivity',
|
model_name='issueactivity',
|
||||||
name='epoch',
|
name='epoch',
|
||||||
field=models.FloatField(null=True),
|
field=models.FloatField(null=True),
|
||||||
),
|
),
|
||||||
migrations.RunPython(update_epoch),
|
migrations.RunPython(update_issue_activity_priority),
|
||||||
|
migrations.RunPython(update_issue_activity_blocked),
|
||||||
]
|
]
|
@ -17,6 +17,7 @@ class GlobalView(BaseModel):
|
|||||||
default=1, choices=((0, "Private"), (1, "Public"))
|
default=1, choices=((0, "Private"), (1, "Public"))
|
||||||
)
|
)
|
||||||
query_data = models.JSONField(default=dict)
|
query_data = models.JSONField(default=dict)
|
||||||
|
sort_order = models.FloatField(default=65535)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Global View"
|
verbose_name = "Global View"
|
||||||
@ -24,6 +25,16 @@ class GlobalView(BaseModel):
|
|||||||
db_table = "global_views"
|
db_table = "global_views"
|
||||||
ordering = ("-created_at",)
|
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):
|
def __str__(self):
|
||||||
"""Return name of the View"""
|
"""Return name of the View"""
|
||||||
return f"{self.name} <{self.workspace.name}>"
|
return f"{self.name} <{self.workspace.name}>"
|
||||||
|
@ -29,7 +29,7 @@ def get_default_props():
|
|||||||
},
|
},
|
||||||
"display_filters": {
|
"display_filters": {
|
||||||
"group_by": None,
|
"group_by": None,
|
||||||
"order_by": '-created_at',
|
"order_by": "-created_at",
|
||||||
"type": None,
|
"type": None,
|
||||||
"sub_issue": True,
|
"sub_issue": True,
|
||||||
"show_empty_groups": True,
|
"show_empty_groups": True,
|
||||||
@ -54,6 +54,15 @@ def get_default_props():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_issue_props():
|
||||||
|
return {
|
||||||
|
"subscribed": True,
|
||||||
|
"assigned": True,
|
||||||
|
"created": True,
|
||||||
|
"all_issues": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Workspace(BaseModel):
|
class Workspace(BaseModel):
|
||||||
name = models.CharField(max_length=80, verbose_name="Workspace Name")
|
name = models.CharField(max_length=80, verbose_name="Workspace Name")
|
||||||
logo = models.URLField(verbose_name="Logo", blank=True, null=True)
|
logo = models.URLField(verbose_name="Logo", blank=True, null=True)
|
||||||
@ -89,6 +98,7 @@ class WorkspaceMember(BaseModel):
|
|||||||
company_role = models.TextField(null=True, blank=True)
|
company_role = models.TextField(null=True, blank=True)
|
||||||
view_props = models.JSONField(default=get_default_props)
|
view_props = models.JSONField(default=get_default_props)
|
||||||
default_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:
|
class Meta:
|
||||||
unique_together = ["workspace", "member"]
|
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)
|
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||||
|
|
||||||
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
||||||
|
|
||||||
|
# Unsplash Access key
|
||||||
|
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
|
||||||
|
@ -7,6 +7,7 @@ import dj_database_url
|
|||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
from sentry_sdk.integrations.redis import RedisIntegration
|
from sentry_sdk.integrations.redis import RedisIntegration
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from .common import * # noqa
|
from .common import * # noqa
|
||||||
|
|
||||||
@ -89,90 +90,112 @@ if bool(os.environ.get("SENTRY_DSN", False)):
|
|||||||
profiles_sample_rate=1.0,
|
profiles_sample_rate=1.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
# The AWS region to connect to.
|
if DOCKERIZED and USE_MINIO:
|
||||||
AWS_REGION = os.environ.get("AWS_REGION", "")
|
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
|
||||||
|
|
||||||
# The AWS access key to use.
|
# Custom Domain settings
|
||||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
|
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}:"
|
||||||
|
else:
|
||||||
|
# The AWS region to connect to.
|
||||||
|
AWS_REGION = os.environ.get("AWS_REGION", "")
|
||||||
|
|
||||||
# The AWS secret access key to use.
|
# The AWS access key to use.
|
||||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
|
||||||
|
|
||||||
# The optional AWS session token to use.
|
# The AWS secret access key to use.
|
||||||
# AWS_SESSION_TOKEN = ""
|
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
||||||
|
|
||||||
# The name of the bucket to store files in.
|
# The optional AWS session token to use.
|
||||||
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
|
# AWS_SESSION_TOKEN = ""
|
||||||
|
|
||||||
# How to construct S3 URLs ("auto", "path", "virtual").
|
# The name of the bucket to store files in.
|
||||||
AWS_S3_ADDRESSING_STYLE = "auto"
|
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
|
||||||
|
|
||||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
# How to construct S3 URLs ("auto", "path", "virtual").
|
||||||
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
|
AWS_S3_ADDRESSING_STYLE = "auto"
|
||||||
|
|
||||||
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
|
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||||
AWS_S3_KEY_PREFIX = ""
|
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
|
||||||
|
|
||||||
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
|
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
|
||||||
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
|
AWS_S3_KEY_PREFIX = ""
|
||||||
# and their permissions will be set to "public-read".
|
|
||||||
AWS_S3_BUCKET_AUTH = False
|
|
||||||
|
|
||||||
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
|
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
|
||||||
# is True. It also affects the "Cache-Control" header of the files.
|
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
|
||||||
# Important: Changing this setting will not affect existing files.
|
# and their permissions will be set to "public-read".
|
||||||
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
|
AWS_S3_BUCKET_AUTH = False
|
||||||
|
|
||||||
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
|
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
|
||||||
# cannot be used with `AWS_S3_BUCKET_AUTH`.
|
# is True. It also affects the "Cache-Control" header of the files.
|
||||||
AWS_S3_PUBLIC_URL = ""
|
# Important: Changing this setting will not affect existing files.
|
||||||
|
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
|
||||||
|
|
||||||
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
|
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
|
||||||
# understand the consequences before enabling.
|
# cannot be used with `AWS_S3_BUCKET_AUTH`.
|
||||||
# Important: Changing this setting will not affect existing files.
|
AWS_S3_PUBLIC_URL = ""
|
||||||
AWS_S3_REDUCED_REDUNDANCY = False
|
|
||||||
|
|
||||||
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
|
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
|
||||||
# single `name` argument.
|
# understand the consequences before enabling.
|
||||||
# Important: Changing this setting will not affect existing files.
|
# Important: Changing this setting will not affect existing files.
|
||||||
AWS_S3_CONTENT_DISPOSITION = ""
|
AWS_S3_REDUCED_REDUNDANCY = False
|
||||||
|
|
||||||
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
|
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
|
||||||
# single `name` argument.
|
# single `name` argument.
|
||||||
# Important: Changing this setting will not affect existing files.
|
# Important: Changing this setting will not affect existing files.
|
||||||
AWS_S3_CONTENT_LANGUAGE = ""
|
AWS_S3_CONTENT_DISPOSITION = ""
|
||||||
|
|
||||||
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
|
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
|
||||||
# single `name` argument.
|
# single `name` argument.
|
||||||
# Important: Changing this setting will not affect existing files.
|
# Important: Changing this setting will not affect existing files.
|
||||||
AWS_S3_METADATA = {}
|
AWS_S3_CONTENT_LANGUAGE = ""
|
||||||
|
|
||||||
# If True, then files will be stored using AES256 server-side encryption.
|
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
|
||||||
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
|
# single `name` argument.
|
||||||
# Otherwise, server-side encryption is not be enabled.
|
# Important: Changing this setting will not affect existing files.
|
||||||
# Important: Changing this setting will not affect existing files.
|
AWS_S3_METADATA = {}
|
||||||
AWS_S3_ENCRYPT_KEY = False
|
|
||||||
|
|
||||||
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
|
# If True, then files will be stored using AES256 server-side encryption.
|
||||||
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
|
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
|
||||||
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
|
# Otherwise, server-side encryption is not be enabled.
|
||||||
|
# Important: Changing this setting will not affect existing files.
|
||||||
|
AWS_S3_ENCRYPT_KEY = False
|
||||||
|
|
||||||
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
|
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
|
||||||
# compressed size is smaller than their uncompressed size.
|
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
|
||||||
# Important: Changing this setting will not affect existing files.
|
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
|
||||||
AWS_S3_GZIP = True
|
|
||||||
|
|
||||||
# The signature version to use for S3 requests.
|
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
|
||||||
AWS_S3_SIGNATURE_VERSION = None
|
# compressed size is smaller than their uncompressed size.
|
||||||
|
# Important: Changing this setting will not affect existing files.
|
||||||
|
AWS_S3_GZIP = True
|
||||||
|
|
||||||
# If True, then files with the same name will overwrite each other. By default it's set to False to have
|
# The signature version to use for S3 requests.
|
||||||
# extra characters appended.
|
AWS_S3_SIGNATURE_VERSION = None
|
||||||
AWS_S3_FILE_OVERWRITE = False
|
|
||||||
|
|
||||||
STORAGES["default"] = {
|
# If True, then files with the same name will overwrite each other. By default it's set to False to have
|
||||||
"BACKEND": "django_s3_storage.storage.S3Storage",
|
# extra characters appended.
|
||||||
}
|
AWS_S3_FILE_OVERWRITE = False
|
||||||
|
|
||||||
|
STORAGES["default"] = {
|
||||||
|
"BACKEND": "django_s3_storage.storage.S3Storage",
|
||||||
|
}
|
||||||
# AWS Settings End
|
# AWS Settings End
|
||||||
|
|
||||||
# Enable Connection Pooling (if desired)
|
# Enable Connection Pooling (if desired)
|
||||||
@ -193,16 +216,27 @@ CSRF_COOKIE_SECURE = True
|
|||||||
|
|
||||||
REDIS_URL = os.environ.get("REDIS_URL")
|
REDIS_URL = os.environ.get("REDIS_URL")
|
||||||
|
|
||||||
CACHES = {
|
if DOCKERIZED:
|
||||||
"default": {
|
CACHES = {
|
||||||
"BACKEND": "django_redis.cache.RedisCache",
|
"default": {
|
||||||
"LOCATION": REDIS_URL,
|
"BACKEND": "django_redis.cache.RedisCache",
|
||||||
"OPTIONS": {
|
"LOCATION": REDIS_URL,
|
||||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
"OPTIONS": {
|
||||||
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
|
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django_redis.cache.RedisCache",
|
||||||
|
"LOCATION": REDIS_URL,
|
||||||
|
"OPTIONS": {
|
||||||
|
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||||
|
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so")
|
WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so")
|
||||||
@ -225,8 +259,12 @@ broker_url = (
|
|||||||
f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
CELERY_RESULT_BACKEND = broker_url
|
if DOCKERIZED:
|
||||||
CELERY_BROKER_URL = broker_url
|
CELERY_BROKER_URL = REDIS_URL
|
||||||
|
CELERY_RESULT_BACKEND = REDIS_URL
|
||||||
|
else:
|
||||||
|
CELERY_BROKER_URL = broker_url
|
||||||
|
CELERY_RESULT_BACKEND = broker_url
|
||||||
|
|
||||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||||
|
|
||||||
@ -238,3 +276,6 @@ SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
|
|||||||
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
|
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
|
||||||
SCOUT_NAME = "Plane"
|
SCOUT_NAME = "Plane"
|
||||||
|
|
||||||
|
# Unsplash Access key
|
||||||
|
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
|
||||||
|
|
||||||
|
@ -126,3 +126,4 @@ ANALYTICS_BASE_API = False
|
|||||||
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
|
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
|
||||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
|
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)
|
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||||
|
|
||||||
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
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
|
sorted_data = grouped_data
|
||||||
if temp_axis == "priority":
|
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}
|
sorted_data = {key: grouped_data[key] for key in order if key in grouped_data}
|
||||||
else:
|
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
|
return sorted_data
|
||||||
|
|
||||||
|
|
||||||
|
@ -40,9 +40,6 @@ def filter_priority(params, filter, method):
|
|||||||
priorities = params.get("priority").split(",")
|
priorities = params.get("priority").split(",")
|
||||||
if len(priorities) and "" not in priorities:
|
if len(priorities) and "" not in priorities:
|
||||||
filter["priority__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
|
return filter
|
||||||
|
|
||||||
|
|
||||||
@ -166,17 +163,17 @@ def filter_target_date(params, filter, method):
|
|||||||
for query in target_dates:
|
for query in target_dates:
|
||||||
target_date_query = query.split(";")
|
target_date_query = query.split(";")
|
||||||
if len(target_date_query) == 2 and "after" in target_date_query:
|
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:
|
else:
|
||||||
filter["target_date__lt"] = target_date_query[0]
|
filter["target_date__lte"] = target_date_query[0]
|
||||||
else:
|
else:
|
||||||
if params.get("target_date", None) and len(params.get("target_date")):
|
if params.get("target_date", None) and len(params.get("target_date")):
|
||||||
for query in params.get("target_date"):
|
for query in params.get("target_date"):
|
||||||
target_date_query = query.split(";")
|
target_date_query = query.split(";")
|
||||||
if len(target_date_query) == 2 and "after" in target_date_query:
|
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:
|
else:
|
||||||
filter["target_date__lt"] = target_date_query[0]
|
filter["target_date__lte"] = target_date_query[0]
|
||||||
|
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
@ -1,113 +1,61 @@
|
|||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
x-api-and-worker-env:
|
|
||||||
&api-and-worker-env
|
|
||||||
DEBUG: ${DEBUG}
|
|
||||||
SENTRY_DSN: ${SENTRY_DSN}
|
|
||||||
DJANGO_SETTINGS_MODULE: plane.settings.selfhosted
|
|
||||||
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:
|
services:
|
||||||
plane-web:
|
web:
|
||||||
container_name: planefrontend
|
container_name: web
|
||||||
image: makeplane/plane-frontend:latest
|
image: makeplane/plane-frontend:latest
|
||||||
restart: always
|
restart: always
|
||||||
command: /usr/local/bin/start.sh web/server.js web
|
command: /usr/local/bin/start.sh web/server.js web
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- ./web/.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:
|
depends_on:
|
||||||
- plane-api
|
- api
|
||||||
- plane-worker
|
- worker
|
||||||
|
|
||||||
plane-deploy:
|
space:
|
||||||
container_name: planedeploy
|
container_name: space
|
||||||
image: makeplane/plane-deploy:latest
|
image: makeplane/plane-space:latest
|
||||||
restart: always
|
restart: always
|
||||||
command: /usr/local/bin/start.sh space/server.js space
|
command: /usr/local/bin/start.sh space/server.js space
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- ./space/.env
|
||||||
environment:
|
|
||||||
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- plane-api
|
- api
|
||||||
- plane-worker
|
- worker
|
||||||
- plane-web
|
- web
|
||||||
|
|
||||||
plane-api:
|
api:
|
||||||
container_name: planebackend
|
container_name: api
|
||||||
image: makeplane/plane-backend:latest
|
image: makeplane/plane-backend:latest
|
||||||
restart: always
|
restart: always
|
||||||
command: ./bin/takeoff
|
command: ./bin/takeoff
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- ./apiserver/.env
|
||||||
environment:
|
|
||||||
<<: *api-and-worker-env
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- plane-db
|
- plane-db
|
||||||
- plane-redis
|
- plane-redis
|
||||||
|
|
||||||
plane-worker:
|
worker:
|
||||||
container_name: planebgworker
|
container_name: bgworker
|
||||||
image: makeplane/plane-backend:latest
|
image: makeplane/plane-backend:latest
|
||||||
restart: always
|
restart: always
|
||||||
command: ./bin/worker
|
command: ./bin/worker
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- ./apiserver/.env
|
||||||
environment:
|
|
||||||
<<: *api-and-worker-env
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- plane-api
|
- api
|
||||||
- plane-db
|
- plane-db
|
||||||
- plane-redis
|
- plane-redis
|
||||||
|
|
||||||
plane-beat-worker:
|
beat-worker:
|
||||||
container_name: planebeatworker
|
container_name: beatworker
|
||||||
image: makeplane/plane-backend:latest
|
image: makeplane/plane-backend:latest
|
||||||
restart: always
|
restart: always
|
||||||
command: ./bin/beat
|
command: ./bin/beat
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- ./apiserver/.env
|
||||||
environment:
|
|
||||||
<<: *api-and-worker-env
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- plane-api
|
- api
|
||||||
- plane-db
|
- plane-db
|
||||||
- plane-redis
|
- plane-redis
|
||||||
|
|
||||||
@ -157,8 +105,8 @@ services:
|
|||||||
- plane-minio
|
- plane-minio
|
||||||
|
|
||||||
# Comment this if you already have a reverse proxy running
|
# Comment this if you already have a reverse proxy running
|
||||||
plane-proxy:
|
proxy:
|
||||||
container_name: planeproxy
|
container_name: proxy
|
||||||
image: makeplane/plane-proxy:latest
|
image: makeplane/plane-proxy:latest
|
||||||
ports:
|
ports:
|
||||||
- ${NGINX_PORT}:80
|
- ${NGINX_PORT}:80
|
||||||
@ -168,8 +116,9 @@ services:
|
|||||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
||||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||||
depends_on:
|
depends_on:
|
||||||
- plane-web
|
- web
|
||||||
- plane-api
|
- api
|
||||||
|
- space
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
plane-web:
|
web:
|
||||||
container_name: planefrontend
|
container_name: web
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./web/Dockerfile.web
|
dockerfile: ./web/Dockerfile.web
|
||||||
@ -11,11 +11,11 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
command: /usr/local/bin/start.sh web/server.js web
|
command: /usr/local/bin/start.sh web/server.js web
|
||||||
depends_on:
|
depends_on:
|
||||||
- plane-api
|
- api
|
||||||
- plane-worker
|
- worker
|
||||||
|
|
||||||
plane-deploy:
|
space:
|
||||||
container_name: planedeploy
|
container_name: space
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./space/Dockerfile.space
|
dockerfile: ./space/Dockerfile.space
|
||||||
@ -24,12 +24,12 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
command: /usr/local/bin/start.sh space/server.js space
|
command: /usr/local/bin/start.sh space/server.js space
|
||||||
depends_on:
|
depends_on:
|
||||||
- plane-api
|
- api
|
||||||
- plane-worker
|
- worker
|
||||||
- plane-web
|
- web
|
||||||
|
|
||||||
plane-api:
|
api:
|
||||||
container_name: planebackend
|
container_name: api
|
||||||
build:
|
build:
|
||||||
context: ./apiserver
|
context: ./apiserver
|
||||||
dockerfile: Dockerfile.api
|
dockerfile: Dockerfile.api
|
||||||
@ -43,8 +43,8 @@ services:
|
|||||||
- plane-db
|
- plane-db
|
||||||
- plane-redis
|
- plane-redis
|
||||||
|
|
||||||
plane-worker:
|
worker:
|
||||||
container_name: planebgworker
|
container_name: bgworker
|
||||||
build:
|
build:
|
||||||
context: ./apiserver
|
context: ./apiserver
|
||||||
dockerfile: Dockerfile.api
|
dockerfile: Dockerfile.api
|
||||||
@ -55,12 +55,12 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ./apiserver/.env
|
- ./apiserver/.env
|
||||||
depends_on:
|
depends_on:
|
||||||
- plane-api
|
- api
|
||||||
- plane-db
|
- plane-db
|
||||||
- plane-redis
|
- plane-redis
|
||||||
|
|
||||||
plane-beat-worker:
|
beat-worker:
|
||||||
container_name: planebeatworker
|
container_name: beatworker
|
||||||
build:
|
build:
|
||||||
context: ./apiserver
|
context: ./apiserver
|
||||||
dockerfile: Dockerfile.api
|
dockerfile: Dockerfile.api
|
||||||
@ -71,7 +71,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ./apiserver/.env
|
- ./apiserver/.env
|
||||||
depends_on:
|
depends_on:
|
||||||
- plane-api
|
- api
|
||||||
- plane-db
|
- plane-db
|
||||||
- plane-redis
|
- plane-redis
|
||||||
|
|
||||||
@ -118,8 +118,8 @@ services:
|
|||||||
- plane-minio
|
- plane-minio
|
||||||
|
|
||||||
# Comment this if you already have a reverse proxy running
|
# Comment this if you already have a reverse proxy running
|
||||||
plane-proxy:
|
proxy:
|
||||||
container_name: planeproxy
|
container_name: proxy
|
||||||
build:
|
build:
|
||||||
context: ./nginx
|
context: ./nginx
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@ -130,8 +130,9 @@ services:
|
|||||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
||||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||||
depends_on:
|
depends_on:
|
||||||
- plane-web
|
- web
|
||||||
- plane-api
|
- api
|
||||||
|
- space
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
@ -1,25 +1,32 @@
|
|||||||
events { }
|
events {
|
||||||
|
}
|
||||||
|
|
||||||
http {
|
http {
|
||||||
sendfile on;
|
sendfile on;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
root /www/data/;
|
root /www/data/;
|
||||||
access_log /var/log/nginx/access.log;
|
access_log /var/log/nginx/access.log;
|
||||||
|
|
||||||
client_max_body_size ${FILE_SIZE_LIMIT};
|
client_max_body_size ${FILE_SIZE_LIMIT};
|
||||||
|
|
||||||
|
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 / {
|
location / {
|
||||||
proxy_pass http://planefrontend:3000/;
|
proxy_pass http://web:3000/;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://planebackend:8000/api/;
|
proxy_pass http://api:8000/api/;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /spaces/ {
|
location /spaces/ {
|
||||||
proxy_pass http://planedeploy:3000/spaces/;
|
rewrite ^/spaces/?$ /spaces/login break;
|
||||||
|
proxy_pass http://space:3000/spaces/;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /${BUCKET_NAME}/ {
|
location /${BUCKET_NAME}/ {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"repository": "https://github.com/makeplane/plane.git",
|
"repository": "https://github.com/makeplane/plane.git",
|
||||||
|
"version": "0.13.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "eslint-config-custom",
|
"name": "eslint-config-custom",
|
||||||
"version": "0.0.0",
|
"version": "0.13.2",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tailwind-config-custom",
|
"name": "tailwind-config-custom",
|
||||||
"version": "0.0.1",
|
"version": "0.13.2",
|
||||||
"description": "common tailwind configuration across monorepo",
|
"description": "common tailwind configuration across monorepo",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -7,7 +7,7 @@ module.exports = {
|
|||||||
"./constants/**/*.{js,ts,jsx,tsx}",
|
"./constants/**/*.{js,ts,jsx,tsx}",
|
||||||
"./layouts/**/*.tsx",
|
"./layouts/**/*.tsx",
|
||||||
"./pages/**/*.tsx",
|
"./pages/**/*.tsx",
|
||||||
"./ui/**/*.tsx",
|
"../packages/ui/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tsconfig",
|
"name": "tsconfig",
|
||||||
"version": "0.0.0",
|
"version": "0.13.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"files": [
|
"files": [
|
||||||
"base.json",
|
"base.json",
|
||||||
|
@ -4,8 +4,9 @@
|
|||||||
"extends": "./base.json",
|
"extends": "./base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"lib": ["ES2015"],
|
"lib": ["ES2015", "DOM"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"target": "es6"
|
"target": "es6",
|
||||||
|
"sourceMap": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1
packages/ui/README.md
Normal file
1
packages/ui/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# UI Package
|
@ -1,3 +0,0 @@
|
|||||||
export const Button = () => {
|
|
||||||
return <button>button</button>;
|
|
||||||
};
|
|
@ -1,17 +0,0 @@
|
|||||||
// import * as React from "react";
|
|
||||||
// components
|
|
||||||
// export * from "./breadcrumbs";
|
|
||||||
// export * from "./button";
|
|
||||||
// export * from "./custom-listbox";
|
|
||||||
// export * from "./custom-menu";
|
|
||||||
// export * from "./custom-select";
|
|
||||||
// export * from "./empty-space";
|
|
||||||
// export * from "./header-button";
|
|
||||||
// export * from "./input";
|
|
||||||
// export * from "./loader";
|
|
||||||
// export * from "./outline-button";
|
|
||||||
// export * from "./select";
|
|
||||||
// export * from "./spinner";
|
|
||||||
// export * from "./text-area";
|
|
||||||
// export * from "./tooltip";
|
|
||||||
export * from "./button";
|
|
@ -1,23 +1,40 @@
|
|||||||
{
|
{
|
||||||
"name": "ui",
|
"name": "@plane/ui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.1",
|
||||||
"main": "./index.tsx",
|
"main": "./dist/index.js",
|
||||||
"types": "./index.tsx",
|
"module": "./dist/index.mjs",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"sideEffects": false,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"files": [
|
||||||
|
"dist/**"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint *.ts*"
|
"build": "tsup src/index.tsx --format esm,cjs --dts --external react",
|
||||||
|
"dev": "tsup src/index.tsx --format esm,cjs --watch --dts --external react",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.0.17",
|
"@types/node": "^20.5.2",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react": "18.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
"@types/react-dom": "18.2.0",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
"eslint": "^7.32.0",
|
|
||||||
"eslint-config-custom": "*",
|
"eslint-config-custom": "*",
|
||||||
"next": "12.3.2",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"tsconfig": "*",
|
"tsconfig": "*",
|
||||||
"tailwind-config-custom": "*",
|
"tailwind-config-custom": "*",
|
||||||
|
"tsup": "^5.10.1",
|
||||||
"typescript": "4.7.4"
|
"typescript": "4.7.4"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@blueprintjs/core": "^4.16.3",
|
||||||
|
"@blueprintjs/popover2": "^1.13.3",
|
||||||
|
"@headlessui/react": "^1.7.17",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"next-themes": "^0.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
66
packages/ui/src/button/button.tsx
Normal file
66
packages/ui/src/button/button.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getIconStyling,
|
||||||
|
getButtonStyling,
|
||||||
|
TButtonVariant,
|
||||||
|
TButtonSizes,
|
||||||
|
} from "./helper";
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: TButtonVariant;
|
||||||
|
size?: TButtonSizes;
|
||||||
|
className?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
appendIcon?: any;
|
||||||
|
prependIcon?: any;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
className = "",
|
||||||
|
type = "button",
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
prependIcon = null,
|
||||||
|
appendIcon = null,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const buttonStyle = getButtonStyling(variant, size, disabled || loading);
|
||||||
|
const buttonIconStyle = getIconStyling(size);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={`${buttonStyle} ${className}`}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{prependIcon && (
|
||||||
|
<div className={buttonIconStyle}>
|
||||||
|
{React.cloneElement(prependIcon, { "stroke-width": 2 })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{appendIcon && (
|
||||||
|
<div className={buttonIconStyle}>
|
||||||
|
{React.cloneElement(appendIcon, { "stroke-width": 2 })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = "plane-ui-button";
|
||||||
|
|
||||||
|
export { Button };
|
123
packages/ui/src/button/helper.tsx
Normal file
123
packages/ui/src/button/helper.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
export type TButtonVariant =
|
||||||
|
| "primary"
|
||||||
|
| "accent-primary"
|
||||||
|
| "outline-primary"
|
||||||
|
| "neutral-primary"
|
||||||
|
| "link-primary"
|
||||||
|
| "danger"
|
||||||
|
| "accent-danger"
|
||||||
|
| "outline-danger"
|
||||||
|
| "link-danger"
|
||||||
|
| "tertiary-danger";
|
||||||
|
|
||||||
|
export type TButtonSizes = "sm" | "md" | "lg" | "xl";
|
||||||
|
|
||||||
|
export interface IButtonStyling {
|
||||||
|
[key: string]: {
|
||||||
|
default: string;
|
||||||
|
hover: string;
|
||||||
|
pressed: string;
|
||||||
|
disabled: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
enum buttonSizeStyling {
|
||||||
|
sm = `px-3 py-1.5 font-medium text-xs rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
|
||||||
|
md = `px-4 py-1.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
|
||||||
|
lg = `px-5 py-2 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
|
||||||
|
xl = `px-5 py-3.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum buttonIconStyling {
|
||||||
|
sm = "h-3 w-3 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
|
||||||
|
md = "h-3.5 w-3.5 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
|
||||||
|
lg = "h-4 w-4 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
|
||||||
|
xl = "h-4 w-4 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buttonStyling: IButtonStyling = {
|
||||||
|
primary: {
|
||||||
|
default: `text-white bg-custom-primary-100`,
|
||||||
|
hover: `hover:bg-custom-primary-200`,
|
||||||
|
pressed: `focus:text-custom-brand-40 focus:bg-custom-primary-200`,
|
||||||
|
disabled: `cursor-not-allowed !bg-custom-primary-60 hover:bg-custom-primary-60`,
|
||||||
|
},
|
||||||
|
"accent-primary": {
|
||||||
|
default: `bg-custom-primary-10 text-custom-primary-100`,
|
||||||
|
hover: `hover:bg-custom-primary-20 hover:text-custom-primary-200`,
|
||||||
|
pressed: `focus:bg-custom-primary-20`,
|
||||||
|
disabled: `cursor-not-allowed !text-custom-primary-60`,
|
||||||
|
},
|
||||||
|
"outline-primary": {
|
||||||
|
default: `text-custom-primary-100 bg-custom-background-100 border border-custom-primary-100`,
|
||||||
|
hover: `hover:border-custom-primary-80 hover:bg-custom-primary-10`,
|
||||||
|
pressed: `focus:text-custom-primary-80 focus:bg-custom-primary-10 focus:border-custom-primary-80`,
|
||||||
|
disabled: `cursor-not-allowed !text-custom-primary-60 !border-custom-primary-60 `,
|
||||||
|
},
|
||||||
|
"neutral-primary": {
|
||||||
|
default: `text-custom-text-200 bg-custom-background-100 border border-custom-border-200`,
|
||||||
|
hover: `hover:bg-custom-background-90`,
|
||||||
|
pressed: `focus:text-custom-text-300 focus:bg-custom-background-90`,
|
||||||
|
disabled: `cursor-not-allowed !text-custom-text-400`,
|
||||||
|
},
|
||||||
|
"link-primary": {
|
||||||
|
default: `text-custom-primary-100 bg-custom-background-100`,
|
||||||
|
hover: `hover:text-custom-primary-200`,
|
||||||
|
pressed: `focus:text-custom-primary-80 `,
|
||||||
|
disabled: `cursor-not-allowed !text-custom-primary-60`,
|
||||||
|
},
|
||||||
|
|
||||||
|
danger: {
|
||||||
|
default: `text-white bg-red-500`,
|
||||||
|
hover: ` hover:bg-red-600`,
|
||||||
|
pressed: `focus:text-red-200 focus:bg-red-600`,
|
||||||
|
disabled: `cursor-not-allowed !bg-red-300`,
|
||||||
|
},
|
||||||
|
"accent-danger": {
|
||||||
|
default: `text-red-500 bg-red-50`,
|
||||||
|
hover: `hover:text-red-600 hover:bg-red-100`,
|
||||||
|
pressed: `focus:text-red-500 focus:bg-red-100`,
|
||||||
|
disabled: `cursor-not-allowed !text-red-300`,
|
||||||
|
},
|
||||||
|
"outline-danger": {
|
||||||
|
default: `text-red-500 bg-custom-background-100 border border-red-500`,
|
||||||
|
hover: `hover:text-red-400 hover:border-red-400`,
|
||||||
|
pressed: `focus:text-red-400 focus:border-red-400`,
|
||||||
|
disabled: `cursor-not-allowed !text-red-300 !border-red-300`,
|
||||||
|
},
|
||||||
|
"link-danger": {
|
||||||
|
default: `text-red-500 bg-custom-background-100`,
|
||||||
|
hover: `hover:text-red-400`,
|
||||||
|
pressed: `focus:text-red-400`,
|
||||||
|
disabled: `cursor-not-allowed !text-red-300`,
|
||||||
|
},
|
||||||
|
"tertiary-danger": {
|
||||||
|
default: `text-red-500 bg-custom-background-100 border border-red-200`,
|
||||||
|
hover: `hover:bg-red-50 hover:border-red-300`,
|
||||||
|
pressed: `focus:text-red-400`,
|
||||||
|
disabled: `cursor-not-allowed !text-red-300`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getButtonStyling = (
|
||||||
|
variant: TButtonVariant,
|
||||||
|
size: TButtonSizes,
|
||||||
|
disabled: boolean = false
|
||||||
|
): string => {
|
||||||
|
let _variant: string = ``;
|
||||||
|
const currentVariant = buttonStyling[variant];
|
||||||
|
|
||||||
|
_variant = `${currentVariant.default} ${
|
||||||
|
disabled ? currentVariant.disabled : currentVariant.hover
|
||||||
|
} ${currentVariant.pressed}`;
|
||||||
|
|
||||||
|
let _size: string = ``;
|
||||||
|
if (size) _size = buttonSizeStyling[size];
|
||||||
|
return `${_variant} ${_size}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getIconStyling = (size: TButtonSizes): string => {
|
||||||
|
let icon: string = ``;
|
||||||
|
if (size) icon = buttonIconStyling[size];
|
||||||
|
return icon;
|
||||||
|
};
|
2
packages/ui/src/button/index.tsx
Normal file
2
packages/ui/src/button/index.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./button";
|
||||||
|
export * from "./toggle-switch";
|
49
packages/ui/src/button/toggle-switch.tsx
Normal file
49
packages/ui/src/button/toggle-switch.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { Switch } from "@headlessui/react";
|
||||||
|
|
||||||
|
interface IToggleSwitchProps {
|
||||||
|
value: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
label?: string;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToggleSwitch: React.FC<IToggleSwitchProps> = (props) => {
|
||||||
|
const { value, onChange, label, size = "sm", disabled, className } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={onChange}
|
||||||
|
className={`relative flex-shrink-0 inline-flex ${
|
||||||
|
size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10"
|
||||||
|
} flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||||
|
value ? "bg-custom-primary-100" : "bg-gray-700"
|
||||||
|
} ${className || ""} ${disabled ? "cursor-not-allowed" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`self-center inline-block ${
|
||||||
|
size === "sm" ? "h-2 w-2" : size === "md" ? "h-3 w-3" : "h-4 w-4"
|
||||||
|
} transform rounded-full shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
value
|
||||||
|
? (size === "sm"
|
||||||
|
? "translate-x-3"
|
||||||
|
: size === "md"
|
||||||
|
? "translate-x-4"
|
||||||
|
: "translate-x-5") + " bg-white"
|
||||||
|
: "translate-x-0.5 bg-custom-background-90"
|
||||||
|
} ${disabled ? "cursor-not-allowed" : ""}`}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ToggleSwitch.displayName = "plane-ui-toggle-switch";
|
||||||
|
|
||||||
|
export { ToggleSwitch };
|
2
packages/ui/src/form-fields/index.tsx
Normal file
2
packages/ui/src/form-fields/index.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./input";
|
||||||
|
export * from "./textarea";
|
49
packages/ui/src/form-fields/input.tsx
Normal file
49
packages/ui/src/form-fields/input.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
mode?: "primary" | "transparent" | "true-transparent";
|
||||||
|
inputSize?: "sm" | "md";
|
||||||
|
hasError?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
mode = "primary",
|
||||||
|
inputSize = "sm",
|
||||||
|
hasError = false,
|
||||||
|
className = "",
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
name={name}
|
||||||
|
className={`block rounded-md bg-transparent text-sm focus:outline-none placeholder-custom-text-400 ${
|
||||||
|
mode === "primary"
|
||||||
|
? "rounded-md border border-custom-border-200"
|
||||||
|
: mode === "transparent"
|
||||||
|
? "rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary"
|
||||||
|
: mode === "true-transparent"
|
||||||
|
? "rounded border-none bg-transparent ring-0"
|
||||||
|
: ""
|
||||||
|
} ${hasError ? "border-red-500" : ""} ${
|
||||||
|
hasError && mode === "primary" ? "bg-red-500/20" : ""
|
||||||
|
} ${
|
||||||
|
inputSize === "sm" ? "px-3 py-2" : inputSize === "md" ? "p-3" : ""
|
||||||
|
} ${className}`}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Input.displayName = "form-input-field";
|
||||||
|
|
||||||
|
export { Input };
|
69
packages/ui/src/form-fields/textarea.tsx
Normal file
69
packages/ui/src/form-fields/textarea.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export interface TextAreaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
mode?: "primary" | "transparent";
|
||||||
|
hasError?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates the height of a <textarea> when the value changes.
|
||||||
|
const useAutoSizeTextArea = (
|
||||||
|
textAreaRef: HTMLTextAreaElement | null,
|
||||||
|
value: any
|
||||||
|
) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (textAreaRef) {
|
||||||
|
// We need to reset the height momentarily to get the correct scrollHeight for the textarea
|
||||||
|
textAreaRef.style.height = "0px";
|
||||||
|
const scrollHeight = textAreaRef.scrollHeight;
|
||||||
|
|
||||||
|
// We then set the height directly, outside of the render loop
|
||||||
|
// Trying to set this with state or a ref will product an incorrect value.
|
||||||
|
textAreaRef.style.height = scrollHeight + "px";
|
||||||
|
}
|
||||||
|
}, [textAreaRef, value]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
value = "",
|
||||||
|
rows = 1,
|
||||||
|
cols = 1,
|
||||||
|
mode = "primary",
|
||||||
|
hasError = false,
|
||||||
|
className = "",
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const textAreaRef = React.useRef<any>(ref);
|
||||||
|
|
||||||
|
ref && useAutoSizeTextArea(textAreaRef?.current, value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
ref={textAreaRef}
|
||||||
|
value={value}
|
||||||
|
rows={rows}
|
||||||
|
cols={cols}
|
||||||
|
className={`no-scrollbar w-full bg-transparent placeholder-custom-text-400 px-3 py-2 outline-none ${
|
||||||
|
mode === "primary"
|
||||||
|
? "rounded-md border border-custom-border-200"
|
||||||
|
: mode === "transparent"
|
||||||
|
? "rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-theme"
|
||||||
|
: ""
|
||||||
|
} ${hasError ? "border-red-500" : ""} ${
|
||||||
|
hasError && mode === "primary" ? "bg-red-100" : ""
|
||||||
|
} ${className}`}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export { TextArea };
|
6
packages/ui/src/index.tsx
Normal file
6
packages/ui/src/index.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from "./button";
|
||||||
|
export * from "./form-fields";
|
||||||
|
export * from "./progress";
|
||||||
|
export * from "./spinners";
|
||||||
|
export * from "./loader";
|
||||||
|
export * from "./tooltip";
|
30
packages/ui/src/loader.tsx
Normal file
30
packages/ui/src/loader.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Loader = ({ children, className = "" }: Props) => (
|
||||||
|
<div className={`${className} animate-pulse`} role="status">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
type ItemProps = {
|
||||||
|
height?: string;
|
||||||
|
width?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Item: React.FC<ItemProps> = ({ height = "auto", width = "auto" }) => (
|
||||||
|
<div
|
||||||
|
className="rounded-md bg-custom-background-80"
|
||||||
|
style={{ height: height, width: width }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
Loader.Item = Item;
|
||||||
|
|
||||||
|
Loader.displayName = "plane-ui-loader";
|
||||||
|
|
||||||
|
export { Loader };
|
3
packages/ui/src/progress/index.tsx
Normal file
3
packages/ui/src/progress/index.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./radial-progress";
|
||||||
|
export * from "./progress-bar";
|
||||||
|
export * from "./linear-progress-indicator";
|
44
packages/ui/src/progress/linear-progress-indicator.tsx
Normal file
44
packages/ui/src/progress/linear-progress-indicator.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Tooltip } from "../tooltip";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: any;
|
||||||
|
noTooltip?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LinearProgressIndicator: React.FC<Props> = ({
|
||||||
|
data,
|
||||||
|
noTooltip = false,
|
||||||
|
}) => {
|
||||||
|
const total = data.reduce((acc: any, cur: any) => acc + cur.value, 0);
|
||||||
|
let progress = 0;
|
||||||
|
|
||||||
|
const bars = data.map((item: any) => {
|
||||||
|
const width = `${(item.value / total) * 100}%`;
|
||||||
|
const style = {
|
||||||
|
width,
|
||||||
|
backgroundColor: item.color,
|
||||||
|
};
|
||||||
|
progress += item.value;
|
||||||
|
if (noTooltip) return <div style={style} />;
|
||||||
|
else
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={item.id}
|
||||||
|
tooltipContent={`${item.name} ${Math.round(item.value)}%`}
|
||||||
|
>
|
||||||
|
<div style={style} />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-1 w-full items-center justify-between gap-1">
|
||||||
|
{total === 0 ? (
|
||||||
|
<div className="flex h-full w-full gap-1 bg-neutral-500">{bars}</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full gap-1">{bars}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
77
packages/ui/src/progress/progress-bar.tsx
Normal file
77
packages/ui/src/progress/progress-bar.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
maxValue?: number;
|
||||||
|
value?: number;
|
||||||
|
radius?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
activeStrokeColor?: string;
|
||||||
|
inactiveStrokeColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProgressBar: React.FC<Props> = ({
|
||||||
|
maxValue = 0,
|
||||||
|
value = 0,
|
||||||
|
radius = 8,
|
||||||
|
strokeWidth = 2,
|
||||||
|
activeStrokeColor = "#3e98c7",
|
||||||
|
inactiveStrokeColor = "#ddd",
|
||||||
|
}) => {
|
||||||
|
// PIE Calc Fn
|
||||||
|
const generatePie = (value: any) => {
|
||||||
|
const x = radius - Math.cos((2 * Math.PI) / (100 / value)) * radius;
|
||||||
|
const y = radius + Math.sin((2 * Math.PI) / (100 / value)) * radius;
|
||||||
|
const long = value <= 50 ? 0 : 1;
|
||||||
|
const d = `M${radius} ${radius} L${radius} ${0} A${radius} ${radius} 0 ${long} 1 ${y} ${x} Z`;
|
||||||
|
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- PIE Area Calc --------
|
||||||
|
const calculatePieValue = (numberOfBars: any) => {
|
||||||
|
const angle = 360 / numberOfBars;
|
||||||
|
const pieValue = Math.floor(angle / 4);
|
||||||
|
return pieValue < 1 ? 1 : Math.floor(angle / 4);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- PIE Render Fn --------
|
||||||
|
const renderPie = (i: any) => {
|
||||||
|
const DIRECTION = -1;
|
||||||
|
// Rotation Calc
|
||||||
|
const primaryRotationAngle = (maxValue - 1) * (360 / maxValue);
|
||||||
|
const rotationAngle =
|
||||||
|
-1 * DIRECTION * primaryRotationAngle +
|
||||||
|
i * DIRECTION * primaryRotationAngle;
|
||||||
|
const rotationTransformation = `rotate(${rotationAngle}, ${radius}, ${radius})`;
|
||||||
|
const pieValue = calculatePieValue(maxValue);
|
||||||
|
const dValue = generatePie(pieValue);
|
||||||
|
const fillColor =
|
||||||
|
value > 0 && i <= value ? activeStrokeColor : inactiveStrokeColor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
style={{ opacity: i === 0 ? 0 : 1 }}
|
||||||
|
key={i}
|
||||||
|
d={dValue}
|
||||||
|
fill={fillColor}
|
||||||
|
transform={rotationTransformation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// combining the Pies
|
||||||
|
const renderOuterCircle = () =>
|
||||||
|
[...Array(maxValue + 1)].map((e, i) => renderPie(i));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={radius * 2} height={radius * 2}>
|
||||||
|
{renderOuterCircle()}
|
||||||
|
<circle
|
||||||
|
r={radius - strokeWidth}
|
||||||
|
cx={radius}
|
||||||
|
cy={radius}
|
||||||
|
className="progress-bar"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
45
packages/ui/src/progress/radial-progress.tsx
Normal file
45
packages/ui/src/progress/radial-progress.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import React, { useState, useEffect, FC } from "react";
|
||||||
|
|
||||||
|
interface IRadialProgressBar {
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RadialProgressBar: FC<IRadialProgressBar> = (props) => {
|
||||||
|
const { progress } = props;
|
||||||
|
const [circumference, setCircumference] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const radius = 40;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
setCircumference(circumference);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const progressOffset = ((100 - progress) / 100) * circumference;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-4 w-4">
|
||||||
|
<svg className="absolute top-0 left-0" viewBox="0 0 100 100">
|
||||||
|
<circle
|
||||||
|
className={"stroke-current opacity-10"}
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="40"
|
||||||
|
strokeWidth="12"
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={`${circumference} ${circumference}`}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className={`stroke-current`}
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="40"
|
||||||
|
strokeWidth="12"
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={`${circumference} ${circumference}`}
|
||||||
|
strokeDashoffset={progressOffset}
|
||||||
|
transform="rotate(-90 50 50)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
23
packages/ui/src/spinners/circular-spinner.tsx
Normal file
23
packages/ui/src/spinners/circular-spinner.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export const Spinner: React.FC = () => (
|
||||||
|
<div role="status">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mr-2 h-8 w-8 animate-spin fill-blue-600 text-custom-text-200"
|
||||||
|
viewBox="0 0 100 101"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||||
|
fill="currentFill"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
);
|
1
packages/ui/src/spinners/index.ts
Normal file
1
packages/ui/src/spinners/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./circular-spinner";
|
1
packages/ui/src/tooltip/index.tsx
Normal file
1
packages/ui/src/tooltip/index.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./tooltip";
|
86
packages/ui/src/tooltip/tooltip.tsx
Normal file
86
packages/ui/src/tooltip/tooltip.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// next-themes
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Tooltip2 } from "@blueprintjs/popover2";
|
||||||
|
|
||||||
|
export type TPosition =
|
||||||
|
| "top"
|
||||||
|
| "right"
|
||||||
|
| "bottom"
|
||||||
|
| "left"
|
||||||
|
| "auto"
|
||||||
|
| "auto-end"
|
||||||
|
| "auto-start"
|
||||||
|
| "bottom-left"
|
||||||
|
| "bottom-right"
|
||||||
|
| "left-bottom"
|
||||||
|
| "left-top"
|
||||||
|
| "right-bottom"
|
||||||
|
| "right-top"
|
||||||
|
| "top-left"
|
||||||
|
| "top-right";
|
||||||
|
|
||||||
|
interface ITooltipProps {
|
||||||
|
tooltipHeading?: string;
|
||||||
|
tooltipContent: string | React.ReactNode;
|
||||||
|
position?: TPosition;
|
||||||
|
children: JSX.Element;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
openDelay?: number;
|
||||||
|
closeDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tooltip: React.FC<ITooltipProps> = ({
|
||||||
|
tooltipHeading,
|
||||||
|
tooltipContent,
|
||||||
|
position = "top",
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
openDelay = 200,
|
||||||
|
closeDelay,
|
||||||
|
}) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip2
|
||||||
|
disabled={disabled}
|
||||||
|
hoverOpenDelay={openDelay}
|
||||||
|
hoverCloseDelay={closeDelay}
|
||||||
|
content={
|
||||||
|
<div
|
||||||
|
className={`relative z-50 max-w-xs gap-1 rounded-md p-2 text-xs shadow-md ${
|
||||||
|
theme === "custom"
|
||||||
|
? "bg-custom-background-100 text-custom-text-200"
|
||||||
|
: "bg-black text-gray-400"
|
||||||
|
} break-words overflow-hidden ${className}`}
|
||||||
|
>
|
||||||
|
{tooltipHeading && (
|
||||||
|
<h5
|
||||||
|
className={`font-medium ${
|
||||||
|
theme === "custom" ? "text-custom-text-100" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tooltipHeading}
|
||||||
|
</h5>
|
||||||
|
)}
|
||||||
|
{tooltipContent}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
position={position}
|
||||||
|
renderTarget={({
|
||||||
|
isOpen: isTooltipOpen,
|
||||||
|
ref: eleReference,
|
||||||
|
...tooltipProps
|
||||||
|
}) =>
|
||||||
|
React.cloneElement(children, {
|
||||||
|
ref: eleReference,
|
||||||
|
...tooltipProps,
|
||||||
|
...children.props,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "tsconfig/react-library.json",
|
"extends": "tsconfig/react-library.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react"
|
||||||
|
},
|
||||||
"include": ["."],
|
"include": ["."],
|
||||||
"exclude": ["dist", "build", "node_modules"]
|
"exclude": ["dist", "build", "node_modules"]
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,2 @@
|
|||||||
# Google Client ID for Google OAuth
|
|
||||||
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
|
||||||
# Flag to toggle OAuth
|
# Flag to toggle OAuth
|
||||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
NEXT_PUBLIC_ENABLE_OAUTH=0
|
@ -7,7 +7,8 @@ import userService from "services/user.service";
|
|||||||
// hooks
|
// hooks
|
||||||
// import useToast from "hooks/use-toast";
|
// import useToast from "hooks/use-toast";
|
||||||
// ui
|
// ui
|
||||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
import { Input } from "components/ui";
|
||||||
|
import { Button } from "@plane/ui";
|
||||||
// types
|
// types
|
||||||
type Props = {
|
type Props = {
|
||||||
setIsResettingPassword: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsResettingPassword: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@ -77,12 +78,12 @@ export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword
|
|||||||
{errors.email && <div className="text-sm text-red-500">{errors.email.message}</div>}
|
{errors.email && <div className="text-sm text-red-500">{errors.email.message}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex flex-col-reverse sm:flex-row items-center gap-2">
|
<div className="mt-5 flex flex-col-reverse sm:flex-row items-center gap-2">
|
||||||
<SecondaryButton className="w-full text-center h-[46px]" onClick={() => setIsResettingPassword(false)}>
|
<Button variant="neutral-primary" className="w-full" onClick={() => setIsResettingPassword(false)}>
|
||||||
Go Back
|
Go Back
|
||||||
</SecondaryButton>
|
</Button>
|
||||||
<PrimaryButton type="submit" className="w-full text-center h-[46px]" loading={isSubmitting}>
|
<Button variant="primary" className="w-full" type="submit" loading={isSubmitting}>
|
||||||
{isSubmitting ? "Sending link..." : "Send reset link"}
|
{isSubmitting ? "Sending link..." : "Send reset link"}
|
||||||
</PrimaryButton>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
@ -10,9 +10,12 @@ import githubWhiteImage from "public/logos/github-white.svg";
|
|||||||
|
|
||||||
export interface GithubLoginButtonProps {
|
export interface GithubLoginButtonProps {
|
||||||
handleSignIn: React.Dispatch<string>;
|
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 [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||||
const [gitCode, setGitCode] = useState<null | string>(null);
|
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">
|
<div className="w-full flex justify-center items-center">
|
||||||
<Link
|
<Link
|
||||||
className="w-full"
|
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]">
|
<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
|
<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";
|
import Script from "next/script";
|
||||||
|
|
||||||
export interface IGoogleLoginButton {
|
export interface IGoogleLoginButton {
|
||||||
text?: string;
|
clientId: string;
|
||||||
handleSignIn: React.Dispatch<any>;
|
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);
|
const googleSignInButton = useRef<HTMLDivElement>(null);
|
||||||
|
// states
|
||||||
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
||||||
|
|
||||||
const loadScript = useCallback(() => {
|
const loadScript = useCallback(() => {
|
||||||
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
||||||
|
|
||||||
(window as any)?.google?.accounts.id.initialize({
|
(window as any)?.google?.accounts.id.initialize({
|
||||||
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
|
client_id: clientId,
|
||||||
callback: handleSignIn,
|
callback: handleSignIn,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,26 +1,30 @@
|
|||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
import Image from "next/image";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
// mobx
|
// mobx
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
// services
|
// services
|
||||||
import authenticationService from "services/authentication.service";
|
import authenticationService from "services/authentication.service";
|
||||||
|
import { AppConfigService } from "services/app-config.service";
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// components
|
||||||
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
|
import { EmailPasswordForm, GoogleLoginButton, EmailCodeForm } from "components/accounts";
|
||||||
// images
|
// images
|
||||||
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
|
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
|
||||||
|
|
||||||
|
const appConfig = new AppConfigService();
|
||||||
|
|
||||||
export const SignInView = observer(() => {
|
export const SignInView = observer(() => {
|
||||||
const { user: userStore } = useMobxStore();
|
const { user: userStore } = useMobxStore();
|
||||||
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { next_path } = router.query as { next_path: string };
|
||||||
|
// toast
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
// fetch app config
|
||||||
|
const { data } = useSWR("APP_CONFIG", () => appConfig.envConfig());
|
||||||
|
|
||||||
const onSignInError = (error: any) => {
|
const onSignInError = (error: any) => {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
@ -31,17 +35,17 @@ export const SignInView = observer(() => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSignInSuccess = (response: any) => {
|
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);
|
userStore.setCurrentUser(response?.user);
|
||||||
|
|
||||||
if (!isOnboarded) {
|
const isOnboard = response?.user?.onboarding_step?.profile_complete || false;
|
||||||
router.push(`/onboarding?next_path=${nextPath}`);
|
|
||||||
return;
|
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) => {
|
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) => {
|
const handlePasswordSignIn = async (formData: any) => {
|
||||||
await authenticationService
|
await authenticationService
|
||||||
.emailLogin(formData)
|
.emailLogin(formData)
|
||||||
@ -118,38 +104,32 @@ export const SignInView = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid place-items-center h-full overflow-y-auto py-5 px-7">
|
<div className="grid place-items-center h-full overflow-y-auto py-5 px-7">
|
||||||
<div>
|
<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>
|
||||||
<>
|
{data?.email_password_login && <EmailPasswordForm onSubmit={handlePasswordSignIn} />}
|
||||||
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
|
|
||||||
Sign in to Plane
|
{data?.magic_login && (
|
||||||
</h1>
|
<div className="flex flex-col divide-y divide-custom-border-200">
|
||||||
<div className="flex flex-col divide-y divide-custom-border-200">
|
<div className="pb-7">
|
||||||
<div className="pb-7">
|
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
) : (
|
|
||||||
<EmailPasswordForm onSubmit={handlePasswordSignIn} />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
|
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
|
||||||
<p className="pt-16 text-custom-text-200 text-sm text-center">
|
{data?.google && <GoogleLoginButton clientId={data.google} handleSignIn={handleGoogleSignIn} />}
|
||||||
By signing up, you agree to the{" "}
|
</div>
|
||||||
<a
|
|
||||||
href="https://plane.so/terms-and-conditions"
|
<p className="pt-16 text-custom-text-200 text-sm text-center">
|
||||||
target="_blank"
|
By signing up, you agree to the{" "}
|
||||||
rel="noopener noreferrer"
|
<a
|
||||||
className="font-medium underline"
|
href="https://plane.so/terms-and-conditions"
|
||||||
>
|
target="_blank"
|
||||||
Terms & Conditions
|
rel="noopener noreferrer"
|
||||||
</a>
|
className="font-medium underline"
|
||||||
</p>
|
>
|
||||||
) : null}
|
Terms & Conditions
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -44,19 +44,43 @@ const IssueNavbar = observer(() => {
|
|||||||
}, [projectStore, workspace_slug, project_slug]);
|
}, [projectStore, workspace_slug, project_slug]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workspace_slug && project_slug) {
|
if (workspace_slug && project_slug && projectStore?.deploySettings) {
|
||||||
if (!board) {
|
const viewsAcceptable: string[] = [];
|
||||||
router.push({
|
let currentBoard: string | null = null;
|
||||||
pathname: `/${workspace_slug}/${project_slug}`,
|
|
||||||
query: {
|
if (projectStore?.deploySettings?.views?.list) viewsAcceptable.push("list");
|
||||||
board: "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");
|
||||||
return projectStore.setActiveBoard("list");
|
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 (
|
return (
|
||||||
<div className="px-5 relative w-full flex items-center gap-4">
|
<div className="px-5 relative w-full flex items-center gap-4">
|
||||||
@ -105,7 +129,7 @@ const IssueNavbar = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Link href={`/?next_path=${router.asPath}`}>
|
<Link href={`/login/?next_path=${router.asPath}`}>
|
||||||
<a>
|
<a>
|
||||||
<PrimaryButton className="flex-shrink-0" outline>
|
<PrimaryButton className="flex-shrink-0" outline>
|
||||||
Sign in
|
Sign in
|
||||||
|
@ -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 />}</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -3,12 +3,14 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
// next imports
|
// next imports
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
// js cookie
|
||||||
|
import Cookie from "js-cookie";
|
||||||
// mobx store
|
// mobx store
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
import { RootStore } from "store/root";
|
import { RootStore } from "store/root";
|
||||||
|
|
||||||
const MobxStoreInit = () => {
|
const MobxStoreInit = () => {
|
||||||
const store: RootStore = useMobxStore();
|
const { user: userStore }: RootStore = useMobxStore();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { states, labels, priorities } = router.query as { states: string[]; labels: string[]; priorities: string[] };
|
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.userSelectedStates = states || [];
|
||||||
// }, [store.issue]);
|
// }, [store.issue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const authToken = Cookie.get("accessToken") || null;
|
||||||
|
if (authToken) userStore.fetchCurrentUser();
|
||||||
|
}, [userStore]);
|
||||||
|
|
||||||
return <></>;
|
return <></>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space",
|
"name": "space",
|
||||||
"version": "0.0.1",
|
"version": "0.13.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 4000",
|
"dev": "next dev -p 4000",
|
||||||
|
@ -1,8 +1,19 @@
|
|||||||
import React from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
// components
|
// next
|
||||||
import { HomeView } from "components/views";
|
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;
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -74,24 +74,6 @@ class FileServices extends APIService {
|
|||||||
throw error?.response?.data;
|
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();
|
const fileServices = new FileServices();
|
||||||
|
@ -7,12 +7,17 @@ import { ActorDetail } from "types/issue";
|
|||||||
import { IUser } from "types/user";
|
import { IUser } from "types/user";
|
||||||
|
|
||||||
export interface IUserStore {
|
export interface IUserStore {
|
||||||
|
loader: boolean;
|
||||||
|
error: any | null;
|
||||||
currentUser: any | null;
|
currentUser: any | null;
|
||||||
fetchCurrentUser: () => void;
|
fetchCurrentUser: () => void;
|
||||||
currentActor: () => any;
|
currentActor: () => any;
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserStore implements IUserStore {
|
class UserStore implements IUserStore {
|
||||||
|
loader: boolean = false;
|
||||||
|
error: any | null = null;
|
||||||
|
|
||||||
currentUser: IUser | null = null;
|
currentUser: IUser | null = null;
|
||||||
// root store
|
// root store
|
||||||
rootStore;
|
rootStore;
|
||||||
@ -22,6 +27,9 @@ class UserStore implements IUserStore {
|
|||||||
constructor(_rootStore: any) {
|
constructor(_rootStore: any) {
|
||||||
makeObservable(this, {
|
makeObservable(this, {
|
||||||
// observable
|
// observable
|
||||||
|
loader: observable.ref,
|
||||||
|
error: observable.ref,
|
||||||
|
|
||||||
currentUser: observable.ref,
|
currentUser: observable.ref,
|
||||||
// actions
|
// actions
|
||||||
setCurrentUser: action,
|
setCurrentUser: action,
|
||||||
@ -73,14 +81,19 @@ class UserStore implements IUserStore {
|
|||||||
|
|
||||||
fetchCurrentUser = async () => {
|
fetchCurrentUser = async () => {
|
||||||
try {
|
try {
|
||||||
|
this.loader = true;
|
||||||
|
this.error = null;
|
||||||
const response = await this.userService.currentUser();
|
const response = await this.userService.currentUser();
|
||||||
if (response) {
|
if (response) {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
|
this.loader = false;
|
||||||
this.currentUser = response;
|
this.currentUser = response;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch current user", error);
|
console.error("Failed to fetch current user", error);
|
||||||
|
this.loader = false;
|
||||||
|
this.error = error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
"NEXT_PUBLIC_GITHUB_ID",
|
|
||||||
"NEXT_PUBLIC_GOOGLE_CLIENTID",
|
|
||||||
"NEXT_PUBLIC_API_BASE_URL",
|
"NEXT_PUBLIC_API_BASE_URL",
|
||||||
"NEXT_PUBLIC_DEPLOY_URL",
|
"NEXT_PUBLIC_DEPLOY_URL",
|
||||||
"API_BASE_URL",
|
"API_BASE_URL",
|
||||||
@ -12,8 +10,6 @@
|
|||||||
"NEXT_PUBLIC_GITHUB_APP_NAME",
|
"NEXT_PUBLIC_GITHUB_APP_NAME",
|
||||||
"NEXT_PUBLIC_ENABLE_SENTRY",
|
"NEXT_PUBLIC_ENABLE_SENTRY",
|
||||||
"NEXT_PUBLIC_ENABLE_OAUTH",
|
"NEXT_PUBLIC_ENABLE_OAUTH",
|
||||||
"NEXT_PUBLIC_UNSPLASH_ACCESS",
|
|
||||||
"NEXT_PUBLIC_UNSPLASH_ENABLED",
|
|
||||||
"NEXT_PUBLIC_TRACK_EVENTS",
|
"NEXT_PUBLIC_TRACK_EVENTS",
|
||||||
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
|
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
|
||||||
"NEXT_PUBLIC_CRISP_ID",
|
"NEXT_PUBLIC_CRISP_ID",
|
||||||
|
@ -1,24 +1,4 @@
|
|||||||
# 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
|
# Enable/Disable OAUTH - default 0 for selfhosted instance
|
||||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
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
|
# Public boards deploy URL
|
||||||
NEXT_PUBLIC_DEPLOY_URL="http://localhost:3000/spaces"
|
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"printWidth": 100,
|
"printWidth": 120,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"trailingComma": "es5"
|
"trailingComma": "es5"
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
// ui
|
// ui
|
||||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
import { Button, Input } from "@plane/ui";
|
||||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
|
||||||
// services
|
// services
|
||||||
import authenticationService from "services/authentication.service";
|
import authenticationService from "services/authentication.service";
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
@ -29,6 +28,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
control,
|
||||||
setError,
|
setError,
|
||||||
setValue,
|
setValue,
|
||||||
getValues,
|
getValues,
|
||||||
@ -44,8 +44,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
|||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
const isResendDisabled =
|
const isResendDisabled = resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode;
|
||||||
resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode;
|
|
||||||
|
|
||||||
const onSubmit = async ({ email }: EmailCodeFormValues) => {
|
const onSubmit = async ({ email }: EmailCodeFormValues) => {
|
||||||
setErrorResendingCode(false);
|
setErrorResendingCode(false);
|
||||||
@ -122,44 +121,58 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
|||||||
)}
|
)}
|
||||||
<form className="space-y-4 mt-10 sm:w-[360px] mx-auto">
|
<form className="space-y-4 mt-10 sm:w-[360px] mx-auto">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Input
|
<Controller
|
||||||
id="email"
|
control={control}
|
||||||
type="email"
|
|
||||||
name="email"
|
name="email"
|
||||||
register={register}
|
rules={{
|
||||||
validations={{
|
|
||||||
required: "Email address is required",
|
required: "Email address is required",
|
||||||
validate: (value) =>
|
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(
|
/^(([^<>()[\]\\.,;:\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
|
value
|
||||||
) || "Email address is not valid",
|
) || "Email address is not valid",
|
||||||
}}
|
}}
|
||||||
error={errors.email}
|
render={({ field: { value, onChange, ref } }) => (
|
||||||
placeholder="Enter your email address..."
|
<Input
|
||||||
className="border-custom-border-300 h-[46px]"
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
ref={ref}
|
||||||
|
hasError={Boolean(errors.email)}
|
||||||
|
placeholder="Enter your email address..."
|
||||||
|
className="border-custom-border-300 h-[46px] w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{codeSent && (
|
{codeSent && (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Controller
|
||||||
id="token"
|
control={control}
|
||||||
type="token"
|
|
||||||
name="token"
|
name="token"
|
||||||
register={register}
|
rules={{
|
||||||
validations={{
|
|
||||||
required: "Code is required",
|
required: "Code is required",
|
||||||
}}
|
}}
|
||||||
error={errors.token}
|
render={({ field: { value, onChange, ref } }) => (
|
||||||
placeholder="Enter code..."
|
<Input
|
||||||
className="border-custom-border-300 h-[46px]"
|
id="token"
|
||||||
|
name="token"
|
||||||
|
type="token"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
ref={ref}
|
||||||
|
hasError={Boolean(errors.token)}
|
||||||
|
placeholder="Enter code..."
|
||||||
|
className="border-custom-border-300 h-[46px] w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full justify-end text-xs outline-none ${
|
className={`flex w-full justify-end text-xs outline-none ${
|
||||||
isResendDisabled
|
isResendDisabled ? "cursor-default text-custom-text-200" : "cursor-pointer text-custom-primary-100"
|
||||||
? "cursor-default text-custom-text-200"
|
|
||||||
: "cursor-pointer text-custom-primary-100"
|
|
||||||
} `}
|
} `}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsCodeResending(true);
|
setIsCodeResending(true);
|
||||||
@ -184,20 +197,22 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{codeSent ? (
|
{codeSent ? (
|
||||||
<PrimaryButton
|
<Button
|
||||||
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full text-center h-[46px]"
|
className="w-full"
|
||||||
size="md"
|
size="md"
|
||||||
onClick={handleSubmit(handleSignin)}
|
onClick={handleSubmit(handleSignin)}
|
||||||
disabled={!isValid && isDirty}
|
disabled={!isValid && isDirty}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? "Signing in..." : "Sign in"}
|
{isLoading ? "Signing in..." : "Sign in"}
|
||||||
</PrimaryButton>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<PrimaryButton
|
<Button
|
||||||
className="w-full text-center h-[46px]"
|
variant="primary"
|
||||||
size="md"
|
className="w-full"
|
||||||
|
size="xl"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleSubmit(onSubmit)().then(() => {
|
handleSubmit(onSubmit)().then(() => {
|
||||||
setResendCodeTimer(30);
|
setResendCodeTimer(30);
|
||||||
@ -207,7 +222,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Sending code..." : "Send sign in code"}
|
{isSubmitting ? "Sending code..." : "Send sign in code"}
|
||||||
</PrimaryButton>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
|
62
web/components/account/email-forgot-password-form.tsx
Normal file
62
web/components/account/email-forgot-password-form.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
// ui
|
||||||
|
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||||
|
|
||||||
|
export interface EmailForgotPasswordFormValues {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEmailForgotPasswordForm {
|
||||||
|
onSubmit: (formValues: any) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmailForgotPasswordForm: FC<IEmailForgotPasswordForm> = (props) => {
|
||||||
|
const { onSubmit } = props;
|
||||||
|
// router
|
||||||
|
const router = useRouter();
|
||||||
|
// form data
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<EmailForgotPasswordFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
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 registered email address.."
|
||||||
|
className="border-custom-border-300 h-[46px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex flex-col-reverse sm:flex-row items-center gap-2">
|
||||||
|
<SecondaryButton className="w-full text-center h-[46px]" onClick={() => router.push("/")}>
|
||||||
|
Go Back
|
||||||
|
</SecondaryButton>
|
||||||
|
<PrimaryButton type="submit" className="w-full text-center h-[46px]" loading={isSubmitting}>
|
||||||
|
{isSubmitting ? "Sending link..." : "Send reset link"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
@ -1,31 +1,25 @@
|
|||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
// react hook form
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
// components
|
import { useRouter } from "next/router";
|
||||||
import { EmailResetPasswordForm } from "components/account";
|
|
||||||
// ui
|
// ui
|
||||||
import { Input, PrimaryButton } from "components/ui";
|
import { Input, PrimaryButton } from "components/ui";
|
||||||
|
import Link from "next/link";
|
||||||
// types
|
// types
|
||||||
type EmailPasswordFormValues = {
|
export interface EmailPasswordFormValues {
|
||||||
email: string;
|
email: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
medium?: string;
|
medium?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
type Props = {
|
export interface IEmailPasswordForm {
|
||||||
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
|
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
|
|
||||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
|
||||||
|
|
||||||
|
export const EmailPasswordForm: React.FC<IEmailPasswordForm> = (props) => {
|
||||||
|
const { onSubmit } = props;
|
||||||
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isSignUpPage = router.pathname === "/sign-up";
|
// form info
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@ -42,94 +36,68 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
|
<form className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto" onSubmit={handleSubmit(onSubmit)}>
|
||||||
{isResettingPassword
|
<div className="space-y-1">
|
||||||
? "Reset your password"
|
<Input
|
||||||
: isSignUpPage
|
id="email"
|
||||||
? "Sign up on Plane"
|
type="email"
|
||||||
: "Sign in to Plane"}
|
name="email"
|
||||||
</h1>
|
register={register}
|
||||||
{isResettingPassword ? (
|
validations={{
|
||||||
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} />
|
required: "Email address is required",
|
||||||
) : (
|
validate: (value) =>
|
||||||
<form
|
/^(([^<>()[\]\\.,;:\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(
|
||||||
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
|
value
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
) || "Email address is not valid",
|
||||||
>
|
}}
|
||||||
<div className="space-y-1">
|
error={errors.email}
|
||||||
<Input
|
placeholder="Enter your email address..."
|
||||||
id="email"
|
className="border-custom-border-300 h-[46px]"
|
||||||
type="email"
|
/>
|
||||||
name="email"
|
</div>
|
||||||
register={register}
|
<div className="space-y-1">
|
||||||
validations={{
|
<Input
|
||||||
required: "Email address is required",
|
id="password"
|
||||||
validate: (value) =>
|
type="password"
|
||||||
/^(([^<>()[\]\\.,;:\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(
|
name="password"
|
||||||
value
|
register={register}
|
||||||
) || "Email address is not valid",
|
validations={{
|
||||||
}}
|
required: "Password is required",
|
||||||
error={errors.email}
|
}}
|
||||||
placeholder="Enter your email address..."
|
error={errors.password}
|
||||||
className="border-custom-border-300 h-[46px]"
|
placeholder="Enter your password..."
|
||||||
/>
|
className="border-custom-border-300 h-[46px]"
|
||||||
</div>
|
/>
|
||||||
<div className="space-y-1">
|
</div>
|
||||||
<Input
|
<div className="text-right text-xs">
|
||||||
id="password"
|
<button
|
||||||
type="password"
|
type="button"
|
||||||
name="password"
|
onClick={() => router.push("/accounts/forgot-password")}
|
||||||
register={register}
|
className="text-custom-text-200 hover:text-custom-primary-100"
|
||||||
validations={{
|
>
|
||||||
required: "Password is required",
|
Forgot your password?
|
||||||
}}
|
</button>
|
||||||
error={errors.password}
|
</div>
|
||||||
placeholder="Enter your password..."
|
<div>
|
||||||
className="border-custom-border-300 h-[46px]"
|
<PrimaryButton
|
||||||
/>
|
type="submit"
|
||||||
</div>
|
className="w-full text-center h-[46px]"
|
||||||
<div className="text-right text-xs">
|
disabled={!isValid && isDirty}
|
||||||
{isSignUpPage ? (
|
loading={isSubmitting}
|
||||||
<Link href="/">
|
>
|
||||||
<a className="text-custom-text-200 hover:text-custom-primary-100">
|
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||||
Already have an account? Sign in.
|
</PrimaryButton>
|
||||||
</a>
|
</div>
|
||||||
</Link>
|
<div className="text-xs">
|
||||||
) : (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => router.push("/accounts/sign-up")}
|
||||||
onClick={() => setIsResettingPassword(true)}
|
className="text-custom-text-200 hover:text-custom-primary-100"
|
||||||
className="text-custom-text-200 hover:text-custom-primary-100"
|
>
|
||||||
>
|
{"Don't have an account? Sign Up"}
|
||||||
Forgot your password?
|
</button>
|
||||||
</button>
|
</div>
|
||||||
)}
|
</form>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,97 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
// react hook form
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
// services
|
|
||||||
import userService from "services/user.service";
|
|
||||||
// hooks
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
// ui
|
|
||||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
|
||||||
// types
|
|
||||||
type Props = {
|
|
||||||
setIsResettingPassword: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword }) => {
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm({
|
|
||||||
defaultValues: {
|
|
||||||
email: "",
|
|
||||||
},
|
|
||||||
mode: "onChange",
|
|
||||||
reValidateMode: "onChange",
|
|
||||||
});
|
|
||||||
|
|
||||||
const forgotPassword = async (formData: any) => {
|
|
||||||
const payload = {
|
|
||||||
email: formData.email,
|
|
||||||
};
|
|
||||||
|
|
||||||
await userService
|
|
||||||
.forgotPassword(payload)
|
|
||||||
.then(() =>
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "Password reset link has been sent to your email address.",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.status === 400)
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Please check the Email ID entered.",
|
|
||||||
});
|
|
||||||
else
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Something went wrong. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
|
|
||||||
onSubmit={handleSubmit(forgotPassword)}
|
|
||||||
>
|
|
||||||
<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 registered email address.."
|
|
||||||
className="border-custom-border-300 h-[46px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 flex flex-col-reverse sm:flex-row items-center gap-2">
|
|
||||||
<SecondaryButton
|
|
||||||
className="w-full text-center h-[46px]"
|
|
||||||
onClick={() => setIsResettingPassword(false)}
|
|
||||||
>
|
|
||||||
Go Back
|
|
||||||
</SecondaryButton>
|
|
||||||
<PrimaryButton type="submit" className="w-full text-center h-[46px]" loading={isSubmitting}>
|
|
||||||
{isSubmitting ? "Sending link..." : "Send reset link"}
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
135
web/components/account/email-signup-form.tsx
Normal file
135
web/components/account/email-signup-form.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
// ui
|
||||||
|
import { Button, Input } from "@plane/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,
|
||||||
|
control,
|
||||||
|
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">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
rules={{
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
render={({ field: { value, onChange, ref } }) => (
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
ref={ref}
|
||||||
|
hasError={Boolean(errors.email)}
|
||||||
|
placeholder="Enter your email address..."
|
||||||
|
className="border-custom-border-300 h-[46px] w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="password"
|
||||||
|
rules={{
|
||||||
|
required: "Password is required",
|
||||||
|
}}
|
||||||
|
render={({ field: { value, onChange, ref } }) => (
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
ref={ref}
|
||||||
|
hasError={Boolean(errors.password)}
|
||||||
|
placeholder="Enter your password..."
|
||||||
|
className="border-custom-border-300 h-[46px] w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="confirm_password"
|
||||||
|
rules={{
|
||||||
|
required: "Password is required",
|
||||||
|
validate: (val: string) => {
|
||||||
|
if (watch("password") != val) {
|
||||||
|
return "Your passwords do no match";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
render={({ field: { value, onChange, ref } }) => (
|
||||||
|
<Input
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
type="password"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
ref={ref}
|
||||||
|
hasError={Boolean(errors.confirm_password)}
|
||||||
|
placeholder="Confirm your password..."
|
||||||
|
className="border-custom-border-300 h-[46px] w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!isValid && isDirty}
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Signing up..." : "Sign up"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,29 +1,27 @@
|
|||||||
import { useEffect, useState, FC } from "react";
|
import { useEffect, useState, FC } from "react";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
// next-themes
|
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
// images
|
// images
|
||||||
import githubBlackImage from "/public/logos/github-black.png";
|
import githubBlackImage from "/public/logos/github-black.png";
|
||||||
import githubWhiteImage from "/public/logos/github-white.png";
|
import githubWhiteImage from "/public/logos/github-white.png";
|
||||||
|
|
||||||
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
|
|
||||||
|
|
||||||
export interface GithubLoginButtonProps {
|
export interface GithubLoginButtonProps {
|
||||||
handleSignIn: React.Dispatch<string>;
|
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 [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||||
const [gitCode, setGitCode] = useState<null | string>(null);
|
const [gitCode, setGitCode] = useState<null | string>(null);
|
||||||
|
// router
|
||||||
const {
|
const {
|
||||||
query: { code },
|
query: { code },
|
||||||
} = useRouter();
|
} = useRouter();
|
||||||
|
// theme
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -42,7 +40,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn })
|
|||||||
return (
|
return (
|
||||||
<div className="w-full flex justify-center items-center">
|
<div className="w-full flex justify-center items-center">
|
||||||
<Link
|
<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]">
|
<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
|
<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";
|
import Script from "next/script";
|
||||||
|
|
||||||
export interface IGoogleLoginButton {
|
export interface IGoogleLoginButton {
|
||||||
text?: string;
|
|
||||||
handleSignIn: React.Dispatch<any>;
|
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);
|
const googleSignInButton = useRef<HTMLDivElement>(null);
|
||||||
|
// states
|
||||||
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
||||||
|
|
||||||
const loadScript = useCallback(() => {
|
const loadScript = useCallback(() => {
|
||||||
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
||||||
|
|
||||||
window?.google?.accounts.id.initialize({
|
window?.google?.accounts.id.initialize({
|
||||||
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
|
client_id: clientId,
|
||||||
callback: handleSignIn,
|
callback: handleSignIn,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -39,7 +40,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
|
|||||||
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
|
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
|
||||||
|
|
||||||
setGsiScriptLoaded(true);
|
setGsiScriptLoaded(true);
|
||||||
}, [handleSignIn, gsiScriptLoaded]);
|
}, [handleSignIn, gsiScriptLoaded, clientId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window?.google?.accounts?.id) {
|
if (window?.google?.accounts?.id) {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
export * from "./email-code-form";
|
export * from "./email-code-form";
|
||||||
export * from "./email-password-form";
|
export * from "./email-password-form";
|
||||||
export * from "./email-reset-password-form";
|
export * from "./email-forgot-password-form";
|
||||||
export * from "./github-login-button";
|
export * from "./github-login-button";
|
||||||
export * from "./google-login";
|
export * from "./google-login";
|
||||||
|
export * from "./email-signup-form";
|
||||||
|
@ -1,161 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
// react-hook-form
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
// headless ui
|
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
|
||||||
// services
|
|
||||||
import analyticsService from "services/analytics.service";
|
|
||||||
// hooks
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
// ui
|
|
||||||
import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
|
|
||||||
// types
|
|
||||||
import { IAnalyticsParams, ISaveAnalyticsFormData } from "types";
|
|
||||||
|
|
||||||
// types
|
|
||||||
type Props = {
|
|
||||||
isOpen: boolean;
|
|
||||||
handleClose: () => void;
|
|
||||||
params?: IAnalyticsParams;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FormValues = {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultValues: FormValues = {
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CreateUpdateAnalyticsModal: React.FC<Props> = ({ isOpen, handleClose, params }) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug } = router.query;
|
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
} = useForm<FormValues>({
|
|
||||||
defaultValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
handleClose();
|
|
||||||
reset(defaultValues);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (formData: FormValues) => {
|
|
||||||
if (!workspaceSlug) return;
|
|
||||||
|
|
||||||
const payload: ISaveAnalyticsFormData = {
|
|
||||||
name: formData.name,
|
|
||||||
description: formData.description,
|
|
||||||
query_dict: {
|
|
||||||
x_axis: "priority",
|
|
||||||
y_axis: "issue_count",
|
|
||||||
...params,
|
|
||||||
project: params?.project ?? [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await analyticsService
|
|
||||||
.saveAnalytics(workspaceSlug.toString(), payload)
|
|
||||||
.then(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "Analytics saved successfully.",
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
})
|
|
||||||
.catch(() =>
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Analytics could not be saved. Please try again.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-30" onClose={onClose}>
|
|
||||||
<Transition.Child
|
|
||||||
as={React.Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
|
||||||
<Transition.Child
|
|
||||||
as={React.Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<div>
|
|
||||||
<Dialog.Title
|
|
||||||
as="h3"
|
|
||||||
className="text-lg font-medium leading-6 text-custom-text-100"
|
|
||||||
>
|
|
||||||
Save Analytics
|
|
||||||
</Dialog.Title>
|
|
||||||
<div className="mt-5">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
placeholder="Title"
|
|
||||||
autoComplete="off"
|
|
||||||
error={errors.name}
|
|
||||||
register={register}
|
|
||||||
width="full"
|
|
||||||
validations={{
|
|
||||||
required: "Title is required",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextArea
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
placeholder="Description"
|
|
||||||
className="mt-3 h-32 resize-none text-sm"
|
|
||||||
error={errors.description}
|
|
||||||
register={register}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 flex justify-end gap-2">
|
|
||||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
|
||||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
|
||||||
{isSubmitting ? "Saving..." : "Save Analytics"}
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition.Root>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,133 +1,75 @@
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
import { mutate } from "swr";
|
// services
|
||||||
|
import analyticsService from "services/analytics.service";
|
||||||
// react-hook-form
|
|
||||||
import { Control, UseFormSetValue } from "react-hook-form";
|
|
||||||
// hooks
|
|
||||||
import useProjects from "hooks/use-projects";
|
|
||||||
// components
|
// components
|
||||||
import {
|
import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSidebar } from "components/analytics";
|
||||||
AnalyticsGraph,
|
|
||||||
AnalyticsSelectBar,
|
|
||||||
AnalyticsSidebar,
|
|
||||||
AnalyticsTable,
|
|
||||||
} from "components/analytics";
|
|
||||||
// ui
|
|
||||||
import { Loader, PrimaryButton } from "components/ui";
|
|
||||||
// helpers
|
|
||||||
import { convertResponseToBarGraphData } from "helpers/analytics.helper";
|
|
||||||
// types
|
// types
|
||||||
import { IAnalyticsParams, IAnalyticsResponse, ICurrentUserResponse } from "types";
|
import { IAnalyticsParams } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { ANALYTICS } from "constants/fetch-keys";
|
import { ANALYTICS } from "constants/fetch-keys";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
analytics: IAnalyticsResponse | undefined;
|
additionalParams?: Partial<IAnalyticsParams>;
|
||||||
analyticsError: any;
|
|
||||||
params: IAnalyticsParams;
|
|
||||||
control: Control<IAnalyticsParams, any>;
|
|
||||||
setValue: UseFormSetValue<IAnalyticsParams>;
|
|
||||||
fullScreen: boolean;
|
fullScreen: boolean;
|
||||||
user: ICurrentUserResponse | undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CustomAnalytics: React.FC<Props> = ({
|
const defaultValues: IAnalyticsParams = {
|
||||||
analytics,
|
x_axis: "priority",
|
||||||
analyticsError,
|
y_axis: "issue_count",
|
||||||
params,
|
segment: null,
|
||||||
control,
|
project: null,
|
||||||
setValue,
|
};
|
||||||
fullScreen,
|
|
||||||
user,
|
export const CustomAnalytics: React.FC<Props> = observer((props) => {
|
||||||
}) => {
|
const { additionalParams, fullScreen } = props;
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const { control, watch, setValue } = useForm({ defaultValues });
|
||||||
|
|
||||||
|
const params: IAnalyticsParams = {
|
||||||
|
x_axis: watch("x_axis"),
|
||||||
|
y_axis: watch("y_axis"),
|
||||||
|
segment: watch("segment"),
|
||||||
|
project: projectId ? [projectId.toString()] : watch("project"),
|
||||||
|
...additionalParams,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: analytics, error: analyticsError } = useSWR(
|
||||||
|
workspaceSlug ? ANALYTICS(workspaceSlug.toString(), params) : null,
|
||||||
|
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
|
||||||
|
);
|
||||||
|
|
||||||
const isProjectLevel = projectId ? true : false;
|
const isProjectLevel = projectId ? true : false;
|
||||||
|
|
||||||
const yAxisKey = params.y_axis === "issue_count" ? "count" : "estimate";
|
|
||||||
const barGraphData = convertResponseToBarGraphData(analytics?.distribution, params);
|
|
||||||
|
|
||||||
const { projects } = useProjects();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`overflow-hidden flex flex-col-reverse ${fullScreen ? "md:grid md:grid-cols-4 md:h-full" : ""}`}>
|
||||||
className={`overflow-hidden flex flex-col-reverse ${
|
|
||||||
fullScreen ? "md:grid md:grid-cols-4 md:h-full" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="col-span-3 flex flex-col h-full overflow-hidden">
|
<div className="col-span-3 flex flex-col h-full overflow-hidden">
|
||||||
<AnalyticsSelectBar
|
<CustomAnalyticsSelectBar
|
||||||
control={control}
|
control={control}
|
||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
projects={projects ?? []}
|
|
||||||
params={params}
|
params={params}
|
||||||
fullScreen={fullScreen}
|
fullScreen={fullScreen}
|
||||||
isProjectLevel={isProjectLevel}
|
isProjectLevel={isProjectLevel}
|
||||||
/>
|
/>
|
||||||
{!analyticsError ? (
|
<CustomAnalyticsMainContent
|
||||||
analytics ? (
|
analytics={analytics}
|
||||||
analytics.total > 0 ? (
|
error={analyticsError}
|
||||||
<div className="h-full overflow-y-auto">
|
fullScreen={fullScreen}
|
||||||
<AnalyticsGraph
|
params={params}
|
||||||
analytics={analytics}
|
/>
|
||||||
barGraphData={barGraphData}
|
|
||||||
params={params}
|
|
||||||
yAxisKey={yAxisKey}
|
|
||||||
fullScreen={fullScreen}
|
|
||||||
/>
|
|
||||||
<AnalyticsTable
|
|
||||||
analytics={analytics}
|
|
||||||
barGraphData={barGraphData}
|
|
||||||
params={params}
|
|
||||||
yAxisKey={yAxisKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid h-full place-items-center p-5">
|
|
||||||
<div className="space-y-4 text-custom-text-200">
|
|
||||||
<p className="text-sm">No matching issues found. Try changing the parameters.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Loader className="space-y-6 p-5">
|
|
||||||
<Loader.Item height="300px" />
|
|
||||||
<Loader className="space-y-4">
|
|
||||||
<Loader.Item height="30px" />
|
|
||||||
<Loader.Item height="30px" />
|
|
||||||
<Loader.Item height="30px" />
|
|
||||||
<Loader.Item height="30px" />
|
|
||||||
</Loader>
|
|
||||||
</Loader>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="grid h-full place-items-center p-5">
|
|
||||||
<div className="space-y-4 text-custom-text-200">
|
|
||||||
<p className="text-sm">There was some error in fetching the data.</p>
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={() => {
|
|
||||||
if (!workspaceSlug) return;
|
|
||||||
|
|
||||||
mutate(ANALYTICS(workspaceSlug.toString(), params));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<AnalyticsSidebar
|
<CustomAnalyticsSidebar
|
||||||
analytics={analytics}
|
analytics={analytics}
|
||||||
params={params}
|
params={params}
|
||||||
fullScreen={fullScreen}
|
fullScreen={fullScreen}
|
||||||
isProjectLevel={isProjectLevel}
|
isProjectLevel={isProjectLevel}
|
||||||
user={user}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -6,10 +6,9 @@ import { CustomTooltip } from "./custom-tooltip";
|
|||||||
import { BarGraph } from "components/ui";
|
import { BarGraph } from "components/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { findStringWithMostCharacters } from "helpers/array.helper";
|
import { findStringWithMostCharacters } from "helpers/array.helper";
|
||||||
import { generateBarColor } from "helpers/analytics.helper";
|
import { generateBarColor, generateDisplayName } from "helpers/analytics.helper";
|
||||||
// types
|
// types
|
||||||
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||||
// constants
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
analytics: IAnalyticsResponse;
|
analytics: IAnalyticsResponse;
|
||||||
@ -22,21 +21,7 @@ type Props = {
|
|||||||
fullScreen: boolean;
|
fullScreen: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AnalyticsGraph: React.FC<Props> = ({
|
export const AnalyticsGraph: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey, fullScreen }) => {
|
||||||
analytics,
|
|
||||||
barGraphData,
|
|
||||||
params,
|
|
||||||
yAxisKey,
|
|
||||||
fullScreen,
|
|
||||||
}) => {
|
|
||||||
const renderAssigneeName = (assigneeId: string): string => {
|
|
||||||
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
|
|
||||||
|
|
||||||
if (!assignee) return "?";
|
|
||||||
|
|
||||||
return assignee.assignees__display_name || "?";
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateYAxisTickValues = () => {
|
const generateYAxisTickValues = () => {
|
||||||
if (!analytics) return [];
|
if (!analytics) return [];
|
||||||
|
|
||||||
@ -111,7 +96,7 @@ export const AnalyticsGraph: React.FC<Props> = ({
|
|||||||
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
|
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
|
||||||
{params.x_axis === "assignees__id"
|
{params.x_axis === "assignees__id"
|
||||||
? datum.value && datum.value !== "None"
|
? datum.value && datum.value !== "None"
|
||||||
? renderAssigneeName(datum.value)[0].toUpperCase()
|
? generateDisplayName(datum.value, analytics, params, "x_axis")[0].toUpperCase()
|
||||||
: "?"
|
: "?"
|
||||||
: datum.value && datum.value !== "None"
|
: datum.value && datum.value !== "None"
|
||||||
? `${datum.value}`.toUpperCase()[0]
|
? `${datum.value}`.toUpperCase()[0]
|
||||||
@ -120,7 +105,13 @@ export const AnalyticsGraph: React.FC<Props> = ({
|
|||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: undefined,
|
: (datum) => (
|
||||||
|
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||||
|
<text x={0} y={21} textAnchor="middle" fontSize={10}>
|
||||||
|
{generateDisplayName(datum.value, analytics, params, "x_axis")}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
theme={{
|
theme={{
|
||||||
axis: {},
|
axis: {},
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
export * from "./graph";
|
export * from "./graph";
|
||||||
export * from "./create-update-analytics-modal";
|
export * from "./select";
|
||||||
export * from "./custom-analytics";
|
export * from "./custom-analytics";
|
||||||
|
export * from "./main-content";
|
||||||
export * from "./select-bar";
|
export * from "./select-bar";
|
||||||
export * from "./sidebar";
|
export * from "./sidebar";
|
||||||
export * from "./table";
|
export * from "./table";
|
||||||
|
85
web/components/analytics/custom-analytics/main-content.tsx
Normal file
85
web/components/analytics/custom-analytics/main-content.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { mutate } from "swr";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { AnalyticsGraph, AnalyticsTable } from "components/analytics";
|
||||||
|
// ui
|
||||||
|
import { Button, Loader } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { convertResponseToBarGraphData } from "helpers/analytics.helper";
|
||||||
|
// types
|
||||||
|
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||||
|
// fetch-keys
|
||||||
|
import { ANALYTICS } from "constants/fetch-keys";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
analytics: IAnalyticsResponse | undefined;
|
||||||
|
error: any;
|
||||||
|
fullScreen: boolean;
|
||||||
|
params: IAnalyticsParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomAnalyticsMainContent: React.FC<Props> = (props) => {
|
||||||
|
const { analytics, error, fullScreen, params } = props;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const yAxisKey = params.y_axis === "issue_count" ? "count" : "estimate";
|
||||||
|
const barGraphData = convertResponseToBarGraphData(analytics?.distribution, params);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!error ? (
|
||||||
|
analytics ? (
|
||||||
|
analytics.total > 0 ? (
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<AnalyticsGraph
|
||||||
|
analytics={analytics}
|
||||||
|
barGraphData={barGraphData}
|
||||||
|
params={params}
|
||||||
|
yAxisKey={yAxisKey}
|
||||||
|
fullScreen={fullScreen}
|
||||||
|
/>
|
||||||
|
<AnalyticsTable analytics={analytics} barGraphData={barGraphData} params={params} yAxisKey={yAxisKey} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full place-items-center p-5">
|
||||||
|
<div className="space-y-4 text-custom-text-200">
|
||||||
|
<p className="text-sm">No matching issues found. Try changing the parameters.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Loader className="space-y-6 p-5">
|
||||||
|
<Loader.Item height="300px" />
|
||||||
|
<Loader className="space-y-4">
|
||||||
|
<Loader.Item height="30px" />
|
||||||
|
<Loader.Item height="30px" />
|
||||||
|
<Loader.Item height="30px" />
|
||||||
|
<Loader.Item height="30px" />
|
||||||
|
</Loader>
|
||||||
|
</Loader>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full place-items-center p-5">
|
||||||
|
<div className="space-y-4 text-custom-text-200">
|
||||||
|
<p className="text-sm">There was some error in fetching the data.</p>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
|
||||||
|
mutate(ANALYTICS(workspaceSlug.toString(), params));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user