Merge pull request #2309 from makeplane/stage-release

release: stage release to production
This commit is contained in:
sriram veeraghanta 2023-09-29 17:40:17 +05:30 committed by GitHub
commit 8d4ac9b430
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
377 changed files with 19460 additions and 8120 deletions

View File

@ -1,38 +1,3 @@
# Frontend
# Extra image domains that need to be added for Next Image
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID=""
# Github ID for Github OAuth
NEXT_PUBLIC_GITHUB_ID=""
# Github App Name for GitHub Integration
NEXT_PUBLIC_GITHUB_APP_NAME=""
# Sentry DSN for error monitoring
NEXT_PUBLIC_SENTRY_DSN=""
# Enable/Disable OAUTH - default 0 for selfhosted instance
NEXT_PUBLIC_ENABLE_OAUTH=0
# Enable/Disable sentry
NEXT_PUBLIC_ENABLE_SENTRY=0
# Enable/Disable session recording
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
# Enable/Disable event tracking
NEXT_PUBLIC_TRACK_EVENTS=0
# Slack for Slack Integration
NEXT_PUBLIC_SLACK_CLIENT_ID=""
# For Telemetry, set it to "app.plane.so"
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
# public boards deploy url
NEXT_PUBLIC_DEPLOY_URL=""
# plane deploy using nginx
NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
# Error logs
SENTRY_DSN=""
# Database Settings # Database Settings
PGUSER="plane" PGUSER="plane"
PGPASSWORD="plane" PGPASSWORD="plane"
@ -45,15 +10,6 @@ REDIS_HOST="plane-redis"
REDIS_PORT="6379" REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/" REDIS_URL="redis://${REDIS_HOST}:6379/"
# Email Settings
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS="1"
EMAIL_USE_SSL="0"
# AWS Settings # AWS Settings
AWS_REGION="" AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key" AWS_ACCESS_KEY_ID="access-key"
@ -69,9 +25,6 @@ OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint
OPENAI_API_KEY="sk-" # add your openai key here OPENAI_API_KEY="sk-" # add your openai key here
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access
# Github
GITHUB_CLIENT_SECRET="" # For fetching release notes
# Settings related to Docker # Settings related to Docker
DOCKERIZED=1 DOCKERIZED=1
# set to 1 If using the pre-configured minio setup # set to 1 If using the pre-configured minio setup
@ -80,10 +33,3 @@ USE_MINIO=1
# Nginx Configuration # Nginx Configuration
NGINX_PORT=80 NGINX_PORT=80
# Default Creds
DEFAULT_EMAIL="captain@plane.so"
DEFAULT_PASSWORD="password123"
# SignUps
ENABLE_SIGNUP="1"
# Auto generated and Required that will be generated from setup.sh

View File

@ -33,14 +33,9 @@ jobs:
deploy: deploy:
- space/** - space/**
- name: Setup .npmrc for repository
run: |
echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc
- name: Build Plane's Main App - name: Build Plane's Main App
if: steps.changed-files.outputs.web_any_changed == 'true' if: steps.changed-files.outputs.web_any_changed == 'true'
run: | run: |
mv ./.npmrc ./web
cd web cd web
yarn yarn
yarn build yarn build

View File

@ -22,10 +22,6 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup .npmrc for repository
run: |
echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release - name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaFrontend id: metaFrontend
uses: docker/metadata-action@v4.3.0 uses: docker/metadata-action@v4.3.0

View File

@ -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.

View File

@ -59,17 +59,6 @@ chmod +x 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
- Setup Tiptap Pro
Visit [Tiptap Pro](https://collab.tiptap.dev/pro-extensions) and signup (it is free).
Create a **`.npmrc`** file, copy the following and replace your registry token generated from Tiptap Pro.
```
@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=YOUR_REGISTRY_TOKEN
```
- Run Docker compose up - Run Docker compose up
```bash ```bash

61
apiserver/.env.example Normal file
View File

@ -0,0 +1,61 @@
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
DJANGO_SETTINGS_MODULE="plane.settings.selfhosted"
# Error logs
SENTRY_DSN=""
# Database Settings
PGUSER="plane"
PGPASSWORD="plane"
PGHOST="plane-db"
PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# Email Settings
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS="1"
EMAIL_USE_SSL="0"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key"
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
# Changing this requires change in the nginx.conf for uploads if using minio setup
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint
OPENAI_API_KEY="sk-" # add your openai key here
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access
# Github
GITHUB_CLIENT_SECRET="" # For fetching release notes
# Settings related to Docker
DOCKERIZED=1
# set to 1 If using the pre-configured minio setup
USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# Default Creds
DEFAULT_EMAIL="captain@plane.so"
DEFAULT_PASSWORD="password123"
# SignUps
ENABLE_SIGNUP="1"

View File

@ -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()

View File

@ -23,7 +23,7 @@ from .project import (
ProjectPublicMemberSerializer ProjectPublicMemberSerializer
) )
from .state import StateSerializer, StateLiteSerializer from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer from .view import GlobalViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer
from .asset import FileAssetSerializer from .asset import FileAssetSerializer
from .issue import ( from .issue import (
@ -31,8 +31,6 @@ from .issue import (
IssueActivitySerializer, IssueActivitySerializer,
IssueCommentSerializer, IssueCommentSerializer,
IssuePropertySerializer, IssuePropertySerializer,
BlockerIssueSerializer,
BlockedIssueSerializer,
IssueAssigneeSerializer, IssueAssigneeSerializer,
LabelSerializer, LabelSerializer,
IssueSerializer, IssueSerializer,
@ -45,6 +43,8 @@ from .issue import (
IssueReactionSerializer, IssueReactionSerializer,
CommentReactionSerializer, CommentReactionSerializer,
IssueVoteSerializer, IssueVoteSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer, IssuePublicSerializer,
) )

View File

@ -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
@ -64,24 +62,6 @@ class CycleSerializer(BaseSerializer):
unique_list = [dict(item) for item in unique_objects] unique_list = [dict(item) for item in unique_objects]
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

View File

@ -17,12 +17,10 @@ from plane.db.models import (
IssueActivity, IssueActivity,
IssueComment, IssueComment,
IssueProperty, IssueProperty,
IssueBlocker,
IssueAssignee, IssueAssignee,
IssueSubscriber, IssueSubscriber,
IssueLabel, IssueLabel,
Label, Label,
IssueBlocker,
CycleIssue, CycleIssue,
Cycle, Cycle,
Module, Module,
@ -32,6 +30,7 @@ from plane.db.models import (
IssueReaction, IssueReaction,
CommentReaction, CommentReaction,
IssueVote, IssueVote,
IssueRelation,
) )
@ -50,6 +49,7 @@ class IssueFlatSerializer(BaseSerializer):
"target_date", "target_date",
"sequence_id", "sequence_id",
"sort_order", "sort_order",
"is_draft",
] ]
@ -81,25 +81,12 @@ class IssueCreateSerializer(BaseSerializer):
required=False, required=False,
) )
# List of issues that are blocking this issue
blockers_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()),
write_only=True,
required=False,
)
labels_list = serializers.ListField( labels_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True, write_only=True,
required=False, required=False,
) )
# List of issues that are blocked by this issue
blocks_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()),
write_only=True,
required=False,
)
class Meta: class Meta:
model = Issue model = Issue
fields = "__all__" fields = "__all__"
@ -122,10 +109,8 @@ class IssueCreateSerializer(BaseSerializer):
return data return data
def create(self, validated_data): def create(self, validated_data):
blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None) assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None) labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
project_id = self.context["project_id"] project_id = self.context["project_id"]
workspace_id = self.context["workspace_id"] workspace_id = self.context["workspace_id"]
@ -137,22 +122,6 @@ class IssueCreateSerializer(BaseSerializer):
created_by_id = issue.created_by_id created_by_id = issue.created_by_id
updated_by_id = issue.updated_by_id updated_by_id = issue.updated_by_id
if blockers is not None and len(blockers):
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
block=issue,
blocked_by=blocker,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for blocker in blockers
],
batch_size=10,
)
if assignees is not None and len(assignees): if assignees is not None and len(assignees):
IssueAssignee.objects.bulk_create( IssueAssignee.objects.bulk_create(
[ [
@ -196,29 +165,11 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10, batch_size=10,
) )
if blocks is not None and len(blocks):
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
block=block,
blocked_by=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for block in blocks
],
batch_size=10,
)
return issue return issue
def update(self, instance, validated_data): def update(self, instance, validated_data):
blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None) assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None) labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
# Related models # Related models
project_id = instance.project_id project_id = instance.project_id
@ -226,23 +177,6 @@ class IssueCreateSerializer(BaseSerializer):
created_by_id = instance.created_by_id created_by_id = instance.created_by_id
updated_by_id = instance.updated_by_id updated_by_id = instance.updated_by_id
if blockers is not None:
IssueBlocker.objects.filter(block=instance).delete()
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
block=instance,
blocked_by=blocker,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for blocker in blockers
],
batch_size=10,
)
if assignees is not None: if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete() IssueAssignee.objects.filter(issue=instance).delete()
IssueAssignee.objects.bulk_create( IssueAssignee.objects.bulk_create(
@ -277,23 +211,6 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10, batch_size=10,
) )
if blocks is not None:
IssueBlocker.objects.filter(blocked_by=instance).delete()
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
block=block,
blocked_by=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for block in blocks
],
batch_size=10,
)
# Time updation occues even when other related models are updated # Time updation occues even when other related models are updated
instance.updated_at = timezone.now() instance.updated_at = timezone.now()
return super().update(instance, validated_data) return super().update(instance, validated_data)
@ -375,32 +292,39 @@ class IssueLabelSerializer(BaseSerializer):
] ]
class BlockedIssueSerializer(BaseSerializer): class IssueRelationSerializer(BaseSerializer):
blocked_issue_detail = IssueProjectLiteSerializer(source="block", read_only=True) issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
class Meta: class Meta:
model = IssueBlocker model = IssueRelation
fields = [ fields = [
"blocked_issue_detail", "issue_detail",
"blocked_by", "relation_type",
"block", "related_issue",
"issue",
"id"
]
read_only_fields = [
"workspace",
"project",
] ]
read_only_fields = fields
class RelatedIssueSerializer(BaseSerializer):
class BlockerIssueSerializer(BaseSerializer): issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue")
blocker_issue_detail = IssueProjectLiteSerializer(
source="blocked_by", read_only=True
)
class Meta: class Meta:
model = IssueBlocker model = IssueRelation
fields = [ fields = [
"blocker_issue_detail", "issue_detail",
"blocked_by", "relation_type",
"block", "related_issue",
"issue",
"id"
]
read_only_fields = [
"workspace",
"project",
] ]
read_only_fields = fields
class IssueAssigneeSerializer(BaseSerializer): class IssueAssigneeSerializer(BaseSerializer):
@ -617,10 +541,8 @@ class IssueSerializer(BaseSerializer):
parent_detail = IssueStateFlatSerializer(read_only=True, source="parent") parent_detail = IssueStateFlatSerializer(read_only=True, source="parent")
label_details = LabelSerializer(read_only=True, source="labels", many=True) label_details = LabelSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
# List of issues blocked by this issue related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True)
blocked_issues = BlockedIssueSerializer(read_only=True, many=True) issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True)
# List of issues that block this issue
blocker_issues = BlockerIssueSerializer(read_only=True, many=True)
issue_cycle = IssueCycleDetailSerializer(read_only=True) issue_cycle = IssueCycleDetailSerializer(read_only=True)
issue_module = IssueModuleDetailSerializer(read_only=True) issue_module = IssueModuleDetailSerializer(read_only=True)
issue_link = IssueLinkSerializer(read_only=True, many=True) issue_link = IssueLinkSerializer(read_only=True, many=True)

View File

@ -5,10 +5,39 @@ from rest_framework import serializers
from .base import BaseSerializer from .base import BaseSerializer
from .workspace import WorkspaceLiteSerializer from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer from .project import ProjectLiteSerializer
from plane.db.models import IssueView, IssueViewFavorite from plane.db.models import GlobalView, IssueView, IssueViewFavorite
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
class GlobalViewSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
class Meta:
model = GlobalView
fields = "__all__"
read_only_fields = [
"workspace",
"query",
]
def create(self, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
return GlobalView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)
class IssueViewSerializer(BaseSerializer): class IssueViewSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True) is_favorite = serializers.BooleanField(read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True) project_detail = ProjectLiteSerializer(source="project", read_only=True)

View File

@ -90,7 +90,9 @@ from plane.api.views import (
IssueSubscriberViewSet, IssueSubscriberViewSet,
IssueCommentPublicViewSet, IssueCommentPublicViewSet,
IssueReactionViewSet, IssueReactionViewSet,
IssueRelationViewSet,
CommentReactionViewSet, CommentReactionViewSet,
IssueDraftViewSet,
## End Issues ## End Issues
# States # States
StateViewSet, StateViewSet,
@ -100,6 +102,8 @@ from plane.api.views import (
BulkEstimatePointEndpoint, BulkEstimatePointEndpoint,
## End Estimates ## End Estimates
# Views # Views
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewViewSet, IssueViewViewSet,
ViewIssuesEndpoint, ViewIssuesEndpoint,
IssueViewFavoriteViewSet, IssueViewFavoriteViewSet,
@ -182,7 +186,6 @@ from plane.api.views import (
## Exporter ## Exporter
ExportIssuesEndpoint, ExportIssuesEndpoint,
## End Exporter ## End Exporter
) )
@ -239,7 +242,11 @@ urlpatterns = [
UpdateUserTourCompletedEndpoint.as_view(), UpdateUserTourCompletedEndpoint.as_view(),
name="user-tour", name="user-tour",
), ),
path("users/workspaces/<str:slug>/activities/", UserActivityEndpoint.as_view(), name="user-activities"), path(
"users/workspaces/<str:slug>/activities/",
UserActivityEndpoint.as_view(),
name="user-activities",
),
# user workspaces # user workspaces
path( path(
"users/me/workspaces/", "users/me/workspaces/",
@ -647,6 +654,37 @@ urlpatterns = [
ViewIssuesEndpoint.as_view(), ViewIssuesEndpoint.as_view(),
name="project-view-issues", name="project-view-issues",
), ),
path(
"workspaces/<str:slug>/views/",
GlobalViewViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/views/<uuid:pk>/",
GlobalViewViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/issues/",
GlobalViewIssuesViewSet.as_view(
{
"get": "list",
}
),
name="global-view-issues",
),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/", "workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/",
IssueViewFavoriteViewSet.as_view( IssueViewFavoriteViewSet.as_view(
@ -765,11 +803,6 @@ urlpatterns = [
), ),
name="project-issue", name="project-issue",
), ),
path(
"workspaces/<str:slug>/issues/",
WorkSpaceIssuesEndpoint.as_view(),
name="workspace-issue",
),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/", "workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
LabelViewSet.as_view( LabelViewSet.as_view(
@ -1010,6 +1043,49 @@ urlpatterns = [
name="project-issue-archive", name="project-issue-archive",
), ),
## End Issue Archives ## End Issue Archives
## Issue Relation
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-relation/",
IssueRelationViewSet.as_view(
{
"post": "create",
}
),
name="issue-relation",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-relation/<uuid:pk>/",
IssueRelationViewSet.as_view(
{
"delete": "destroy",
}
),
name="issue-relation",
),
## End Issue Relation
## Issue Drafts
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-drafts/",
IssueDraftViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-draft",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-drafts/<uuid:pk>/",
IssueDraftViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-issue-draft",
),
## End Issue Drafts
## File Assets ## File Assets
path( path(
"workspaces/<str:slug>/file-assets/", "workspaces/<str:slug>/file-assets/",

View File

@ -56,7 +56,7 @@ from .workspace import (
LeaveWorkspaceEndpoint, LeaveWorkspaceEndpoint,
) )
from .state import StateViewSet from .state import StateViewSet
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
from .cycle import ( from .cycle import (
CycleViewSet, CycleViewSet,
CycleIssueViewSet, CycleIssueViewSet,
@ -86,8 +86,10 @@ from .issue import (
IssueReactionPublicViewSet, IssueReactionPublicViewSet,
CommentReactionPublicViewSet, CommentReactionPublicViewSet,
IssueVotePublicViewSet, IssueVotePublicViewSet,
IssueRelationViewSet,
IssueRetrievePublicEndpoint, IssueRetrievePublicEndpoint,
ProjectIssuesPublicEndpoint, ProjectIssuesPublicEndpoint,
IssueDraftViewSet,
) )
from .auth_extended import ( from .auth_extended import (
@ -167,6 +169,4 @@ from .analytic import (
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
from .exporter import ( from .exporter import ExportIssuesEndpoint
ExportIssuesEndpoint,
)

View File

@ -80,6 +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())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -101,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(
@ -195,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")
@ -221,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")
@ -384,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")
@ -411,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")
@ -487,6 +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())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -517,6 +607,7 @@ class CycleIssueViewSet(BaseViewSet):
try: try:
order_by = request.GET.get("order_by", "created_at") order_by = request.GET.get("order_by", "created_at")
group_by = request.GET.get("group_by", False) group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
issues = ( issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
@ -555,9 +646,15 @@ class CycleIssueViewSet(BaseViewSet):
issues_data = IssueStateSerializer(issues, many=True).data issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by: if group_by:
return Response( return Response(
group_results(issues_data, group_by), group_results(issues_data, group_by, sub_group_by),
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@ -655,6 +752,7 @@ class CycleIssueViewSet(BaseViewSet):
), ),
} }
), ),
epoch=int(timezone.now().timestamp())
) )
# Return all Cycle Issues # Return all Cycle Issues

View File

@ -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,
) )
) )

View File

@ -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,6 +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())
) )
# create an inbox issue # create an inbox issue
InboxIssue.objects.create( InboxIssue.objects.create(
@ -277,6 +278,7 @@ class InboxIssueViewSet(BaseViewSet):
IssueSerializer(current_instance).data, IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch=int(timezone.now().timestamp())
) )
issue_serializer.save() issue_serializer.save()
else: else:
@ -368,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:
@ -478,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
@ -518,6 +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())
) )
# create an inbox issue # create an inbox issue
InboxIssue.objects.create( InboxIssue.objects.create(
@ -582,6 +590,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
IssueSerializer(current_instance).data, IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
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)

View File

@ -4,6 +4,7 @@ import random
from itertools import chain from itertools import chain
# Django imports # Django imports
from django.utils import timezone
from django.db.models import ( from django.db.models import (
Prefetch, Prefetch,
OuterRef, OuterRef,
@ -23,7 +24,7 @@ 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
# Third Party imports # Third Party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -51,10 +52,11 @@ from plane.api.serializers import (
IssueReactionSerializer, IssueReactionSerializer,
CommentReactionSerializer, CommentReactionSerializer,
IssueVoteSerializer, IssueVoteSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer, IssuePublicSerializer,
) )
from plane.api.permissions import ( from plane.api.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission, ProjectEntityPermission,
WorkSpaceAdminPermission, WorkSpaceAdminPermission,
ProjectMemberPermission, ProjectMemberPermission,
@ -76,6 +78,7 @@ from plane.db.models import (
CommentReaction, CommentReaction,
ProjectDeployBoard, ProjectDeployBoard,
IssueVote, IssueVote,
IssueRelation,
ProjectPublicMember, ProjectPublicMember,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
@ -125,6 +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())
) )
return super().perform_update(serializer) return super().perform_update(serializer)
@ -145,6 +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())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -178,7 +183,7 @@ class IssueViewSet(BaseViewSet):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state # Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None] priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] 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")
@ -266,9 +271,16 @@ class IssueViewSet(BaseViewSet):
## Grouping the results ## Grouping the results
group_by = request.GET.get("group_by", False) group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by: if group_by:
return Response( return Response(
group_results(issues, group_by), status=status.HTTP_200_OK group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
) )
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
@ -304,6 +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())
) )
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)
@ -315,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)
@ -331,7 +349,7 @@ class UserWorkSpaceIssues(BaseAPIView):
try: try:
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state # Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None] priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] 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")
@ -443,9 +461,16 @@ class UserWorkSpaceIssues(BaseAPIView):
## Grouping the results ## Grouping the results
group_by = request.GET.get("group_by", False) group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by: if group_by:
return Response( return Response(
group_results(issues, group_by), status=status.HTTP_200_OK group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
) )
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
@ -491,7 +516,7 @@ class IssueActivityEndpoint(BaseAPIView):
issue_activities = ( issue_activities = (
IssueActivity.objects.filter(issue_id=issue_id) IssueActivity.objects.filter(issue_id=issue_id)
.filter( .filter(
~Q(field__in=["comment", "vote", "reaction"]), ~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
) )
.select_related("actor", "workspace", "issue", "project") .select_related("actor", "workspace", "issue", "project")
@ -550,6 +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())
) )
def perform_update(self, serializer): def perform_update(self, serializer):
@ -568,6 +594,7 @@ class IssueCommentViewSet(BaseViewSet):
IssueCommentSerializer(current_instance).data, IssueCommentSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch=int(timezone.now().timestamp())
) )
return super().perform_update(serializer) return super().perform_update(serializer)
@ -589,6 +616,7 @@ class IssueCommentViewSet(BaseViewSet):
IssueCommentSerializer(current_instance).data, IssueCommentSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch=int(timezone.now().timestamp())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -684,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(
@ -872,6 +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())
) )
def perform_update(self, serializer): def perform_update(self, serializer):
@ -890,6 +927,7 @@ class IssueLinkViewSet(BaseViewSet):
IssueLinkSerializer(current_instance).data, IssueLinkSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch=int(timezone.now().timestamp())
) )
return super().perform_update(serializer) return super().perform_update(serializer)
@ -911,6 +949,7 @@ class IssueLinkViewSet(BaseViewSet):
IssueLinkSerializer(current_instance).data, IssueLinkSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch=int(timezone.now().timestamp())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -989,6 +1028,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
serializer.data, serializer.data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
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)
@ -1011,6 +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())
) )
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@ -1068,7 +1109,7 @@ class IssueArchiveViewSet(BaseViewSet):
show_sub_issues = request.GET.get("show_sub_issues", "true") show_sub_issues = request.GET.get("show_sub_issues", "true")
# Custom ordering for priority and state # Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None] priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] 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")
@ -1213,6 +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())
) )
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
@ -1417,6 +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())
) )
def destroy(self, request, slug, project_id, issue_id, reaction_code): def destroy(self, request, slug, project_id, issue_id, reaction_code):
@ -1440,6 +1483,7 @@ class IssueReactionViewSet(BaseViewSet):
"identifier": str(issue_reaction.id), "identifier": str(issue_reaction.id),
} }
), ),
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)
@ -1488,6 +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())
) )
def destroy(self, request, slug, project_id, comment_id, reaction_code): def destroy(self, request, slug, project_id, comment_id, reaction_code):
@ -1512,6 +1557,7 @@ class CommentReactionViewSet(BaseViewSet):
"comment_id": str(comment_id), "comment_id": str(comment_id),
} }
), ),
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)
@ -1608,6 +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())
) )
if not ProjectMember.objects.filter( if not ProjectMember.objects.filter(
project_id=project_id, project_id=project_id,
@ -1657,6 +1704,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
IssueCommentSerializer(comment).data, IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
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)
@ -1690,6 +1738,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
IssueCommentSerializer(comment).data, IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
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)
@ -1764,6 +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())
) )
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)
@ -1808,6 +1858,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
"identifier": str(issue_reaction.id), "identifier": str(issue_reaction.id),
} }
), ),
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)
@ -1881,6 +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())
) )
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)
@ -1932,6 +1984,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
"comment_id": str(comment_id), "comment_id": str(comment_id),
} }
), ),
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)
@ -1995,6 +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())
) )
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)
@ -2029,6 +2083,7 @@ class IssueVotePublicViewSet(BaseViewSet):
"identifier": str(issue_vote.id), "identifier": str(issue_vote.id),
} }
), ),
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)
@ -2040,6 +2095,109 @@ class IssueVotePublicViewSet(BaseViewSet):
) )
class IssueRelationViewSet(BaseViewSet):
serializer_class = IssueRelationSerializer
model = IssueRelation
permission_classes = [
ProjectEntityPermission,
]
def perform_destroy(self, instance):
current_instance = (
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None:
issue_activity.delay(
type="issue_relation.activity.deleted",
requested_data=json.dumps({"related_list": None}),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueRelationSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
def create(self, request, slug, project_id, issue_id):
try:
related_list = request.data.get("related_list", [])
relation = request.data.get("relation", None)
project = Project.objects.get(pk=project_id)
issue_relation = IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=related_issue["issue"],
related_issue_id=related_issue["related_issue"],
relation_type=related_issue["relation_type"],
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for related_issue in related_list
],
batch_size=10,
ignore_conflicts=True,
)
issue_activity.delay(
type="issue_relation.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
if relation == "blocking":
return Response(
RelatedIssueSerializer(issue_relation, many=True).data,
status=status.HTTP_201_CREATED,
)
else:
return Response(
IssueRelationSerializer(issue_relation, many=True).data,
status=status.HTTP_201_CREATED,
)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"name": "The issue is already taken"},
status=status.HTTP_410_GONE,
)
else:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.select_related("issue")
.distinct()
)
class IssueRetrievePublicEndpoint(BaseAPIView): class IssueRetrievePublicEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
AllowAny, AllowAny,
@ -2078,7 +2236,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state # Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None] priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] 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")
@ -2240,3 +2398,256 @@ class ProjectIssuesPublicEndpoint(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 IssueDraftViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = IssueFlatSerializer
model = Issue
def perform_destroy(self, instance):
current_instance = (
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None:
issue_activity.delay(
type="issue_draft.activity.deleted",
requested_data=json.dumps(
{"issue_id": str(self.kwargs.get("pk", None))}
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
def get_queryset(self):
return (
Issue.objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(is_draft=True)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
try:
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
return Response(
group_results(issues, group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def create(self, request, slug, project_id):
try:
project = Project.objects.get(pk=project_id)
serializer = IssueCreateSerializer(
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
},
)
if serializer.is_valid():
serializer.save(is_draft=True)
# Track the issue
issue_activity.delay(
type="issue_draft.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Project.DoesNotExist:
return Response(
{"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND
)
def partial_update(self, request, slug, project_id, pk):
try:
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
serializer = IssueSerializer(
issue, data=request.data, partial=True
)
if serializer.is_valid():
if(request.data.get("is_draft") is not None and not request.data.get("is_draft")):
serializer.save(created_at=timezone.now(), updated_at=timezone.now())
else:
serializer.save()
issue_activity.delay(
type="issue_draft.activity.updated",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueSerializer(issue).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Issue.DoesNotExist:
return Response(
{"error": "Issue does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def retrieve(self, request, slug, project_id, pk=None):
try:
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
except Issue.DoesNotExist:
return Response(
{"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND
)

View File

@ -2,6 +2,7 @@
import json import json
# Django Imports # Django Imports
from django.utils import timezone
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q
from django.core import serializers from django.core import serializers
@ -39,6 +40,7 @@ from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters from plane.utils.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 = [
@ -77,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")
@ -129,6 +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())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -177,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")
@ -204,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")
@ -277,6 +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())
) )
return super().perform_destroy(instance) return super().perform_destroy(instance)
@ -308,6 +374,7 @@ class ModuleIssueViewSet(BaseViewSet):
try: try:
order_by = request.GET.get("order_by", "created_at") order_by = request.GET.get("order_by", "created_at")
group_by = request.GET.get("group_by", False) group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
issues = ( issues = (
Issue.issue_objects.filter(issue_module__module_id=module_id) Issue.issue_objects.filter(issue_module__module_id=module_id)
@ -346,9 +413,15 @@ class ModuleIssueViewSet(BaseViewSet):
issues_data = IssueStateSerializer(issues, many=True).data issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by: if group_by:
return Response( return Response(
group_results(issues_data, group_by), group_results(issues_data, group_by, sub_group_by),
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@ -437,6 +510,7 @@ class ModuleIssueViewSet(BaseViewSet):
), ),
} }
), ),
epoch=int(timezone.now().timestamp())
) )
return Response( return Response(
@ -483,7 +557,6 @@ class ModuleLinkViewSet(BaseViewSet):
class ModuleFavoriteViewSet(BaseViewSet): class ModuleFavoriteViewSet(BaseViewSet):
serializer_class = ModuleFavoriteSerializer serializer_class = ModuleFavoriteSerializer
model = ModuleFavorite model = ModuleFavorite

View File

@ -1094,7 +1094,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:

View File

@ -220,7 +220,7 @@ class IssueSearchEndpoint(BaseAPIView):
query = request.query_params.get("search", False) query = request.query_params.get("search", False)
workspace_search = request.query_params.get("workspace_search", "false") workspace_search = request.query_params.get("workspace_search", "false")
parent = request.query_params.get("parent", "false") parent = request.query_params.get("parent", "false")
blocker_blocked_by = request.query_params.get("blocker_blocked_by", "false") issue_relation = request.query_params.get("issue_relation", "false")
cycle = request.query_params.get("cycle", "false") cycle = request.query_params.get("cycle", "false")
module = request.query_params.get("module", "false") module = request.query_params.get("module", "false")
sub_issue = request.query_params.get("sub_issue", "false") sub_issue = request.query_params.get("sub_issue", "false")
@ -247,12 +247,12 @@ class IssueSearchEndpoint(BaseAPIView):
"parent_id", flat=True "parent_id", flat=True
) )
) )
if blocker_blocked_by == "true" and issue_id: if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id) issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter( issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue_id),
~Q(blocked_issues__block=issue), ~Q(issue_related__issue=issue),
~Q(blocker_issues__blocked_by=issue), ~Q(issue_relation__related_issue=issue),
) )
if sub_issue == "true" and issue_id: if sub_issue == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id) issue = Issue.issue_objects.get(pk=issue_id)

View File

@ -1,4 +1,18 @@
# Django imports # Django imports
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Case,
Value,
CharField,
When,
Exists,
Max,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Prefetch, OuterRef, Exists from django.db.models import Prefetch, OuterRef, Exists
@ -10,18 +24,192 @@ from sentry_sdk import capture_exception
# Module imports # Module imports
from . import BaseViewSet, BaseAPIView from . import BaseViewSet, BaseAPIView
from plane.api.serializers import ( from plane.api.serializers import (
GlobalViewSerializer,
IssueViewSerializer, IssueViewSerializer,
IssueLiteSerializer, IssueLiteSerializer,
IssueViewFavoriteSerializer, IssueViewFavoriteSerializer,
) )
from plane.api.permissions import ProjectEntityPermission from plane.api.permissions import WorkspaceEntityPermission, ProjectEntityPermission
from plane.db.models import ( from plane.db.models import (
Workspace,
GlobalView,
IssueView, IssueView,
Issue, Issue,
IssueViewFavorite, IssueViewFavorite,
IssueReaction, IssueReaction,
IssueLink,
IssueAttachment,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
class GlobalViewViewSet(BaseViewSet):
serializer_class = GlobalViewSerializer
model = GlobalView
permission_classes = [
WorkspaceEntityPermission,
]
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(workspace_id=workspace.id)
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace")
.order_by(self.request.GET.get("order_by", "-created_at"))
.distinct()
)
class GlobalViewIssuesViewSet(BaseViewSet):
permission_classes = [
WorkspaceEntityPermission,
]
def get_queryset(self):
return (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
@method_decorator(gzip_page)
def list(self, request, slug):
try:
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
.filter(project__project_projectmember__member=self.request.user)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueViewViewSet(BaseViewSet): class IssueViewViewSet(BaseViewSet):

View File

@ -1072,7 +1072,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
.order_by("state_group") .order_by("state_group")
) )
priority_order = ["urgent", "high", "medium", "low", None] priority_order = ["urgent", "high", "medium", "low", "none"]
priority_distribution = ( priority_distribution = (
Issue.issue_objects.filter( Issue.issue_objects.filter(
@ -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(

View File

@ -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"),

View File

@ -39,6 +39,7 @@ def track_name(
project, project,
actor, actor,
issue_activities, issue_activities,
epoch
): ):
if current_instance.get("name") != requested_data.get("name"): if current_instance.get("name") != requested_data.get("name"):
issue_activities.append( issue_activities.append(
@ -52,6 +53,7 @@ def track_name(
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"updated the name to {requested_data.get('name')}", comment=f"updated the name to {requested_data.get('name')}",
epoch=epoch,
) )
) )
@ -64,6 +66,7 @@ def track_parent(
project, project,
actor, actor,
issue_activities, issue_activities,
epoch
): ):
if current_instance.get("parent") != requested_data.get("parent"): if current_instance.get("parent") != requested_data.get("parent"):
if requested_data.get("parent") == None: if requested_data.get("parent") == None:
@ -81,6 +84,7 @@ def track_parent(
comment=f"updated the parent issue to None", comment=f"updated the parent issue to None",
old_identifier=old_parent.id, old_identifier=old_parent.id,
new_identifier=None, new_identifier=None,
epoch=epoch,
) )
) )
else: else:
@ -101,6 +105,7 @@ def track_parent(
comment=f"updated the parent issue to {new_parent.name}", comment=f"updated the parent issue to {new_parent.name}",
old_identifier=old_parent.id if old_parent is not None else None, old_identifier=old_parent.id if old_parent is not None else None,
new_identifier=new_parent.id, new_identifier=new_parent.id,
epoch=epoch,
) )
) )
@ -113,36 +118,23 @@ def track_priority(
project, project,
actor, actor,
issue_activities, issue_activities,
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,
)
)
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')}",
)
) )
)
# Track chnages in state of the issue # Track chnages in state of the issue
@ -153,6 +145,7 @@ def track_state(
project, project,
actor, actor,
issue_activities, issue_activities,
epoch
): ):
if current_instance.get("state") != requested_data.get("state"): if current_instance.get("state") != requested_data.get("state"):
new_state = State.objects.get(pk=requested_data.get("state", None)) new_state = State.objects.get(pk=requested_data.get("state", None))
@ -171,6 +164,7 @@ def track_state(
comment=f"updated the state to {new_state.name}", comment=f"updated the state to {new_state.name}",
old_identifier=old_state.id, old_identifier=old_state.id,
new_identifier=new_state.id, new_identifier=new_state.id,
epoch=epoch,
) )
) )
@ -183,6 +177,7 @@ def track_description(
project, project,
actor, actor,
issue_activities, issue_activities,
epoch
): ):
if current_instance.get("description_html") != requested_data.get( if current_instance.get("description_html") != requested_data.get(
"description_html" "description_html"
@ -203,6 +198,7 @@ def track_description(
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"updated the description to {requested_data.get('description_html')}", comment=f"updated the description to {requested_data.get('description_html')}",
epoch=epoch,
) )
) )
@ -215,6 +211,7 @@ def track_target_date(
project, project,
actor, actor,
issue_activities, issue_activities,
epoch
): ):
if current_instance.get("target_date") != requested_data.get("target_date"): if current_instance.get("target_date") != requested_data.get("target_date"):
if requested_data.get("target_date") == None: if requested_data.get("target_date") == None:
@ -229,6 +226,7 @@ def track_target_date(
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"updated the target date to None", comment=f"updated the target date to None",
epoch=epoch,
) )
) )
else: else:
@ -243,6 +241,7 @@ def track_target_date(
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"updated the target date to {requested_data.get('target_date')}", comment=f"updated the target date to {requested_data.get('target_date')}",
epoch=epoch,
) )
) )
@ -255,6 +254,7 @@ def track_start_date(
project, project,
actor, actor,
issue_activities, issue_activities,
epoch
): ):
if current_instance.get("start_date") != requested_data.get("start_date"): if current_instance.get("start_date") != requested_data.get("start_date"):
if requested_data.get("start_date") == None: if requested_data.get("start_date") == None:
@ -269,6 +269,7 @@ def track_start_date(
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"updated the start date to None", comment=f"updated the start date to None",
epoch=epoch,
) )
) )
else: else:
@ -283,6 +284,7 @@ def track_start_date(
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"updated the start date to {requested_data.get('start_date')}", comment=f"updated the start date to {requested_data.get('start_date')}",
epoch=epoch,
) )
) )
@ -295,6 +297,7 @@ def track_labels(
project, project,
actor, actor,
issue_activities, issue_activities,
epoch
): ):
# Label Addition # Label Addition
if len(requested_data.get("labels_list")) > len(current_instance.get("labels")): if len(requested_data.get("labels_list")) > len(current_instance.get("labels")):
@ -314,6 +317,7 @@ def track_labels(
comment=f"added label {label.name}", comment=f"added label {label.name}",
new_identifier=label.id, new_identifier=label.id,
old_identifier=None, old_identifier=None,
epoch=epoch,
) )
) )
@ -335,6 +339,7 @@ def track_labels(
comment=f"removed label {label.name}", comment=f"removed label {label.name}",
old_identifier=label.id, old_identifier=label.id,
new_identifier=None, new_identifier=None,
epoch=epoch,
) )
) )
@ -347,6 +352,7 @@ def track_assignees(
project, project,
actor, actor,
issue_activities, issue_activities,
epoch
): ):
# Assignee Addition # Assignee Addition
if len(requested_data.get("assignees_list")) > len( if len(requested_data.get("assignees_list")) > len(
@ -367,6 +373,7 @@ def track_assignees(
workspace=project.workspace, workspace=project.workspace,
comment=f"added assignee {assignee.display_name}", comment=f"added assignee {assignee.display_name}",
new_identifier=assignee.id, new_identifier=assignee.id,
epoch=epoch,
) )
) )
@ -389,151 +396,29 @@ def track_assignees(
workspace=project.workspace, workspace=project.workspace,
comment=f"removed assignee {assignee.display_name}", comment=f"removed assignee {assignee.display_name}",
old_identifier=assignee.id, old_identifier=assignee.id,
) epoch=epoch,
)
# Track changes in blocking issues
def track_blocks(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blocks_list")) > len(
current_instance.get("blocked_issues")
):
for block in requested_data.get("blocks_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocked_issues")
if blocked.get("block") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"added blocking issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
if len(requested_data.get("blocks_list")) < len(
current_instance.get("blocked_issues")
):
for blocked in current_instance.get("blocked_issues"):
if blocked.get("block") not in requested_data.get("blocks_list"):
issue = Issue.objects.get(pk=blocked.get("block"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{issue.project.identifier}-{issue.sequence_id}",
new_value="",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"removed blocking issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
# Track changes in blocked_by issues
def track_blockings(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blockers_list")) > len(
current_instance.get("blocker_issues")
):
for block in requested_data.get("blockers_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocker_issues")
if blocked.get("blocked_by") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"added blocked by issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
if len(requested_data.get("blockers_list")) < len(
current_instance.get("blocker_issues")
):
for blocked in current_instance.get("blocker_issues"):
if blocked.get("blocked_by") not in requested_data.get("blockers_list"):
issue = Issue.objects.get(pk=blocked.get("blocked_by"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{issue.project.identifier}-{issue.sequence_id}",
new_value="",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"removed blocked by issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
) )
) )
def create_issue_activity( def create_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
issue_activities.append( issue_activities.append(
IssueActivity( IssueActivity(
issue_id=issue_id, issue_id=issue_id,
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"created the issue", comment=f"created the issue",
verb="created", verb="created",
actor=actor, actor=actor,
epoch=epoch,
)
) )
)
def track_estimate_points( def track_estimate_points(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
if current_instance.get("estimate_point") != requested_data.get("estimate_point"): if current_instance.get("estimate_point") != requested_data.get("estimate_point"):
if requested_data.get("estimate_point") == None: if requested_data.get("estimate_point") == None:
@ -548,6 +433,7 @@ def track_estimate_points(
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"updated the estimate point to None", comment=f"updated the estimate point to None",
epoch=epoch,
) )
) )
else: else:
@ -562,12 +448,13 @@ def track_estimate_points(
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"updated the estimate point to {requested_data.get('estimate_point')}", comment=f"updated the estimate point to {requested_data.get('estimate_point')}",
epoch=epoch,
) )
) )
def track_archive_at( def track_archive_at(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
if requested_data.get("archived_at") is None: if requested_data.get("archived_at") is None:
issue_activities.append( issue_activities.append(
@ -581,6 +468,7 @@ def track_archive_at(
field="archived_at", field="archived_at",
old_value="archive", old_value="archive",
new_value="restore", new_value="restore",
epoch=epoch,
) )
) )
else: else:
@ -595,12 +483,13 @@ def track_archive_at(
field="archived_at", field="archived_at",
old_value=None, old_value=None,
new_value="archive", new_value="archive",
epoch=epoch,
) )
) )
def track_closed_to( def track_closed_to(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
if requested_data.get("closed_to") is not None: if requested_data.get("closed_to") is not None:
updated_state = State.objects.get( updated_state = State.objects.get(
@ -620,12 +509,13 @@ def track_closed_to(
comment=f"Plane updated the state to {updated_state.name}", comment=f"Plane updated the state to {updated_state.name}",
old_identifier=None, old_identifier=None,
new_identifier=updated_state.id, new_identifier=updated_state.id,
epoch=epoch,
) )
) )
def update_issue_activity( def update_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
ISSUE_ACTIVITY_MAPPER = { ISSUE_ACTIVITY_MAPPER = {
"name": track_name, "name": track_name,
@ -637,8 +527,6 @@ def update_issue_activity(
"start_date": track_start_date, "start_date": track_start_date,
"labels_list": track_labels, "labels_list": track_labels,
"assignees_list": track_assignees, "assignees_list": track_assignees,
"blocks_list": track_blocks,
"blockers_list": track_blockings,
"estimate_point": track_estimate_points, "estimate_point": track_estimate_points,
"archived_at": track_archive_at, "archived_at": track_archive_at,
"closed_to": track_closed_to, "closed_to": track_closed_to,
@ -659,11 +547,12 @@ def update_issue_activity(
project, project,
actor, actor,
issue_activities, issue_activities,
epoch
) )
def delete_issue_activity( def delete_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
issue_activities.append( issue_activities.append(
IssueActivity( IssueActivity(
@ -673,12 +562,13 @@ def delete_issue_activity(
verb="deleted", verb="deleted",
actor=actor, actor=actor,
field="issue", field="issue",
epoch=epoch,
) )
) )
def create_comment_activity( def create_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
requested_data = json.loads(requested_data) if requested_data is not None else None requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = ( current_instance = (
@ -697,12 +587,13 @@ def create_comment_activity(
new_value=requested_data.get("comment_html", ""), new_value=requested_data.get("comment_html", ""),
new_identifier=requested_data.get("id", None), new_identifier=requested_data.get("id", None),
issue_comment_id=requested_data.get("id", None), issue_comment_id=requested_data.get("id", None),
epoch=epoch,
) )
) )
def update_comment_activity( def update_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
requested_data = json.loads(requested_data) if requested_data is not None else None requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = ( current_instance = (
@ -724,12 +615,13 @@ def update_comment_activity(
new_value=requested_data.get("comment_html", ""), new_value=requested_data.get("comment_html", ""),
new_identifier=current_instance.get("id", None), new_identifier=current_instance.get("id", None),
issue_comment_id=current_instance.get("id", None), issue_comment_id=current_instance.get("id", None),
epoch=epoch,
) )
) )
def delete_comment_activity( def delete_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
issue_activities.append( issue_activities.append(
IssueActivity( IssueActivity(
@ -740,12 +632,13 @@ def delete_comment_activity(
verb="deleted", verb="deleted",
actor=actor, actor=actor,
field="comment", field="comment",
epoch=epoch,
) )
) )
def create_cycle_issue_activity( def create_cycle_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
requested_data = json.loads(requested_data) if requested_data is not None else None requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = ( current_instance = (
@ -777,6 +670,7 @@ def create_cycle_issue_activity(
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}", comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id, old_identifier=old_cycle.id,
new_identifier=new_cycle.id, new_identifier=new_cycle.id,
epoch=epoch,
) )
) )
@ -797,12 +691,13 @@ def create_cycle_issue_activity(
workspace=project.workspace, workspace=project.workspace,
comment=f"added cycle {cycle.name}", comment=f"added cycle {cycle.name}",
new_identifier=cycle.id, new_identifier=cycle.id,
epoch=epoch,
) )
) )
def delete_cycle_issue_activity( def delete_cycle_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
requested_data = json.loads(requested_data) if requested_data is not None else None requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = ( current_instance = (
@ -826,12 +721,13 @@ def delete_cycle_issue_activity(
workspace=project.workspace, workspace=project.workspace,
comment=f"removed this issue from {cycle.name if cycle is not None else None}", comment=f"removed this issue from {cycle.name if cycle is not None else None}",
old_identifier=cycle.id if cycle is not None else None, old_identifier=cycle.id if cycle is not None else None,
epoch=epoch,
) )
) )
def create_module_issue_activity( def create_module_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
requested_data = json.loads(requested_data) if requested_data is not None else None requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = ( current_instance = (
@ -863,6 +759,7 @@ def create_module_issue_activity(
comment=f"updated module from {old_module.name} to {new_module.name}", comment=f"updated module from {old_module.name} to {new_module.name}",
old_identifier=old_module.id, old_identifier=old_module.id,
new_identifier=new_module.id, new_identifier=new_module.id,
epoch=epoch,
) )
) )
@ -882,12 +779,13 @@ def create_module_issue_activity(
workspace=project.workspace, workspace=project.workspace,
comment=f"added module {module.name}", comment=f"added module {module.name}",
new_identifier=module.id, new_identifier=module.id,
epoch=epoch,
) )
) )
def delete_module_issue_activity( def delete_module_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
requested_data = json.loads(requested_data) if requested_data is not None else None requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = ( current_instance = (
@ -911,12 +809,13 @@ def delete_module_issue_activity(
workspace=project.workspace, workspace=project.workspace,
comment=f"removed this issue from {module.name if module is not None else None}", comment=f"removed this issue from {module.name if module is not None else None}",
old_identifier=module.id if module is not None else None, old_identifier=module.id if module is not None else None,
epoch=epoch,
) )
) )
def create_link_activity( def create_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
requested_data = json.loads(requested_data) if requested_data is not None else None requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = ( current_instance = (
@ -934,12 +833,13 @@ def create_link_activity(
field="link", field="link",
new_value=requested_data.get("url", ""), new_value=requested_data.get("url", ""),
new_identifier=requested_data.get("id", None), new_identifier=requested_data.get("id", None),
epoch=epoch,
) )
) )
def update_link_activity( def update_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
requested_data = json.loads(requested_data) if requested_data is not None else None requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = ( current_instance = (
@ -960,12 +860,13 @@ def update_link_activity(
old_identifier=current_instance.get("id"), old_identifier=current_instance.get("id"),
new_value=requested_data.get("url", ""), new_value=requested_data.get("url", ""),
new_identifier=current_instance.get("id", None), new_identifier=current_instance.get("id", None),
epoch=epoch,
) )
) )
def delete_link_activity( def delete_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
current_instance = ( current_instance = (
@ -982,13 +883,14 @@ def delete_link_activity(
actor=actor, actor=actor,
field="link", field="link",
old_value=current_instance.get("url", ""), old_value=current_instance.get("url", ""),
new_value="" new_value="",
epoch=epoch,
) )
) )
def create_attachment_activity( def create_attachment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
requested_data = json.loads(requested_data) if requested_data is not None else None requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = ( current_instance = (
@ -1006,12 +908,13 @@ def create_attachment_activity(
field="attachment", field="attachment",
new_value=current_instance.get("asset", ""), new_value=current_instance.get("asset", ""),
new_identifier=current_instance.get("id", None), new_identifier=current_instance.get("id", None),
epoch=epoch,
) )
) )
def delete_attachment_activity( def delete_attachment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
issue_activities.append( issue_activities.append(
IssueActivity( IssueActivity(
@ -1022,11 +925,12 @@ def delete_attachment_activity(
verb="deleted", verb="deleted",
actor=actor, actor=actor,
field="attachment", field="attachment",
epoch=epoch,
) )
) )
def create_issue_reaction_activity( def create_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
requested_data = json.loads(requested_data) if requested_data is not None else None requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None: if requested_data and requested_data.get("reaction") is not None:
@ -1045,12 +949,13 @@ def create_issue_reaction_activity(
comment="added the reaction", comment="added the reaction",
old_identifier=None, old_identifier=None,
new_identifier=issue_reaction, new_identifier=issue_reaction,
epoch=epoch,
) )
) )
def delete_issue_reaction_activity( def delete_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
current_instance = ( current_instance = (
json.loads(current_instance) if current_instance is not None else None json.loads(current_instance) if current_instance is not None else None
@ -1069,12 +974,13 @@ def delete_issue_reaction_activity(
comment="removed the reaction", comment="removed the reaction",
old_identifier=current_instance.get("identifier"), old_identifier=current_instance.get("identifier"),
new_identifier=None, new_identifier=None,
epoch=epoch,
) )
) )
def create_comment_reaction_activity( def create_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
requested_data = json.loads(requested_data) if requested_data is not None else None requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None: if requested_data and requested_data.get("reaction") is not None:
@ -1094,12 +1000,13 @@ def create_comment_reaction_activity(
comment="added the reaction", comment="added the reaction",
old_identifier=None, old_identifier=None,
new_identifier=comment_reaction_id, new_identifier=comment_reaction_id,
epoch=epoch,
) )
) )
def delete_comment_reaction_activity( def delete_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
current_instance = ( current_instance = (
json.loads(current_instance) if current_instance is not None else None json.loads(current_instance) if current_instance is not None else None
@ -1120,12 +1027,13 @@ def delete_comment_reaction_activity(
comment="removed the reaction", comment="removed the reaction",
old_identifier=current_instance.get("identifier"), old_identifier=current_instance.get("identifier"),
new_identifier=None, new_identifier=None,
epoch=epoch,
) )
) )
def create_issue_vote_activity( def create_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
requested_data = json.loads(requested_data) if requested_data is not None else None requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("vote") is not None: if requested_data and requested_data.get("vote") is not None:
@ -1142,12 +1050,13 @@ def create_issue_vote_activity(
comment="added the vote", comment="added the vote",
old_identifier=None, old_identifier=None,
new_identifier=None, new_identifier=None,
epoch=epoch,
) )
) )
def delete_issue_vote_activity( def delete_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
): ):
current_instance = ( current_instance = (
json.loads(current_instance) if current_instance is not None else None json.loads(current_instance) if current_instance is not None else None
@ -1166,10 +1075,170 @@ def delete_issue_vote_activity(
comment="removed the vote", comment="removed the vote",
old_identifier=current_instance.get("identifier"), old_identifier=current_instance.get("identifier"),
new_identifier=None, new_identifier=None,
epoch=epoch,
) )
) )
def create_issue_relation_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance is None and requested_data.get("related_list") is not None:
for issue_relation in requested_data.get("related_list"):
if issue_relation.get("relation_type") == "blocked_by":
relation_type = "blocking"
else:
relation_type = issue_relation.get("relation_type")
issue = Issue.objects.get(pk=issue_relation.get("issue"))
issue_activities.append(
IssueActivity(
issue_id=issue_relation.get("related_issue"),
actor=actor,
verb="created",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field=relation_type,
project=project,
workspace=project.workspace,
comment=f'added {relation_type} relation',
old_identifier=issue_relation.get("issue"),
)
)
issue = Issue.objects.get(pk=issue_relation.get("related_issue"))
issue_activities.append(
IssueActivity(
issue_id=issue_relation.get("issue"),
actor=actor,
verb="created",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field=f'{issue_relation.get("relation_type")}',
project=project,
workspace=project.workspace,
comment=f'added {issue_relation.get("relation_type")} relation',
old_identifier=issue_relation.get("related_issue"),
epoch=epoch,
)
)
def delete_issue_relation_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if current_instance is not None and requested_data.get("related_list") is None:
if current_instance.get("relation_type") == "blocked_by":
relation_type = "blocking"
else:
relation_type = current_instance.get("relation_type")
issue = Issue.objects.get(pk=current_instance.get("issue"))
issue_activities.append(
IssueActivity(
issue_id=current_instance.get("related_issue"),
actor=actor,
verb="deleted",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field=relation_type,
project=project,
workspace=project.workspace,
comment=f'deleted {relation_type} relation',
old_identifier=current_instance.get("issue"),
epoch=epoch,
)
)
issue = Issue.objects.get(pk=current_instance.get("related_issue"))
issue_activities.append(
IssueActivity(
issue_id=current_instance.get("issue"),
actor=actor,
verb="deleted",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field=f'{current_instance.get("relation_type")}',
project=project,
workspace=project.workspace,
comment=f'deleted {current_instance.get("relation_type")} relation',
old_identifier=current_instance.get("related_issue"),
epoch=epoch,
)
)
def create_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"drafted the issue",
field="draft",
verb="created",
actor=actor,
epoch=epoch,
)
)
def update_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
if requested_data.get("is_draft") is not None and requested_data.get("is_draft") == False:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"created the issue",
verb="updated",
actor=actor,
epoch=epoch,
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"updated the draft issue",
field="draft",
verb="updated",
actor=actor,
epoch=epoch,
)
)
def delete_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
project=project,
workspace=project.workspace,
comment=f"deleted the draft issue",
field="draft",
verb="deleted",
actor=actor,
epoch=epoch,
)
)
# Receive message from room group # Receive message from room group
@shared_task @shared_task
def issue_activity( def issue_activity(
@ -1179,6 +1248,7 @@ def issue_activity(
issue_id, issue_id,
actor_id, actor_id,
project_id, project_id,
epoch,
subscriber=True, subscriber=True,
): ):
try: try:
@ -1233,12 +1303,17 @@ def issue_activity(
"link.activity.deleted": delete_link_activity, "link.activity.deleted": delete_link_activity,
"attachment.activity.created": create_attachment_activity, "attachment.activity.created": create_attachment_activity,
"attachment.activity.deleted": delete_attachment_activity, "attachment.activity.deleted": delete_attachment_activity,
"issue_relation.activity.created": create_issue_relation_activity,
"issue_relation.activity.deleted": delete_issue_relation_activity,
"issue_reaction.activity.created": create_issue_reaction_activity, "issue_reaction.activity.created": create_issue_reaction_activity,
"issue_reaction.activity.deleted": delete_issue_reaction_activity, "issue_reaction.activity.deleted": delete_issue_reaction_activity,
"comment_reaction.activity.created": create_comment_reaction_activity, "comment_reaction.activity.created": create_comment_reaction_activity,
"comment_reaction.activity.deleted": delete_comment_reaction_activity, "comment_reaction.activity.deleted": delete_comment_reaction_activity,
"issue_vote.activity.created": create_issue_vote_activity, "issue_vote.activity.created": create_issue_vote_activity,
"issue_vote.activity.deleted": delete_issue_vote_activity, "issue_vote.activity.deleted": delete_issue_vote_activity,
"issue_draft.activity.created": create_draft_issue_activity,
"issue_draft.activity.updated": update_draft_issue_activity,
"issue_draft.activity.deleted": delete_draft_issue_activity,
} }
func = ACTIVITY_MAPPER.get(type) func = ACTIVITY_MAPPER.get(type)
@ -1250,6 +1325,7 @@ def issue_activity(
project, project,
actor, actor,
issue_activities, issue_activities,
epoch,
) )
# Save all the values to database # Save all the values to database
@ -1313,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(

View File

@ -58,27 +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())
) )
for issue in updated_issues for issue in issues_to_update
] ]
return return
except Exception as e: except Exception as e:
@ -138,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",
@ -148,8 +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())
) )
for issue in updated_issues for issue in issues_to_update
] ]
return return
except Exception as e: except Exception as e:

View File

@ -0,0 +1,83 @@
# Generated by Django 4.2.3 on 2023-09-12 07:29
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
from plane.db.models import IssueRelation
from sentry_sdk import capture_exception
import uuid
def create_issue_relation(apps, schema_editor):
try:
IssueBlockerModel = apps.get_model("db", "IssueBlocker")
updated_issue_relation = []
for blocked_issue in IssueBlockerModel.objects.all():
updated_issue_relation.append(
IssueRelation(
issue_id=blocked_issue.block_id,
related_issue_id=blocked_issue.blocked_by_id,
relation_type="blocked_by",
project_id=blocked_issue.project_id,
workspace_id=blocked_issue.workspace_id,
created_by_id=blocked_issue.created_by_id,
updated_by_id=blocked_issue.updated_by_id,
)
)
IssueRelation.objects.bulk_create(updated_issue_relation, batch_size=100)
except Exception as e:
print(e)
capture_exception(e)
def update_issue_priority_choice(apps, schema_editor):
IssueModel = apps.get_model("db", "Issue")
updated_issues = []
for obj in IssueModel.objects.filter(priority=None):
obj.priority = "none"
updated_issues.append(obj)
IssueModel.objects.bulk_update(updated_issues, ["priority"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('db', '0042_alter_analyticview_created_by_and_more'),
]
operations = [
migrations.CreateModel(
name='IssueRelation',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('relation_type', models.CharField(choices=[('duplicate', 'Duplicate'), ('relates_to', 'Relates To'), ('blocked_by', 'Blocked By')], default='blocked_by', max_length=20, verbose_name='Issue Relation Type')),
('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')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_relation', to='db.issue')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('related_issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_related', to='db.issue')),
('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='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Issue Relation',
'verbose_name_plural': 'Issue Relations',
'db_table': 'issue_relations',
'ordering': ('-created_at',),
'unique_together': {('issue', 'related_issue')},
},
),
migrations.AddField(
model_name='issue',
name='is_draft',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='issue',
name='priority',
field=models.CharField(choices=[('urgent', 'Urgent'), ('high', 'High'), ('medium', 'Medium'), ('low', 'Low'), ('none', 'None')], default='none', max_length=30, verbose_name='Issue Priority'),
),
migrations.RunPython(create_issue_relation),
migrations.RunPython(update_issue_priority_choice),
]

View File

@ -0,0 +1,138 @@
# Generated by Django 4.2.3 on 2023-09-13 07:09
from django.db import migrations
def workspace_member_props(old_props):
new_props = {
"filters": {
"priority": old_props.get("filters", {}).get("priority", None),
"state": old_props.get("filters", {}).get("state", None),
"state_group": old_props.get("filters", {}).get("state_group", None),
"assignees": old_props.get("filters", {}).get("assignees", None),
"created_by": old_props.get("filters", {}).get("created_by", None),
"labels": old_props.get("filters", {}).get("labels", None),
"start_date": old_props.get("filters", {}).get("start_date", None),
"target_date": old_props.get("filters", {}).get("target_date", None),
"subscriber": old_props.get("filters", {}).get("subscriber", None),
},
"display_filters": {
"group_by": old_props.get("groupByProperty", None),
"order_by": old_props.get("orderBy", "-created_at"),
"type": old_props.get("filters", {}).get("type", None),
"sub_issue": old_props.get("showSubIssues", True),
"show_empty_groups": old_props.get("showEmptyGroups", True),
"layout": old_props.get("issueView", "list"),
"calendar_date_range": old_props.get("calendarDateRange", ""),
},
"display_properties": {
"assignee": old_props.get("properties", {}).get("assignee", True),
"attachment_count": old_props.get("properties", {}).get("attachment_count", True),
"created_on": old_props.get("properties", {}).get("created_on", True),
"due_date": old_props.get("properties", {}).get("due_date", True),
"estimate": old_props.get("properties", {}).get("estimate", True),
"key": old_props.get("properties", {}).get("key", True),
"labels": old_props.get("properties", {}).get("labels", True),
"link": old_props.get("properties", {}).get("link", True),
"priority": old_props.get("properties", {}).get("priority", True),
"start_date": old_props.get("properties", {}).get("start_date", True),
"state": old_props.get("properties", {}).get("state", True),
"sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", True),
"updated_on": old_props.get("properties", {}).get("updated_on", True),
},
}
return new_props
def project_member_props(old_props):
new_props = {
"filters": {
"priority": old_props.get("filters", {}).get("priority", None),
"state": old_props.get("filters", {}).get("state", None),
"state_group": old_props.get("filters", {}).get("state_group", None),
"assignees": old_props.get("filters", {}).get("assignees", None),
"created_by": old_props.get("filters", {}).get("created_by", None),
"labels": old_props.get("filters", {}).get("labels", None),
"start_date": old_props.get("filters", {}).get("start_date", None),
"target_date": old_props.get("filters", {}).get("target_date", None),
"subscriber": old_props.get("filters", {}).get("subscriber", None),
},
"display_filters": {
"group_by": old_props.get("groupByProperty", None),
"order_by": old_props.get("orderBy", "-created_at"),
"type": old_props.get("filters", {}).get("type", None),
"sub_issue": old_props.get("showSubIssues", True),
"show_empty_groups": old_props.get("showEmptyGroups", True),
"layout": old_props.get("issueView", "list"),
"calendar_date_range": old_props.get("calendarDateRange", ""),
},
}
return new_props
def cycle_module_props(old_props):
new_props = {
"filters": {
"priority": old_props.get("filters", {}).get("priority", None),
"state": old_props.get("filters", {}).get("state", None),
"state_group": old_props.get("filters", {}).get("state_group", None),
"assignees": old_props.get("filters", {}).get("assignees", None),
"created_by": old_props.get("filters", {}).get("created_by", None),
"labels": old_props.get("filters", {}).get("labels", None),
"start_date": old_props.get("filters", {}).get("start_date", None),
"target_date": old_props.get("filters", {}).get("target_date", None),
"subscriber": old_props.get("filters", {}).get("subscriber", None),
},
}
return new_props
def update_workspace_member_view_props(apps, schema_editor):
WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember")
updated_workspace_member = []
for obj in WorkspaceMemberModel.objects.all():
obj.view_props = workspace_member_props(obj.view_props)
obj.default_props = workspace_member_props(obj.default_props)
updated_workspace_member.append(obj)
WorkspaceMemberModel.objects.bulk_update(updated_workspace_member, ["view_props", "default_props"], batch_size=100)
def update_project_member_view_props(apps, schema_editor):
ProjectMemberModel = apps.get_model("db", "ProjectMember")
updated_project_member = []
for obj in ProjectMemberModel.objects.all():
obj.view_props = project_member_props(obj.view_props)
obj.default_props = project_member_props(obj.default_props)
updated_project_member.append(obj)
ProjectMemberModel.objects.bulk_update(updated_project_member, ["view_props", "default_props"], batch_size=100)
def update_cycle_props(apps, schema_editor):
CycleModel = apps.get_model("db", "Cycle")
updated_cycle = []
for obj in CycleModel.objects.all():
if "filter" in obj.view_props:
obj.view_props = cycle_module_props(obj.view_props)
updated_cycle.append(obj)
CycleModel.objects.bulk_update(updated_cycle, ["view_props"], batch_size=100)
def update_module_props(apps, schema_editor):
ModuleModel = apps.get_model("db", "Module")
updated_module = []
for obj in ModuleModel.objects.all():
if "filter" in obj.view_props:
obj.view_props = cycle_module_props(obj.view_props)
updated_module.append(obj)
ModuleModel.objects.bulk_update(updated_module, ["view_props"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('db', '0043_alter_analyticview_created_by_and_more'),
]
operations = [
migrations.RunPython(update_workspace_member_view_props),
migrations.RunPython(update_project_member_view_props),
migrations.RunPython(update_cycle_props),
migrations.RunPython(update_module_props),
]

View File

@ -0,0 +1,79 @@
# Generated by Django 4.2.5 on 2023-09-29 10:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.workspace
import uuid
def update_issue_activity_priority(apps, schema_editor):
IssueActivity = apps.get_model("db", "IssueActivity")
updated_issue_activity = []
for obj in IssueActivity.objects.filter(field="priority"):
# Set the old and new value to none if it is empty for Priority
obj.new_value = obj.new_value or "none"
obj.old_value = obj.old_value or "none"
updated_issue_activity.append(obj)
IssueActivity.objects.bulk_update(
updated_issue_activity,
["new_value", "old_value"],
batch_size=2000,
)
def update_issue_activity_blocked(apps, schema_editor):
IssueActivity = apps.get_model("db", "IssueActivity")
updated_issue_activity = []
for obj in IssueActivity.objects.filter(field="blocks"):
# Set the field to blocked_by
obj.field = "blocked_by"
updated_issue_activity.append(obj)
IssueActivity.objects.bulk_update(
updated_issue_activity,
["field"],
batch_size=1000,
)
class Migration(migrations.Migration):
dependencies = [
('db', '0044_auto_20230913_0709'),
]
operations = [
migrations.CreateModel(
name='GlobalView',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=255, verbose_name='View Name')),
('description', models.TextField(blank=True, verbose_name='View Description')),
('query', models.JSONField(verbose_name='View Query')),
('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)),
('query_data', models.JSONField(default=dict)),
('sort_order', models.FloatField(default=65535)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_views', to='db.workspace')),
],
options={
'verbose_name': 'Global View',
'verbose_name_plural': 'Global Views',
'db_table': 'global_views',
'ordering': ('-created_at',),
},
),
migrations.AddField(
model_name='workspacemember',
name='issue_props',
field=models.JSONField(default=plane.db.models.workspace.get_issue_props),
),
migrations.AddField(
model_name='issueactivity',
name='epoch',
field=models.FloatField(null=True),
),
migrations.RunPython(update_issue_activity_priority),
migrations.RunPython(update_issue_activity_blocked),
]

View File

@ -32,6 +32,7 @@ from .issue import (
IssueAssignee, IssueAssignee,
Label, Label,
IssueBlocker, IssueBlocker,
IssueRelation,
IssueLink, IssueLink,
IssueSequence, IssueSequence,
IssueAttachment, IssueAttachment,
@ -49,7 +50,7 @@ from .state import State
from .cycle import Cycle, CycleIssue, CycleFavorite from .cycle import Cycle, CycleIssue, CycleFavorite
from .view import IssueView, IssueViewFavorite from .view import GlobalView, IssueView, IssueViewFavorite
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite

View File

@ -29,6 +29,7 @@ class IssueManager(models.Manager):
| models.Q(issue_inbox__isnull=True) | models.Q(issue_inbox__isnull=True)
) )
.exclude(archived_at__isnull=False) .exclude(archived_at__isnull=False)
.exclude(is_draft=True)
) )
@ -38,6 +39,7 @@ class Issue(ProjectBaseModel):
("high", "High"), ("high", "High"),
("medium", "Medium"), ("medium", "Medium"),
("low", "Low"), ("low", "Low"),
("none", "None")
) )
parent = models.ForeignKey( parent = models.ForeignKey(
"self", "self",
@ -64,8 +66,7 @@ class Issue(ProjectBaseModel):
max_length=30, max_length=30,
choices=PRIORITY_CHOICES, choices=PRIORITY_CHOICES,
verbose_name="Issue Priority", verbose_name="Issue Priority",
null=True, default="none",
blank=True,
) )
start_date = models.DateField(null=True, blank=True) start_date = models.DateField(null=True, blank=True)
target_date = models.DateField(null=True, blank=True) target_date = models.DateField(null=True, blank=True)
@ -83,6 +84,7 @@ class Issue(ProjectBaseModel):
sort_order = models.FloatField(default=65535) sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True) completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True) archived_at = models.DateField(null=True)
is_draft = models.BooleanField(default=False)
objects = models.Manager() objects = models.Manager()
issue_objects = IssueManager() issue_objects = IssueManager()
@ -178,6 +180,37 @@ class IssueBlocker(ProjectBaseModel):
return f"{self.block.name} {self.blocked_by.name}" return f"{self.block.name} {self.blocked_by.name}"
class IssueRelation(ProjectBaseModel):
RELATION_CHOICES = (
("duplicate", "Duplicate"),
("relates_to", "Relates To"),
("blocked_by", "Blocked By"),
)
issue = models.ForeignKey(
Issue, related_name="issue_relation", on_delete=models.CASCADE
)
related_issue = models.ForeignKey(
Issue, related_name="issue_related", on_delete=models.CASCADE
)
relation_type = models.CharField(
max_length=20,
choices=RELATION_CHOICES,
verbose_name="Issue Relation Type",
default="blocked_by",
)
class Meta:
unique_together = ["issue", "related_issue"]
verbose_name = "Issue Relation"
verbose_name_plural = "Issue Relations"
db_table = "issue_relations"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.related_issue.name}"
class IssueAssignee(ProjectBaseModel): class IssueAssignee(ProjectBaseModel):
issue = models.ForeignKey( issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_assignee" Issue, on_delete=models.CASCADE, related_name="issue_assignee"
@ -276,6 +309,7 @@ class IssueActivity(ProjectBaseModel):
) )
old_identifier = models.UUIDField(null=True) old_identifier = models.UUIDField(null=True)
new_identifier = models.UUIDField(null=True) new_identifier = models.UUIDField(null=True)
epoch = models.FloatField(null=True)
class Meta: class Meta:
verbose_name = "Issue Activity" verbose_name = "Issue Activity"

View File

@ -25,13 +25,26 @@ ROLE_CHOICES = (
def get_default_props(): def get_default_props():
return { return {
"filters": {"type": None}, "filters": {
"orderBy": "-created_at", "priority": None,
"collapsed": True, "state": None,
"issueView": "list", "state_group": None,
"filterIssue": None, "assignees": None,
"groupByProperty": None, "created_by": None,
"showEmptyGroups": True, "labels": None,
"start_date": None,
"target_date": None,
"subscriber": None,
},
"display_filters": {
"group_by": None,
"order_by": '-created_at',
"type": None,
"sub_issue": True,
"show_empty_groups": True,
"layout": "list",
"calendar_date_range": "",
},
} }

View File

@ -3,7 +3,41 @@ from django.db import models
from django.conf import settings from django.conf import settings
# Module import # Module import
from . import ProjectBaseModel from . import ProjectBaseModel, BaseModel
class GlobalView(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="global_views"
)
name = models.CharField(max_length=255, verbose_name="View Name")
description = models.TextField(verbose_name="View Description", blank=True)
query = models.JSONField(verbose_name="View Query")
access = models.PositiveSmallIntegerField(
default=1, choices=((0, "Private"), (1, "Public"))
)
query_data = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta:
verbose_name = "Global View"
verbose_name_plural = "Global Views"
db_table = "global_views"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
largest_sort_order = GlobalView.objects.filter(
workspace=self.workspace
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(GlobalView, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the View"""
return f"{self.name} <{self.workspace.name}>"
class IssueView(ProjectBaseModel): class IssueView(ProjectBaseModel):

View File

@ -16,26 +16,50 @@ ROLE_CHOICES = (
def get_default_props(): def get_default_props():
return { return {
"filters": {"type": None}, "filters": {
"groupByProperty": None, "priority": None,
"issueView": "list", "state": None,
"orderBy": "-created_at", "state_group": None,
"properties": { "assignees": None,
"created_by": None,
"labels": None,
"start_date": None,
"target_date": None,
"subscriber": None,
},
"display_filters": {
"group_by": None,
"order_by": "-created_at",
"type": None,
"sub_issue": True,
"show_empty_groups": True,
"layout": "list",
"calendar_date_range": "",
},
"display_properties": {
"assignee": True, "assignee": True,
"attachment_count": True,
"created_on": True,
"due_date": True, "due_date": True,
"estimate": True,
"key": True, "key": True,
"labels": True, "labels": True,
"link": True,
"priority": True, "priority": True,
"start_date": True,
"state": True, "state": True,
"sub_issue_count": True, "sub_issue_count": True,
"attachment_count": True,
"link": True,
"estimate": True,
"created_on": True,
"updated_on": True, "updated_on": True,
"start_date": True, }
}, }
"showEmptyGroups": True,
def get_issue_props():
return {
"subscribed": True,
"assigned": True,
"created": True,
"all_issues": True,
} }
@ -74,6 +98,7 @@ class WorkspaceMember(BaseModel):
company_role = models.TextField(null=True, blank=True) 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"]

View File

@ -1,10 +1,8 @@
"""Production settings and globals.""" """Production settings and globals."""
from urllib.parse import urlparse
import ssl import ssl
import certifi import certifi
import dj_database_url import dj_database_url
from urllib.parse import urlparse
import sentry_sdk import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
@ -91,112 +89,89 @@ if bool(os.environ.get("SENTRY_DSN", False)):
profiles_sample_rate=1.0, profiles_sample_rate=1.0,
) )
if DOCKERIZED and USE_MINIO: # The AWS region to connect to.
INSTALLED_APPS += ("storages",) AWS_REGION = os.environ.get("AWS_REGION", "")
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
# The name of the bucket to store files in.
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get(
"AWS_S3_ENDPOINT_URL", "http://plane-minio:9000"
)
# Default permissions
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False
# Custom Domain settings # The AWS access key to use.
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost")) AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
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 access key to use. # The AWS secret access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "") AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
# The AWS secret access key to use. # The optional AWS session token to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "") # AWS_SESSION_TOKEN = ""
# The optional AWS session token to use. # The name of the bucket to store files in.
# AWS_SESSION_TOKEN = "" AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
# The name of the bucket to store files in. # How to construct S3 URLs ("auto", "path", "virtual").
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") AWS_S3_ADDRESSING_STYLE = "auto"
# How to construct S3 URLs ("auto", "path", "virtual"). # The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ADDRESSING_STYLE = "auto" AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
# The full URL to the S3 endpoint. Leave blank to use the default region URL. # A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "") AWS_S3_KEY_PREFIX = ""
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. # Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
AWS_S3_KEY_PREFIX = "" # token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
# and their permissions will be set to "public-read".
AWS_S3_BUCKET_AUTH = False
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication # How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, # is True. It also affects the "Cache-Control" header of the files.
# and their permissions will be set to "public-read". # Important: Changing this setting will not affect existing files.
AWS_S3_BUCKET_AUTH = False AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` # A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
# is True. It also affects the "Cache-Control" header of the files. # cannot be used with `AWS_S3_BUCKET_AUTH`.
# Important: Changing this setting will not affect existing files. AWS_S3_PUBLIC_URL = ""
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting # If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
# cannot be used with `AWS_S3_BUCKET_AUTH`. # understand the consequences before enabling.
AWS_S3_PUBLIC_URL = "" # Important: Changing this setting will not affect existing files.
AWS_S3_REDUCED_REDUNDANCY = False
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you # The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
# understand the consequences before enabling. # single `name` argument.
# Important: Changing this setting will not affect existing files. # Important: Changing this setting will not affect existing files.
AWS_S3_REDUCED_REDUNDANCY = False AWS_S3_CONTENT_DISPOSITION = ""
# The Content-Disposition header used when the file is downloaded. This 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_CONTENT_DISPOSITION = "" AWS_S3_CONTENT_LANGUAGE = ""
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a # A mapping of custom metadata for each file. Each value can be a string, or a function taking a
# single `name` argument. # single `name` argument.
# Important: Changing this setting will not affect existing files. # Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_LANGUAGE = "" AWS_S3_METADATA = {}
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a # If True, then files will be stored using AES256 server-side encryption.
# single `name` argument. # If this is a string value (e.g., "aws:kms"), that encryption type will be used.
# Important: Changing this setting will not affect existing files. # Otherwise, server-side encryption is not be enabled.
AWS_S3_METADATA = {} # Important: Changing this setting will not affect existing files.
AWS_S3_ENCRYPT_KEY = False
# If True, then files will be stored using AES256 server-side encryption. # The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
# If this is a string value (e.g., "aws:kms"), that encryption type will be used. # This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
# Otherwise, server-side encryption is not be enabled. # AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
# Important: Changing this setting will not affect existing files.
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 text files will be stored using gzip content encoding. Files will only be gzipped if their
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above). # compressed size is smaller than their uncompressed size.
# AWS_S3_KMS_ENCRYPTION_KEY_ID = "" # Important: Changing this setting will not affect existing files.
AWS_S3_GZIP = True
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their # The signature version to use for S3 requests.
# compressed size is smaller than their uncompressed size. AWS_S3_SIGNATURE_VERSION = None
# Important: Changing this setting will not affect existing files.
AWS_S3_GZIP = True
# The signature version to use for S3 requests. # If True, then files with the same name will overwrite each other. By default it's set to False to have
AWS_S3_SIGNATURE_VERSION = None # extra characters appended.
AWS_S3_FILE_OVERWRITE = False
# If True, then files with the same name will overwrite each other. By default it's set to False to have STORAGES["default"] = {
# extra characters appended. "BACKEND": "django_s3_storage.storage.S3Storage",
AWS_S3_FILE_OVERWRITE = False }
STORAGES["default"] = {
"BACKEND": "django_s3_storage.storage.S3Storage",
}
# AWS Settings End # AWS Settings End
@ -218,27 +193,16 @@ CSRF_COOKIE_SECURE = True
REDIS_URL = os.environ.get("REDIS_URL") REDIS_URL = os.environ.get("REDIS_URL")
if DOCKERIZED: CACHES = {
CACHES = { "default": {
"default": { "BACKEND": "django_redis.cache.RedisCache",
"BACKEND": "django_redis.cache.RedisCache", "LOCATION": REDIS_URL,
"LOCATION": REDIS_URL, "OPTIONS": {
"OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient",
"CLIENT_CLASS": "django_redis.client.DefaultClient", "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
}, },
}
}
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")
@ -261,19 +225,16 @@ 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()}"
) )
if DOCKERIZED: CELERY_RESULT_BACKEND = broker_url
CELERY_BROKER_URL = REDIS_URL CELERY_BROKER_URL = broker_url
CELERY_RESULT_BACKEND = REDIS_URL
else:
CELERY_RESULT_BACKEND = broker_url
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 or Disable signups
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Scout Settings # Scout Settings
SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False) 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"

View File

@ -0,0 +1,128 @@
"""Self hosted settings and globals."""
from urllib.parse import urlparse
import dj_database_url
from urllib.parse import urlparse
from .common import * # noqa
# Database
DEBUG = int(os.environ.get("DEBUG", 0)) == 1
# Docker configurations
DOCKERIZED = 1
USE_MINIO = 1
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "plane",
"USER": os.environ.get("PGUSER", ""),
"PASSWORD": os.environ.get("PGPASSWORD", ""),
"HOST": os.environ.get("PGHOST", ""),
}
}
# Parse database configuration from $DATABASE_URL
DATABASES["default"] = dj_database_url.config()
SITE_ID = 1
# File size limit
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
CORS_ALLOW_METHODS = [
"DELETE",
"GET",
"OPTIONS",
"PATCH",
"POST",
"PUT",
]
CORS_ALLOW_HEADERS = [
"accept",
"accept-encoding",
"authorization",
"content-type",
"dnt",
"origin",
"user-agent",
"x-csrftoken",
"x-requested-with",
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = True
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
INSTALLED_APPS += ("storages",)
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
# The name of the bucket to store files in.
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get(
"AWS_S3_ENDPOINT_URL", "http://plane-minio:9000"
)
# Default permissions
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False
# Custom Domain settings
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Allow all host headers
ALLOWED_HOSTS = [
"*",
]
# Security settings
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Redis URL
REDIS_URL = os.environ.get("REDIS_URL")
# Caches
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
# URL used for email redirects
WEB_URL = os.environ.get("WEB_URL", "http://localhost")
# Celery settings
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
# Enable or Disable signups
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Analytics
ANALYTICS_BASE_API = False
# OPEN AI Settings
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")

View File

@ -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

View File

@ -15,7 +15,7 @@ def resolve_keys(group_keys, value):
return value return value
def group_results(results_data, group_by): def group_results(results_data, group_by, sub_group_by=False):
"""group results data into certain group_by """group results data into certain group_by
Args: Args:
@ -25,38 +25,140 @@ def group_results(results_data, group_by):
Returns: Returns:
obj: grouped results obj: grouped results
""" """
response_dict = dict() if sub_group_by:
main_responsive_dict = dict()
if group_by == "priority": if sub_group_by == "priority":
response_dict = { main_responsive_dict = {
"urgent": [], "urgent": {},
"high": [], "high": {},
"medium": [], "medium": {},
"low": [], "low": {},
"None": [], "none": {},
} }
for value in results_data: for value in results_data:
group_attribute = resolve_keys(group_by, value) main_group_attribute = resolve_keys(sub_group_by, value)
if isinstance(group_attribute, list): group_attribute = resolve_keys(group_by, value)
if len(group_attribute): if isinstance(main_group_attribute, list) and not isinstance(group_attribute, list):
for attrib in group_attribute: if len(main_group_attribute):
if str(attrib) in response_dict: for attrib in main_group_attribute:
response_dict[str(attrib)].append(value) if str(attrib) not in main_responsive_dict:
else: main_responsive_dict[str(attrib)] = {}
response_dict[str(attrib)] = [] if str(group_attribute) in main_responsive_dict[str(attrib)]:
response_dict[str(attrib)].append(value) main_responsive_dict[str(attrib)][str(group_attribute)].append(value)
else: else:
if str(None) in response_dict: main_responsive_dict[str(attrib)][str(group_attribute)] = []
response_dict[str(None)].append(value) main_responsive_dict[str(attrib)][str(group_attribute)].append(value)
else: else:
response_dict[str(None)] = [] if str(None) not in main_responsive_dict:
response_dict[str(None)].append(value) main_responsive_dict[str(None)] = {}
else:
if str(group_attribute) in response_dict:
response_dict[str(group_attribute)].append(value)
else:
response_dict[str(group_attribute)] = []
response_dict[str(group_attribute)].append(value)
return response_dict if str(group_attribute) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][str(group_attribute)].append(value)
else:
main_responsive_dict[str(None)][str(group_attribute)] = []
main_responsive_dict[str(None)][str(group_attribute)].append(value)
elif isinstance(group_attribute, list) and not isinstance(main_group_attribute, list):
if str(main_group_attribute) not in main_responsive_dict:
main_responsive_dict[str(main_group_attribute)] = {}
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in main_responsive_dict[str(main_group_attribute)]:
main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value)
else:
main_responsive_dict[str(main_group_attribute)][str(attrib)] = []
main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value)
else:
if str(None) in main_responsive_dict[str(main_group_attribute)]:
main_responsive_dict[str(main_group_attribute)][str(None)].append(value)
else:
main_responsive_dict[str(main_group_attribute)][str(None)] = []
main_responsive_dict[str(main_group_attribute)][str(None)].append(value)
elif isinstance(group_attribute, list) and isinstance(main_group_attribute, list):
if len(main_group_attribute):
for main_attrib in main_group_attribute:
if str(main_attrib) not in main_responsive_dict:
main_responsive_dict[str(main_attrib)] = {}
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in main_responsive_dict[str(main_attrib)]:
main_responsive_dict[str(main_attrib)][str(attrib)].append(value)
else:
main_responsive_dict[str(main_attrib)][str(attrib)] = []
main_responsive_dict[str(main_attrib)][str(attrib)].append(value)
else:
if str(None) in main_responsive_dict[str(main_attrib)]:
main_responsive_dict[str(main_attrib)][str(None)].append(value)
else:
main_responsive_dict[str(main_attrib)][str(None)] = []
main_responsive_dict[str(main_attrib)][str(None)].append(value)
else:
if str(None) not in main_responsive_dict:
main_responsive_dict[str(None)] = {}
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][str(attrib)].append(value)
else:
main_responsive_dict[str(None)][str(attrib)] = []
main_responsive_dict[str(None)][str(attrib)].append(value)
else:
if str(None) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][str(None)].append(value)
else:
main_responsive_dict[str(None)][str(None)] = []
main_responsive_dict[str(None)][str(None)].append(value)
else:
main_group_attribute = resolve_keys(sub_group_by, value)
group_attribute = resolve_keys(group_by, value)
if str(main_group_attribute) not in main_responsive_dict:
main_responsive_dict[str(main_group_attribute)] = {}
if str(group_attribute) in main_responsive_dict[str(main_group_attribute)]:
main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value)
else:
main_responsive_dict[str(main_group_attribute)][str(group_attribute)] = []
main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value)
return main_responsive_dict
else:
response_dict = dict()
if group_by == "priority":
response_dict = {
"urgent": [],
"high": [],
"medium": [],
"low": [],
"none": [],
}
for value in results_data:
group_attribute = resolve_keys(group_by, value)
if isinstance(group_attribute, list):
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in response_dict:
response_dict[str(attrib)].append(value)
else:
response_dict[str(attrib)] = []
response_dict[str(attrib)].append(value)
else:
if str(None) in response_dict:
response_dict[str(None)].append(value)
else:
response_dict[str(None)] = []
response_dict[str(None)].append(value)
else:
if str(group_attribute) in response_dict:
response_dict[str(group_attribute)].append(value)
else:
response_dict[str(group_attribute)] = []
response_dict[str(group_attribute)].append(value)
return response_dict

View File

@ -1,6 +1,7 @@
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
from django.utils.dateparse import parse_datetime from django.utils.dateparse import parse_datetime
def filter_state(params, filter, method): def filter_state(params, filter, method):
if method == "GET": if method == "GET":
states = params.get("state").split(",") states = params.get("state").split(",")
@ -23,7 +24,6 @@ def filter_state_group(params, filter, method):
return filter return filter
def filter_estimate_point(params, filter, method): def filter_estimate_point(params, filter, method):
if method == "GET": if method == "GET":
estimate_points = params.get("estimate_point").split(",") estimate_points = params.get("estimate_point").split(",")
@ -39,25 +39,7 @@ def filter_priority(params, filter, method):
if method == "GET": if method == "GET":
priorities = params.get("priority").split(",") priorities = params.get("priority").split(",")
if len(priorities) and "" not in priorities: if len(priorities) and "" not in priorities:
if len(priorities) == 1 and "null" in priorities: filter["priority__in"] = priorities
filter["priority__isnull"] = True
elif len(priorities) > 1 and "null" in priorities:
filter["priority__isnull"] = True
filter["priority__in"] = [p for p in priorities if p != "null"]
else:
filter["priority__in"] = [p for p in priorities if p != "null"]
else:
if params.get("priority", None) and len(params.get("priority")):
priorities = params.get("priority")
if len(priorities) == 1 and "null" in priorities:
filter["priority__isnull"] = True
elif len(priorities) > 1 and "null" in priorities:
filter["priority__isnull"] = True
filter["priority__in"] = [p for p in priorities if p != "null"]
else:
filter["priority__in"] = [p for p in priorities if p != "null"]
return filter return filter
@ -181,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
@ -229,7 +211,6 @@ def filter_issue_state_type(params, filter, method):
return filter return filter
def filter_project(params, filter, method): def filter_project(params, filter, method):
if method == "GET": if method == "GET":
projects = params.get("project").split(",") projects = params.get("project").split(",")
@ -329,7 +310,7 @@ def issue_filters(query_params, method):
"module": filter_module, "module": filter_module,
"inbox_status": filter_inbox_status, "inbox_status": filter_inbox_status,
"sub_issue": filter_sub_issue_toggle, "sub_issue": filter_sub_issue_toggle,
"subscriber": filter_subscribed_issues, "subscriber": filter_subscribed_issues,
"start_target_date": filter_start_target_date_issues, "start_target_date": filter_start_target_date_issues,
} }

View File

@ -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.production
DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE}
REDIS_URL: redis://plane-redis:6379/
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
EMAIL_PORT: ${EMAIL_PORT}
EMAIL_FROM: ${EMAIL_FROM}
EMAIL_USE_TLS: ${EMAIL_USE_TLS}
EMAIL_USE_SSL: ${EMAIL_USE_SSL}
AWS_REGION: ${AWS_REGION}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT}
WEB_URL: ${WEB_URL}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
DISABLE_COLLECTSTATIC: 1
DOCKERIZED: 1
OPENAI_API_BASE: ${OPENAI_API_BASE}
OPENAI_API_KEY: ${OPENAI_API_KEY}
GPT_ENGINE: ${GPT_ENGINE}
SECRET_KEY: ${SECRET_KEY}
DEFAULT_EMAIL: ${DEFAULT_EMAIL}
DEFAULT_PASSWORD: ${DEFAULT_PASSWORD}
USE_MINIO: ${USE_MINIO}
ENABLE_SIGNUP: ${ENABLE_SIGNUP}
services: 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:

View File

@ -1,88 +1,35 @@
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.production
DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE}
REDIS_URL: redis://plane-redis:6379/
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
EMAIL_PORT: ${EMAIL_PORT}
EMAIL_FROM: ${EMAIL_FROM}
EMAIL_USE_TLS: ${EMAIL_USE_TLS}
EMAIL_USE_SSL: ${EMAIL_USE_SSL}
AWS_REGION: ${AWS_REGION}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT}
WEB_URL: ${WEB_URL}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
DISABLE_COLLECTSTATIC: 1
DOCKERIZED: 1
OPENAI_API_BASE: ${OPENAI_API_BASE}
OPENAI_API_KEY: ${OPENAI_API_KEY}
GPT_ENGINE: ${GPT_ENGINE}
SECRET_KEY: ${SECRET_KEY}
DEFAULT_EMAIL: ${DEFAULT_EMAIL}
DEFAULT_PASSWORD: ${DEFAULT_PASSWORD}
USE_MINIO: ${USE_MINIO}
ENABLE_SIGNUP: ${ENABLE_SIGNUP}
services: services:
plane-web: web:
container_name: planefrontend container_name: web
build: build:
context: . context: .
dockerfile: ./web/Dockerfile.web dockerfile: ./web/Dockerfile.web
args: args:
DOCKER_BUILDKIT: 1 DOCKER_BUILDKIT: 1
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
NEXT_PUBLIC_DEPLOY_URL: http://localhost/spaces
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
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
build: build:
context: . context: .
dockerfile: ./space/Dockerfile.space dockerfile: ./space/Dockerfile.space
args: args:
DOCKER_BUILDKIT: 1 DOCKER_BUILDKIT: 1
NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
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
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
build: build:
context: ./apiserver context: ./apiserver
dockerfile: Dockerfile.api dockerfile: Dockerfile.api
@ -91,15 +38,13 @@ services:
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
build: build:
context: ./apiserver context: ./apiserver
dockerfile: Dockerfile.api dockerfile: Dockerfile.api
@ -108,16 +53,14 @@ services:
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
build: build:
context: ./apiserver context: ./apiserver
dockerfile: Dockerfile.api dockerfile: Dockerfile.api
@ -126,11 +69,9 @@ services:
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
@ -163,8 +104,6 @@ services:
command: server /export --console-address ":9090" command: server /export --console-address ":9090"
volumes: volumes:
- uploads:/export - uploads:/export
env_file:
- .env
environment: environment:
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID} MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID}
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY} MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
@ -179,22 +118,21 @@ 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
restart: always restart: always
ports: ports:
- ${NGINX_PORT}:80 - ${NGINX_PORT}:80
env_file:
- .env
environment: environment:
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:

View File

@ -1,30 +1,36 @@
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};
location / { add_header X-Content-Type-Options "nosniff" always;
proxy_pass http://planefrontend:3000/; add_header Referrer-Policy "no-referrer-when-downgrade" always;
} add_header Permissions-Policy "interest-cohort=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location /api/ { location / {
proxy_pass http://planebackend:8000/api/; proxy_pass http://web:3000/;
} }
location /spaces/ { location /api/ {
proxy_pass http://planedeploy:3000/spaces/; proxy_pass http://api:8000/api/;
} }
location /${BUCKET_NAME}/ { location /spaces/ {
proxy_pass http://plane-minio:9000/uploads/; rewrite ^/spaces/?$ /spaces/login break;
proxy_pass http://space:3000/spaces/;
}
location /${BUCKET_NAME}/ {
proxy_pass http://plane-minio:9000/uploads/;
}
} }
} }
}

View File

@ -16,8 +16,12 @@
"format": "prettier --write \"**/*.{ts,tsx,md}\"" "format": "prettier --write \"**/*.{ts,tsx,md}\""
}, },
"devDependencies": { "devDependencies": {
"autoprefixer": "^10.4.15",
"eslint-config-custom": "*", "eslint-config-custom": "*",
"postcss": "^8.4.29",
"prettier": "latest", "prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "latest" "turbo": "latest"
}, },
"packageManager": "yarn@1.22.19" "packageManager": "yarn@1.22.19"

View File

@ -16,5 +16,7 @@ module.exports = {
"no-duplicate-imports": "error", "no-duplicate-imports": "error",
"arrow-body-style": ["error", "as-needed"], "arrow-body-style": ["error", "as-needed"],
"react/self-closing-comp": ["error", { component: true, html: true }], "react/self-closing-comp": ["error", { component: true, html: true }],
"@next/next/no-img-element": "off",
"@typescript-eslint/no-unused-vars": ["warn"],
}, },
}; };

View File

@ -0,0 +1,10 @@
{
"name": "tailwind-config-custom",
"version": "0.0.1",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"devDependencies": {
"@tailwindcss/typography": "^0.5.10",
"tailwindcss-animate": "^1.0.7"
}
}

View File

@ -0,0 +1,7 @@
module.exports = {
plugins: {
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,212 @@
const convertToRGB = (variableName) => `rgba(var(${variableName}))`;
module.exports = {
darkMode: "class",
content: [
"./components/**/*.tsx",
"./constants/**/*.{js,ts,jsx,tsx}",
"./layouts/**/*.tsx",
"./pages/**/*.tsx",
"./ui/**/*.tsx",
],
theme: {
extend: {
boxShadow: {
"custom-shadow-2xs": "var(--color-shadow-2xs)",
"custom-shadow-xs": "var(--color-shadow-xs)",
"custom-shadow-sm": "var(--color-shadow-sm)",
"custom-shadow-rg": "var(--color-shadow-rg)",
"custom-shadow-md": "var(--color-shadow-md)",
"custom-shadow-lg": "var(--color-shadow-lg)",
"custom-shadow-xl": "var(--color-shadow-xl)",
"custom-shadow-2xl": "var(--color-shadow-2xl)",
"custom-shadow-3xl": "var(--color-shadow-3xl)",
"custom-sidebar-shadow-2xs": "var(--color-sidebar-shadow-2xs)",
"custom-sidebar-shadow-xs": "var(--color-sidebar-shadow-xs)",
"custom-sidebar-shadow-sm": "var(--color-sidebar-shadow-sm)",
"custom-sidebar-shadow-rg": "var(--color-sidebar-shadow-rg)",
"custom-sidebar-shadow-md": "var(--color-sidebar-shadow-md)",
"custom-sidebar-shadow-lg": "var(--color-sidebar-shadow-lg)",
"custom-sidebar-shadow-xl": "var(--color-sidebar-shadow-xl)",
"custom-sidebar-shadow-2xl": "var(--color-sidebar-shadow-2xl)",
"custom-sidebar-shadow-3xl": "var(--color-sidebar-shadow-3xl)",
},
colors: {
custom: {
primary: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-primary-10"),
20: convertToRGB("--color-primary-20"),
30: convertToRGB("--color-primary-30"),
40: convertToRGB("--color-primary-40"),
50: convertToRGB("--color-primary-50"),
60: convertToRGB("--color-primary-60"),
70: convertToRGB("--color-primary-70"),
80: convertToRGB("--color-primary-80"),
90: convertToRGB("--color-primary-90"),
100: convertToRGB("--color-primary-100"),
200: convertToRGB("--color-primary-200"),
300: convertToRGB("--color-primary-300"),
400: convertToRGB("--color-primary-400"),
500: convertToRGB("--color-primary-500"),
600: convertToRGB("--color-primary-600"),
700: convertToRGB("--color-primary-700"),
800: convertToRGB("--color-primary-800"),
900: convertToRGB("--color-primary-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-primary-100"),
},
background: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-background-10"),
20: convertToRGB("--color-background-20"),
30: convertToRGB("--color-background-30"),
40: convertToRGB("--color-background-40"),
50: convertToRGB("--color-background-50"),
60: convertToRGB("--color-background-60"),
70: convertToRGB("--color-background-70"),
80: convertToRGB("--color-background-80"),
90: convertToRGB("--color-background-90"),
100: convertToRGB("--color-background-100"),
200: convertToRGB("--color-background-200"),
300: convertToRGB("--color-background-300"),
400: convertToRGB("--color-background-400"),
500: convertToRGB("--color-background-500"),
600: convertToRGB("--color-background-600"),
700: convertToRGB("--color-background-700"),
800: convertToRGB("--color-background-800"),
900: convertToRGB("--color-background-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-background-100"),
},
text: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-text-10"),
20: convertToRGB("--color-text-20"),
30: convertToRGB("--color-text-30"),
40: convertToRGB("--color-text-40"),
50: convertToRGB("--color-text-50"),
60: convertToRGB("--color-text-60"),
70: convertToRGB("--color-text-70"),
80: convertToRGB("--color-text-80"),
90: convertToRGB("--color-text-90"),
100: convertToRGB("--color-text-100"),
200: convertToRGB("--color-text-200"),
300: convertToRGB("--color-text-300"),
400: convertToRGB("--color-text-400"),
500: convertToRGB("--color-text-500"),
600: convertToRGB("--color-text-600"),
700: convertToRGB("--color-text-700"),
800: convertToRGB("--color-text-800"),
900: convertToRGB("--color-text-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-text-100"),
},
border: {
0: "rgb(255, 255, 255)",
100: convertToRGB("--color-border-100"),
200: convertToRGB("--color-border-200"),
300: convertToRGB("--color-border-300"),
400: convertToRGB("--color-border-400"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-border-200"),
},
sidebar: {
background: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-sidebar-background-10"),
20: convertToRGB("--color-sidebar-background-20"),
30: convertToRGB("--color-sidebar-background-30"),
40: convertToRGB("--color-sidebar-background-40"),
50: convertToRGB("--color-sidebar-background-50"),
60: convertToRGB("--color-sidebar-background-60"),
70: convertToRGB("--color-sidebar-background-70"),
80: convertToRGB("--color-sidebar-background-80"),
90: convertToRGB("--color-sidebar-background-90"),
100: convertToRGB("--color-sidebar-background-100"),
200: convertToRGB("--color-sidebar-background-200"),
300: convertToRGB("--color-sidebar-background-300"),
400: convertToRGB("--color-sidebar-background-400"),
500: convertToRGB("--color-sidebar-background-500"),
600: convertToRGB("--color-sidebar-background-600"),
700: convertToRGB("--color-sidebar-background-700"),
800: convertToRGB("--color-sidebar-background-800"),
900: convertToRGB("--color-sidebar-background-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-sidebar-background-100"),
},
text: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-sidebar-text-10"),
20: convertToRGB("--color-sidebar-text-20"),
30: convertToRGB("--color-sidebar-text-30"),
40: convertToRGB("--color-sidebar-text-40"),
50: convertToRGB("--color-sidebar-text-50"),
60: convertToRGB("--color-sidebar-text-60"),
70: convertToRGB("--color-sidebar-text-70"),
80: convertToRGB("--color-sidebar-text-80"),
90: convertToRGB("--color-sidebar-text-90"),
100: convertToRGB("--color-sidebar-text-100"),
200: convertToRGB("--color-sidebar-text-200"),
300: convertToRGB("--color-sidebar-text-300"),
400: convertToRGB("--color-sidebar-text-400"),
500: convertToRGB("--color-sidebar-text-500"),
600: convertToRGB("--color-sidebar-text-600"),
700: convertToRGB("--color-sidebar-text-700"),
800: convertToRGB("--color-sidebar-text-800"),
900: convertToRGB("--color-sidebar-text-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-sidebar-text-100"),
},
border: {
0: "rgb(255, 255, 255)",
100: convertToRGB("--color-sidebar-border-100"),
200: convertToRGB("--color-sidebar-border-200"),
300: convertToRGB("--color-sidebar-border-300"),
400: convertToRGB("--color-sidebar-border-400"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-sidebar-border-200"),
},
},
backdrop: "#131313",
},
},
keyframes: {
leftToaster: {
"0%": { left: "-20rem" },
"100%": { left: "0" },
},
rightToaster: {
"0%": { right: "-20rem" },
"100%": { right: "0" },
},
},
typography: ({ theme }) => ({
brand: {
css: {
"--tw-prose-body": convertToRGB("--color-text-100"),
"--tw-prose-p": convertToRGB("--color-text-100"),
"--tw-prose-headings": convertToRGB("--color-text-100"),
"--tw-prose-lead": convertToRGB("--color-text-100"),
"--tw-prose-links": convertToRGB("--color-primary-100"),
"--tw-prose-bold": convertToRGB("--color-text-100"),
"--tw-prose-counters": convertToRGB("--color-text-100"),
"--tw-prose-bullets": convertToRGB("--color-text-100"),
"--tw-prose-hr": convertToRGB("--color-text-100"),
"--tw-prose-quotes": convertToRGB("--color-text-100"),
"--tw-prose-quote-borders": convertToRGB("--color-border"),
"--tw-prose-code": convertToRGB("--color-text-100"),
"--tw-prose-pre-code": convertToRGB("--color-text-100"),
"--tw-prose-pre-bg": convertToRGB("--color-background-100"),
"--tw-prose-th-borders": convertToRGB("--color-border"),
"--tw-prose-td-borders": convertToRGB("--color-border"),
},
},
}),
},
fontFamily: {
custom: ["Inter", "sans-serif"],
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
};

View File

@ -17,6 +17,7 @@
"next": "12.3.2", "next": "12.3.2",
"react": "^18.2.0", "react": "^18.2.0",
"tsconfig": "*", "tsconfig": "*",
"tailwind-config-custom": "*",
"typescript": "4.7.4" "typescript": "4.7.4"
} }
} }

View File

@ -0,0 +1 @@
module.exports = require("tailwind-config-custom/postcss.config");

View File

@ -0,0 +1 @@
module.exports = require("tailwind-config-custom/tailwind.config");

View File

@ -1,9 +1,5 @@
{ {
"extends": "../tsconfig/nextjs.json", "extends": "tsconfig/react-library.json",
"include": ["."], "include": ["."],
"exclude": ["dist", "build", "node_modules"], "exclude": ["dist", "build", "node_modules"]
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["DOM"]
}
} }

View File

@ -1,15 +0,0 @@
#!/bin/sh
FROM=$1
TO=$2
DIRECTORY=$3
if [ "${FROM}" = "${TO}" ]; then
echo "Nothing to replace, the value is already set to ${TO}."
exit 0
fi
# Only perform action if $FROM and $TO are different.
echo "Replacing all statically built instances of $FROM with this string $TO ."
grep -R -la "${FROM}" $DIRECTORY/.next | xargs -I{} sed -i "s|$FROM|$TO|g" "{}"

View File

@ -5,25 +5,9 @@ cp ./.env.example ./.env
export LC_ALL=C export LC_ALL=C
export LC_CTYPE=C export LC_CTYPE=C
cp ./web/.env.example ./web/.env
# Generate the NEXT_PUBLIC_API_BASE_URL with given IP cp ./space/.env.example ./space/.env
echo -e "\nNEXT_PUBLIC_API_BASE_URL=$1" >> ./.env cp ./apiserver/.env.example ./apiserver/.env
# Generate the SECRET_KEY that will be used by django # Generate the SECRET_KEY that will be used by django
echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./.env echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env
# WEB_URL for email redirection and image saving
echo -e "WEB_URL=$1" >> ./.env
# Generate Prompt for taking tiptap auth key
echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token \e[0m \n"
echo -e "\e[1;38m 1. Head over to TipTap cloud's Pro Extensions Page, https://collab.tiptap.dev/pro-extensions \e[0m"
echo -e "\e[1;38m 2. Copy the token given to you under the first paragraph, after 'Here it is' \e[0m \n"
read -p $'\e[1;32m Please Enter Your TipTap Pro Extensions Authentication Token: \e[0m \e[1;36m' authToken
echo "@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=${authToken}" > .npmrc

View File

@ -1,8 +1,2 @@
# Base url for the API requests
NEXT_PUBLIC_API_BASE_URL=""
# Public boards deploy URL
NEXT_PUBLIC_DEPLOY_URL=""
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID=""
# Flag to toggle OAuth # Flag to toggle OAuth
NEXT_PUBLIC_ENABLE_OAUTH=1 NEXT_PUBLIC_ENABLE_OAUTH=0

View File

@ -1,7 +1,4 @@
module.exports = { module.exports = {
root: true, root: true,
extends: ["custom"], extends: ["custom"],
rules: {
"@next/next/no-img-element": "off",
},
}; };

View File

@ -1,7 +1,6 @@
FROM node:18-alpine AS builder FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo RUN yarn global add turbo
COPY . . COPY . .
@ -20,19 +19,16 @@ RUN yarn install --network-timeout 500000
COPY --from=builder /app/out/full/ . COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 ARG NEXT_PUBLIC_API_BASE_URL=""
ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
RUN yarn turbo run build --filter=space RUN yarn turbo run build --filter=space
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} space
FROM node:18-alpine AS runner FROM node:18-alpine AS runner
WORKDIR /app WORKDIR /app
@ -48,14 +44,14 @@ COPY --from=installer --chown=captain:plane /app/space/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/space/.next ./space/.next COPY --from=installer --chown=captain:plane /app/space/.next ./space/.next
COPY --from=installer --chown=captain:plane /app/space/public ./space/public COPY --from=installer --chown=captain:plane /app/space/public ./space/public
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 ARG NEXT_PUBLIC_API_BASE_URL=""
ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
USER root USER root
COPY replace-env-vars.sh /usr/local/bin/
COPY start.sh /usr/local/bin/ COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh RUN chmod +x /usr/local/bin/start.sh
USER captain USER captain

View File

@ -1,9 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
// react hook form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// components // components
import { EmailResetPasswordForm } from "./email-reset-password-form"; import { EmailResetPasswordForm } from "./email-reset-password-form";

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { useEffect } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -13,7 +13,7 @@ import useToast from "hooks/use-toast";
// components // components
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts"; import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
// images // images
const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces/" : ""; const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
export const SignInView = observer(() => { export const SignInView = observer(() => {
const { user: userStore } = useMobxStore(); const { user: userStore } = useMobxStore();
@ -33,7 +33,7 @@ export const SignInView = observer(() => {
const onSignInSuccess = (response: any) => { const onSignInSuccess = (response: any) => {
const isOnboarded = response?.user?.onboarding_step?.profile_complete || false; const isOnboarded = response?.user?.onboarding_step?.profile_complete || false;
const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/"; const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/login";
userStore.setCurrentUser(response?.user); userStore.setCurrentUser(response?.user);
@ -41,7 +41,7 @@ export const SignInView = observer(() => {
router.push(`/onboarding?next_path=${nextPath}`); router.push(`/onboarding?next_path=${nextPath}`);
return; return;
} }
router.push((nextPath ?? "/").toString()); router.push((nextPath ?? "/login").toString());
}; };
const handleGoogleSignIn = async ({ clientId, credential }: any) => { const handleGoogleSignIn = async ({ clientId, credential }: any) => {

View File

@ -18,7 +18,6 @@ import Gapcursor from "@tiptap/extension-gapcursor";
import ts from "highlight.js/lib/languages/typescript"; import ts from "highlight.js/lib/languages/typescript";
import "highlight.js/styles/github-dark.css"; import "highlight.js/styles/github-dark.css";
import UniqueID from "@tiptap-pro/extension-unique-id";
import UpdatedImage from "./updated-image"; import UpdatedImage from "./updated-image";
import isValidHttpUrl from "../bubble-menu/utils/link-validator"; import isValidHttpUrl from "../bubble-menu/utils/link-validator";
import { CustomTableCell } from "./table/table-cell"; import { CustomTableCell } from "./table/table-cell";
@ -121,9 +120,6 @@ export const TiptapExtensions = (
}, },
includeChildren: true, includeChildren: true,
}), }),
UniqueID.configure({
types: ["image"],
}),
SlashCommand(workspaceSlug, setIsSubmitting), SlashCommand(workspaceSlug, setIsSubmitting),
TiptapUnderline, TiptapUnderline,
TextStyle, TextStyle,

View File

@ -1 +1 @@
export * from "./home"; export * from "./login";

View File

@ -4,7 +4,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
// components // components
import { SignInView, UserLoggedIn } from "components/accounts"; import { SignInView, UserLoggedIn } from "components/accounts";
export const HomeView = observer(() => { export const LoginView = observer(() => {
const { user: userStore } = useMobxStore(); const { user: userStore } = useMobxStore();
if (!userStore.currentUser) return <SignInView />; if (!userStore.currentUser) return <SignInView />;

View File

@ -0,0 +1 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ? process.env.NEXT_PUBLIC_API_BASE_URL : "";

View File

@ -17,8 +17,6 @@
"@heroicons/react": "^2.0.12", "@heroicons/react": "^2.0.12",
"@mui/icons-material": "^5.14.1", "@mui/icons-material": "^5.14.1",
"@mui/material": "^5.14.1", "@mui/material": "^5.14.1",
"@tailwindcss/typography": "^0.5.9",
"@tiptap-pro/extension-unique-id": "^2.1.0",
"@tiptap/extension-code-block-lowlight": "^2.0.4", "@tiptap/extension-code-block-lowlight": "^2.0.4",
"@tiptap/extension-color": "^2.0.4", "@tiptap/extension-color": "^2.0.4",
"@tiptap/extension-gapcursor": "^2.1.7", "@tiptap/extension-gapcursor": "^2.1.7",
@ -62,7 +60,6 @@
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@types/js-cookie": "^3.0.3", "@types/js-cookie": "^3.0.3",
"@types/node": "18.14.1", "@types/node": "18.14.1",
"@types/nprogress": "^0.2.0", "@types/nprogress": "^0.2.0",
@ -70,12 +67,10 @@
"@types/react-dom": "18.0.11", "@types/react-dom": "18.0.11",
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.48.2", "@typescript-eslint/eslint-plugin": "^5.48.2",
"autoprefixer": "^10.4.13",
"eslint": "8.34.0", "eslint": "8.34.0",
"eslint-config-custom": "*", "eslint-config-custom": "*",
"eslint-config-next": "13.2.1", "eslint-config-next": "13.2.1",
"postcss": "^8.4.21",
"tsconfig": "*", "tsconfig": "*",
"tailwindcss": "^3.2.7" "tailwind-config-custom": "*"
} }
} }

View File

@ -1,8 +0,0 @@
import React from "react";
// components
import { HomeView } from "components/views";
const HomePage = () => <HomeView />;
export default HomePage;

View File

@ -0,0 +1,8 @@
import React from "react";
// components
import { LoginView } from "components/views";
const LoginPage = () => <LoginView />;
export default LoginPage;

View File

@ -2,22 +2,16 @@ import React, { useEffect } from "react";
// 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
import authenticationService from "services/authentication.service";
// hooks
import useToast from "hooks/use-toast";
// components // components
import { OnBoardingForm } from "components/accounts/onboarding-form"; import { OnBoardingForm } from "components/accounts/onboarding-form";
const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces/" : ""; const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
const OnBoardingPage = () => { const OnBoardingPage = () => {
const { user: userStore } = useMobxStore(); const { user: userStore } = useMobxStore();
const user = userStore?.currentUser; const user = userStore?.currentUser;
const { setToastAlert } = useToast();
useEffect(() => { useEffect(() => {
const user = userStore?.currentUser; const user = userStore?.currentUser;

View File

@ -1,7 +1 @@
module.exports = { module.exports = require("tailwind-config-custom/postcss.config");
plugins: {
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -1,9 +1,10 @@
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
import { API_BASE_URL } from "helpers/common.helper";
class AuthService extends APIService { class AuthService extends APIService {
constructor() { constructor() {
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async emailLogin(data: any) { async emailLogin(data: any) {

View File

@ -1,7 +1,5 @@
// services
import APIService from "services/api.service"; import APIService from "services/api.service";
import { API_BASE_URL } from "helpers/common.helper";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
interface UnSplashImage { interface UnSplashImage {
id: string; id: string;
@ -29,7 +27,7 @@ interface UnSplashImageUrls {
class FileServices extends APIService { class FileServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async uploadFile(workspaceSlug: string, file: FormData): Promise<any> { async uploadFile(workspaceSlug: string, file: FormData): Promise<any> {

View File

@ -1,9 +1,10 @@
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
import { API_BASE_URL } from "helpers/common.helper";
class IssueService extends APIService { class IssueService extends APIService {
constructor() { constructor() {
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async getPublicIssues(workspace_slug: string, project_slug: string, params: any): Promise<any> { async getPublicIssues(workspace_slug: string, project_slug: string, params: any): Promise<any> {

View File

@ -1,9 +1,10 @@
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
import { API_BASE_URL } from "helpers/common.helper";
class ProjectService extends APIService { class ProjectService extends APIService {
constructor() { constructor() {
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async getProjectSettings(workspace_slug: string, project_slug: string): Promise<any> { async getProjectSettings(workspace_slug: string, project_slug: string): Promise<any> {

View File

@ -1,9 +1,10 @@
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
import { API_BASE_URL } from "helpers/common.helper";
class UserService extends APIService { class UserService extends APIService {
constructor() { constructor() {
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async currentUser(): Promise<any> { async currentUser(): Promise<any> {

View File

@ -1,203 +1 @@
/** @type {import('tailwindcss').Config} */ module.exports = require("tailwind-config-custom/tailwind.config");
const convertToRGB = (variableName) => `rgba(var(${variableName}))`;
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./pages/**/*.{js,ts,jsx,tsx}",
"./layouts/**/*.tsx",
"./components/**/*.{js,ts,jsx,tsx}",
"./constants/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
boxShadow: {
"custom-shadow-2xs": "var(--color-shadow-2xs)",
"custom-shadow-xs": "var(--color-shadow-xs)",
"custom-shadow-sm": "var(--color-shadow-sm)",
"custom-shadow-rg": "var(--color-shadow-rg)",
"custom-shadow-md": "var(--color-shadow-md)",
"custom-shadow-lg": "var(--color-shadow-lg)",
"custom-shadow-xl": "var(--color-shadow-xl)",
"custom-shadow-2xl": "var(--color-shadow-2xl)",
"custom-shadow-3xl": "var(--color-shadow-3xl)",
"custom-sidebar-shadow-2xs": "var(--color-sidebar-shadow-2xs)",
"custom-sidebar-shadow-xs": "var(--color-sidebar-shadow-xs)",
"custom-sidebar-shadow-sm": "var(--color-sidebar-shadow-sm)",
"custom-sidebar-shadow-rg": "var(--color-sidebar-shadow-rg)",
"custom-sidebar-shadow-md": "var(--color-sidebar-shadow-md)",
"custom-sidebar-shadow-lg": "var(--color-sidebar-shadow-lg)",
"custom-sidebar-shadow-xl": "var(--color-sidebar-shadow-xl)",
"custom-sidebar-shadow-2xl": "var(--color-sidebar-shadow-2xl)",
"custom-sidebar-shadow-3xl": "var(--color-sidebar-shadow-3xl)",
},
colors: {
custom: {
primary: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-primary-10"),
20: convertToRGB("--color-primary-20"),
30: convertToRGB("--color-primary-30"),
40: convertToRGB("--color-primary-40"),
50: convertToRGB("--color-primary-50"),
60: convertToRGB("--color-primary-60"),
70: convertToRGB("--color-primary-70"),
80: convertToRGB("--color-primary-80"),
90: convertToRGB("--color-primary-90"),
100: convertToRGB("--color-primary-100"),
200: convertToRGB("--color-primary-200"),
300: convertToRGB("--color-primary-300"),
400: convertToRGB("--color-primary-400"),
500: convertToRGB("--color-primary-500"),
600: convertToRGB("--color-primary-600"),
700: convertToRGB("--color-primary-700"),
800: convertToRGB("--color-primary-800"),
900: convertToRGB("--color-primary-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-primary-100"),
},
background: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-background-10"),
20: convertToRGB("--color-background-20"),
30: convertToRGB("--color-background-30"),
40: convertToRGB("--color-background-40"),
50: convertToRGB("--color-background-50"),
60: convertToRGB("--color-background-60"),
70: convertToRGB("--color-background-70"),
80: convertToRGB("--color-background-80"),
90: convertToRGB("--color-background-90"),
100: convertToRGB("--color-background-100"),
200: convertToRGB("--color-background-200"),
300: convertToRGB("--color-background-300"),
400: convertToRGB("--color-background-400"),
500: convertToRGB("--color-background-500"),
600: convertToRGB("--color-background-600"),
700: convertToRGB("--color-background-700"),
800: convertToRGB("--color-background-800"),
900: convertToRGB("--color-background-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-background-100"),
},
text: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-text-10"),
20: convertToRGB("--color-text-20"),
30: convertToRGB("--color-text-30"),
40: convertToRGB("--color-text-40"),
50: convertToRGB("--color-text-50"),
60: convertToRGB("--color-text-60"),
70: convertToRGB("--color-text-70"),
80: convertToRGB("--color-text-80"),
90: convertToRGB("--color-text-90"),
100: convertToRGB("--color-text-100"),
200: convertToRGB("--color-text-200"),
300: convertToRGB("--color-text-300"),
400: convertToRGB("--color-text-400"),
500: convertToRGB("--color-text-500"),
600: convertToRGB("--color-text-600"),
700: convertToRGB("--color-text-700"),
800: convertToRGB("--color-text-800"),
900: convertToRGB("--color-text-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-text-100"),
},
border: {
0: "rgb(255, 255, 255)",
100: convertToRGB("--color-border-100"),
200: convertToRGB("--color-border-200"),
300: convertToRGB("--color-border-300"),
400: convertToRGB("--color-border-400"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-border-200"),
},
sidebar: {
background: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-sidebar-background-10"),
20: convertToRGB("--color-sidebar-background-20"),
30: convertToRGB("--color-sidebar-background-30"),
40: convertToRGB("--color-sidebar-background-40"),
50: convertToRGB("--color-sidebar-background-50"),
60: convertToRGB("--color-sidebar-background-60"),
70: convertToRGB("--color-sidebar-background-70"),
80: convertToRGB("--color-sidebar-background-80"),
90: convertToRGB("--color-sidebar-background-90"),
100: convertToRGB("--color-sidebar-background-100"),
200: convertToRGB("--color-sidebar-background-200"),
300: convertToRGB("--color-sidebar-background-300"),
400: convertToRGB("--color-sidebar-background-400"),
500: convertToRGB("--color-sidebar-background-500"),
600: convertToRGB("--color-sidebar-background-600"),
700: convertToRGB("--color-sidebar-background-700"),
800: convertToRGB("--color-sidebar-background-800"),
900: convertToRGB("--color-sidebar-background-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-sidebar-background-100"),
},
text: {
0: "rgb(255, 255, 255)",
10: convertToRGB("--color-sidebar-text-10"),
20: convertToRGB("--color-sidebar-text-20"),
30: convertToRGB("--color-sidebar-text-30"),
40: convertToRGB("--color-sidebar-text-40"),
50: convertToRGB("--color-sidebar-text-50"),
60: convertToRGB("--color-sidebar-text-60"),
70: convertToRGB("--color-sidebar-text-70"),
80: convertToRGB("--color-sidebar-text-80"),
90: convertToRGB("--color-sidebar-text-90"),
100: convertToRGB("--color-sidebar-text-100"),
200: convertToRGB("--color-sidebar-text-200"),
300: convertToRGB("--color-sidebar-text-300"),
400: convertToRGB("--color-sidebar-text-400"),
500: convertToRGB("--color-sidebar-text-500"),
600: convertToRGB("--color-sidebar-text-600"),
700: convertToRGB("--color-sidebar-text-700"),
800: convertToRGB("--color-sidebar-text-800"),
900: convertToRGB("--color-sidebar-text-900"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-sidebar-text-100"),
},
border: {
0: "rgb(255, 255, 255)",
100: convertToRGB("--color-sidebar-border-100"),
200: convertToRGB("--color-sidebar-border-200"),
300: convertToRGB("--color-sidebar-border-300"),
400: convertToRGB("--color-sidebar-border-400"),
1000: "rgb(0, 0, 0)",
DEFAULT: convertToRGB("--color-sidebar-border-200"),
},
},
backdrop: "#131313",
},
},
typography: ({ theme }) => ({
brand: {
css: {
"--tw-prose-body": convertToRGB("--color-text-100"),
"--tw-prose-p": convertToRGB("--color-text-100"),
"--tw-prose-headings": convertToRGB("--color-text-100"),
"--tw-prose-lead": convertToRGB("--color-text-100"),
"--tw-prose-links": convertToRGB("--color-primary-100"),
"--tw-prose-bold": convertToRGB("--color-text-100"),
"--tw-prose-counters": convertToRGB("--color-text-100"),
"--tw-prose-bullets": convertToRGB("--color-text-100"),
"--tw-prose-hr": convertToRGB("--color-text-100"),
"--tw-prose-quotes": convertToRGB("--color-text-100"),
"--tw-prose-quote-borders": convertToRGB("--color-border"),
"--tw-prose-code": convertToRGB("--color-text-100"),
"--tw-prose-pre-code": convertToRGB("--color-text-100"),
"--tw-prose-pre-bg": convertToRGB("--color-background-100"),
"--tw-prose-th-borders": convertToRGB("--color-border"),
"--tw-prose-td-borders": convertToRGB("--color-border"),
},
},
}),
},
fontFamily: {
custom: ["Inter", "sans-serif"],
},
},
plugins: [require("@tailwindcss/typography")],
};

View File

@ -1,9 +1,5 @@
#!/bin/sh #!/bin/sh
set -x set -x
# Replace the statically built BUILT_NEXT_PUBLIC_API_BASE_URL with run-time NEXT_PUBLIC_API_BASE_URL
# NOTE: if these values are the same, this will be skipped.
/usr/local/bin/replace-env-vars.sh "$BUILT_NEXT_PUBLIC_API_BASE_URL" "$NEXT_PUBLIC_API_BASE_URL" $2
echo "Starting Plane Frontend.." echo "Starting Plane Frontend.."
node $1 node $1

View File

@ -15,17 +15,20 @@
"NEXT_PUBLIC_UNSPLASH_ACCESS", "NEXT_PUBLIC_UNSPLASH_ACCESS",
"NEXT_PUBLIC_UNSPLASH_ENABLED", "NEXT_PUBLIC_UNSPLASH_ENABLED",
"NEXT_PUBLIC_TRACK_EVENTS", "NEXT_PUBLIC_TRACK_EVENTS",
"TRACKER_ACCESS_KEY", "NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
"NEXT_PUBLIC_CRISP_ID", "NEXT_PUBLIC_CRISP_ID",
"NEXT_PUBLIC_ENABLE_SESSION_RECORDER", "NEXT_PUBLIC_ENABLE_SESSION_RECORDER",
"NEXT_PUBLIC_SESSION_RECORDER_KEY", "NEXT_PUBLIC_SESSION_RECORDER_KEY",
"NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS", "NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS",
"NEXT_PUBLIC_SLACK_CLIENT_ID", "NEXT_PUBLIC_DEPLOY_WITH_NGINX",
"NEXT_PUBLIC_SLACK_CLIENT_SECRET", "NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_POSTHOG_HOST",
"NEXT_PUBLIC_SUPABASE_ANON_KEY", "SLACK_OAUTH_URL",
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN", "SLACK_CLIENT_ID",
"NEXT_PUBLIC_DEPLOY_WITH_NGINX" "SLACK_CLIENT_SECRET",
"JITSU_TRACKER_ACCESS_KEY",
"JITSU_TRACKER_HOST",
"UNSPLASH_ACCESS_KEY"
], ],
"pipeline": { "pipeline": {
"build": { "build": {

View File

@ -1,26 +1,4 @@
# Base url for the API requests
NEXT_PUBLIC_API_BASE_URL=""
# Extra image domains that need to be added for Next Image
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID=""
# GitHub App ID for GitHub OAuth
NEXT_PUBLIC_GITHUB_ID=""
# GitHub App Name for GitHub Integration
NEXT_PUBLIC_GITHUB_APP_NAME=""
# Sentry DSN for error monitoring
NEXT_PUBLIC_SENTRY_DSN=""
# Enable/Disable OAUTH - default 0 for selfhosted instance # 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="" NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"

View File

@ -1,7 +1,4 @@
module.exports = { module.exports = {
root: true, root: true,
extends: ["custom"], extends: ["custom"],
rules: {
"@next/next/no-img-element": "off",
},
}; };

View File

@ -2,7 +2,6 @@ FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo RUN yarn global add turbo
COPY . . COPY . .
@ -14,8 +13,8 @@ FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 ARG NEXT_PUBLIC_API_BASE_URL=""
ARG NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces ARG NEXT_PUBLIC_DEPLOY_URL=""
# First install the dependencies (as they change less often) # First install the dependencies (as they change less often)
COPY .gitignore .gitignore COPY .gitignore .gitignore
@ -26,18 +25,12 @@ RUN yarn install --network-timeout 500000
# Build the project # Build the project
COPY --from=builder /app/out/full/ . COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
RUN yarn turbo run build --filter=web RUN yarn turbo run build --filter=web
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} web
FROM node:18-alpine AS runner FROM node:18-alpine AS runner
WORKDIR /app WORKDIR /app
@ -52,20 +45,15 @@ COPY --from=installer /app/web/package.json .
# Automatically leverage output traces to reduce image size # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=captain:plane /app/web/.next/standalone ./ COPY --from=installer --chown=captain:plane /app/web/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/web/.next ./web/.next COPY --from=installer --chown=captain:plane /app/web/.next ./web/.next
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 ARG NEXT_PUBLIC_API_BASE_URL=""
ARG NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces ARG NEXT_PUBLIC_DEPLOY_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
USER root USER root
COPY replace-env-vars.sh /usr/local/bin/
COPY start.sh /usr/local/bin/ COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh RUN chmod +x /usr/local/bin/start.sh
USER captain USER captain

View File

@ -9,7 +9,6 @@ import { findStringWithMostCharacters } from "helpers/array.helper";
import { generateBarColor } from "helpers/analytics.helper"; import { generateBarColor } 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;

View File

@ -15,17 +15,19 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
<div className="divide-y divide-custom-border-200"> <div className="divide-y divide-custom-border-200">
<div> <div>
<h6 className="px-3 text-base font-medium">Pending issues</h6> <h6 className="px-3 text-base font-medium">Pending issues</h6>
{defaultAnalytics.pending_issue_user.length > 0 ? ( {defaultAnalytics.pending_issue_user && defaultAnalytics.pending_issue_user.length > 0 ? (
<BarGraph <BarGraph
data={defaultAnalytics.pending_issue_user} data={defaultAnalytics.pending_issue_user}
indexBy="assignees__display_name" indexBy="assignees__id"
keys={["count"]} keys={["count"]}
height="250px" height="250px"
colors={() => `#f97316`} colors={() => `#f97316`}
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)} customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) =>
d.count > 0 ? d.count : 50
)}
tooltip={(datum) => { tooltip={(datum) => {
const assignee = defaultAnalytics.pending_issue_user.find( const assignee = defaultAnalytics.pending_issue_user.find(
(a) => a.assignees__display_name === `${datum.indexValue}` (a) => a.assignees__id === `${datum.indexValue}`
); );
return ( return (
@ -39,10 +41,9 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
}} }}
axisBottom={{ axisBottom={{
renderTick: (datum) => { renderTick: (datum) => {
const avatar = const assignee = defaultAnalytics.pending_issue_user[datum.tickIndex] ?? "";
defaultAnalytics.pending_issue_user[datum.tickIndex]?.assignees__avatar ?? "";
if (avatar && avatar !== "") if (assignee && assignee?.assignees__avatar && assignee?.assignees__avatar !== "")
return ( return (
<g transform={`translate(${datum.x},${datum.y})`}> <g transform={`translate(${datum.x},${datum.y})`}>
<image <image
@ -50,7 +51,7 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
y={10} y={10}
width={16} width={16}
height={16} height={16}
xlinkHref={avatar} xlinkHref={assignee?.assignees__avatar}
style={{ clipPath: "circle(50%)" }} style={{ clipPath: "circle(50%)" }}
/> />
</g> </g>
@ -60,7 +61,7 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
<g transform={`translate(${datum.x},${datum.y})`}> <g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" /> <circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff"> <text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{datum.value ? `${datum.value}`.toUpperCase()[0] : "?"} {datum.value ? `${assignee.assignees__display_name}`.toUpperCase()[0] : "?"}
</text> </text>
</g> </g>
); );

View File

@ -3,8 +3,8 @@ import React, { useState } from "react";
// component // component
import { CustomSelect, ToggleSwitch } from "components/ui"; import { CustomSelect, ToggleSwitch } from "components/ui";
import { SelectMonthModal } from "components/automation"; import { SelectMonthModal } from "components/automation";
// icons // icon
import { ChevronDownIcon } from "@heroicons/react/24/outline"; import { ArchiveRestore } from "lucide-react";
// constants // constants
import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
// types // types
@ -28,14 +28,18 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
handleClose={() => setmonthModal(false)} handleClose={() => setmonthModal(false)}
handleChange={handleChange} handleChange={handleChange}
/> />
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-custom-border-300 bg-custom-background-90"> <div className="flex flex-col gap-4 border-b border-custom-border-200 px-4 py-6">
<div className="flex items-center justify-between gap-x-8 gap-y-2"> <div className="flex items-center justify-between">
<div className="flex flex-col gap-2.5"> <div className="flex items-start gap-3">
<h4 className="text-lg font-semibold">Auto-archive closed issues</h4> <div className="flex items-center justify-center p-3 rounded bg-custom-background-90">
<p className="text-sm text-custom-text-200"> <ArchiveRestore className="h-4 w-4 text-custom-text-100 flex-shrink-0" />
Plane will automatically archive issues that have been completed or cancelled for the </div>
configured time period. <div className="">
</p> <h4 className="text-sm font-medium">Auto-archive closed issues</h4>
<p className="text-sm text-custom-text-200 tracking-tight">
Plane will auto archive issues that have been completed or canceled.
</p>
</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
value={projectDetails?.archive_in !== 0} value={projectDetails?.archive_in !== 0}
@ -47,40 +51,43 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
size="sm" size="sm"
/> />
</div> </div>
{projectDetails?.archive_in !== 0 && (
<div className="flex items-center justify-between gap-2 w-full">
<div className="w-1/2 text-base font-medium">
Auto-archive issues that are closed for
</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.archive_in}
label={`${projectDetails?.archive_in} ${
projectDetails?.archive_in === 1 ? "Month" : "Months"
}`}
onChange={(val: number) => {
handleChange({ archive_in: val });
}}
input
verticalPosition="top"
width="w-full"
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
{month.label}
</CustomSelect.Option>
))}
<button {projectDetails?.archive_in !== 0 && (
type="button" <div className="ml-12">
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80" <div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
onClick={() => setmonthModal(true)} <div className="w-1/2 text-sm font-medium">
> Auto-archive issues that are closed for
Customize Time Range </div>
</button> <div className="w-1/2">
</> <CustomSelect
</CustomSelect> value={projectDetails?.archive_in}
label={`${projectDetails?.archive_in} ${
projectDetails?.archive_in === 1 ? "Month" : "Months"
}`}
onChange={(val: number) => {
handleChange({ archive_in: val });
}}
input
verticalPosition="bottom"
width="w-full"
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
<span className="text-sm">{month.label}</span>
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full text-sm select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
Customise Time Range
</button>
</>
</CustomSelect>
</div>
</div> </div>
</div> </div>
)} )}

View File

@ -5,11 +5,12 @@ import useSWR from "swr";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// component // component
import { CustomSearchSelect, CustomSelect, ToggleSwitch } from "components/ui"; import { CustomSearchSelect, CustomSelect, Icon, ToggleSwitch } from "components/ui";
import { SelectMonthModal } from "components/automation"; import { SelectMonthModal } from "components/automation";
// icons // icons
import { ChevronDownIcon, Squares2X2Icon } from "@heroicons/react/24/outline"; import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { StateGroupIcon } from "components/icons"; import { StateGroupIcon } from "components/icons";
import { ArchiveX } from "lucide-react";
// services // services
import stateService from "services/state.service"; import stateService from "services/state.service";
// constants // constants
@ -76,14 +77,18 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
handleChange={handleChange} handleChange={handleChange}
/> />
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-custom-border-300 bg-custom-background-90"> <div className="flex flex-col gap-4 border-b border-custom-border-200 px-4 py-6">
<div className="flex items-center justify-between gap-x-8 gap-y-2 "> <div className="flex items-center justify-between">
<div className="flex flex-col gap-2.5"> <div className="flex items-start gap-3">
<h4 className="text-lg font-semibold">Auto-close inactive issues</h4> <div className="flex items-center justify-center p-3 rounded bg-custom-background-90">
<p className="text-sm text-custom-text-200"> <ArchiveX className="h-4 w-4 text-red-500 flex-shrink-0" />
Plane will automatically close the issues that have not been updated for the </div>
configured time period. <div className="">
</p> <h4 className="text-sm font-medium">Auto-close issues</h4>
<p className="text-sm text-custom-text-200 tracking-tight">
Plane will automatically close issue that havent been completed or canceled.
</p>
</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
value={projectDetails?.close_in !== 0} value={projectDetails?.close_in !== 0}
@ -95,82 +100,86 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
size="sm" size="sm"
/> />
</div> </div>
{projectDetails?.close_in !== 0 && ( {projectDetails?.close_in !== 0 && (
<div className="flex flex-col gap-4 w-full"> <div className="ml-12">
<div className="flex items-center justify-between gap-2 w-full"> <div className="flex flex-col rounded bg-custom-background-90 border border-custom-border-200 p-2">
<div className="w-1/2 text-base font-medium"> <div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
Auto-close issues that are inactive for <div className="w-1/2 text-sm font-medium">
Auto-close issues that are inactive for
</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.close_in}
label={`${projectDetails?.close_in} ${
projectDetails?.close_in === 1 ? "Month" : "Months"
}`}
onChange={(val: number) => {
handleChange({ close_in: val });
}}
input
width="w-full"
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
{month.label}
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
Customise Time Range
</button>
</>
</CustomSelect>
</div>
</div> </div>
<div className="w-1/2">
<CustomSelect <div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
value={projectDetails?.close_in} <div className="w-1/2 text-sm font-medium">Auto-close Status</div>
label={`${projectDetails?.close_in} ${ <div className="w-1/2 ">
projectDetails?.close_in === 1 ? "Month" : "Months" <CustomSearchSelect
}`} value={
onChange={(val: number) => { projectDetails?.default_state ? projectDetails?.default_state : defaultState
handleChange({ close_in: val }); }
}} label={
input <div className="flex items-center gap-2">
width="w-full" {selectedOption ? (
> <StateGroupIcon
<> stateGroup={selectedOption.group}
{PROJECT_AUTOMATION_MONTHS.map((month) => ( color={selectedOption.color}
<CustomSelect.Option key={month.label} value={month.value}> height="16px"
{month.label} width="16px"
</CustomSelect.Option> />
))} ) : currentDefaultState ? (
<button <StateGroupIcon
type="button" stateGroup={currentDefaultState.group}
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80" color={currentDefaultState.color}
onClick={() => setmonthModal(true)} height="16px"
> width="16px"
Customize Time Range />
</button> ) : (
</> <Squares2X2Icon className="h-3.5 w-3.5 text-custom-text-200" />
</CustomSelect> )}
</div> {selectedOption?.name
</div> ? selectedOption.name
<div className="flex items-center justify-between gap-2 w-full"> : currentDefaultState?.name ?? (
<div className="w-1/2 text-base font-medium">Auto-close Status</div> <span className="text-custom-text-200">State</span>
<div className="w-1/2 "> )}
<CustomSearchSelect </div>
value={ }
projectDetails?.default_state ? projectDetails?.default_state : defaultState onChange={(val: string) => {
} handleChange({ default_state: val });
label={ }}
<div className="flex items-center gap-2"> options={options}
{selectedOption ? ( disabled={!multipleOptions}
<StateGroupIcon width="w-full"
stateGroup={selectedOption.group} input
color={selectedOption.color} />
height="16px" </div>
width="16px"
/>
) : currentDefaultState ? (
<StateGroupIcon
stateGroup={currentDefaultState.group}
color={currentDefaultState.color}
height="16px"
width="16px"
/>
) : (
<Squares2X2Icon className="h-3.5 w-3.5 text-custom-text-200" />
)}
{selectedOption?.name
? selectedOption.name
: currentDefaultState?.name ?? (
<span className="text-custom-text-200">State</span>
)}
</div>
}
onChange={(val: string) => {
handleChange({ default_state: val });
}}
options={options}
disabled={!multipleOptions}
width="w-full"
input
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -104,7 +104,7 @@ export const SelectMonthModal: React.FC<Props> = ({
as="h3" as="h3"
className="text-lg font-medium leading-6 text-custom-text-100" className="text-lg font-medium leading-6 text-custom-text-100"
> >
Customize Time Range Customise Time Range
</Dialog.Title> </Dialog.Title>
<div className="mt-8 flex items-center gap-2"> <div className="mt-8 flex items-center gap-2">
<div className="flex w-full flex-col gap-1 justify-center"> <div className="flex w-full flex-col gap-1 justify-center">

View File

@ -41,7 +41,7 @@ export const CommandPalette: React.FC = observer(() => {
const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false); const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId, inboxId } = router.query; const { workspaceSlug, projectId, issueId, inboxId, cycleId, moduleId } = router.query;
const { user } = useUser(); const { user } = useUser();
@ -183,6 +183,13 @@ export const CommandPalette: React.FC = observer(() => {
isOpen={isIssueModalOpen} isOpen={isIssueModalOpen}
handleClose={() => setIsIssueModalOpen(false)} handleClose={() => setIsIssueModalOpen(false)}
fieldsToShow={inboxId ? ["name", "description", "priority"] : ["all"]} fieldsToShow={inboxId ? ["name", "description", "priority"] : ["all"]}
prePopulateData={
cycleId
? { cycle: cycleId.toString() }
: moduleId
? { module: moduleId.toString() }
: undefined
}
/> />
<BulkDeleteIssuesModal <BulkDeleteIssuesModal
isOpen={isBulkDeleteIssuesModalOpen} isOpen={isBulkDeleteIssuesModalOpen}

View File

@ -1,14 +1,41 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr";
// hook
import useEstimateOption from "hooks/use-estimate-option";
// services
import issuesService from "services/issues.service";
// icons // icons
import { Icon, Tooltip } from "components/ui"; import { Icon, Tooltip } from "components/ui";
import { Squares2X2Icon } from "@heroicons/react/24/outline"; import {
import { BlockedIcon, BlockerIcon } from "components/icons"; TagIcon,
CopyPlus,
Calendar,
Link2Icon,
RocketIcon,
Users2Icon,
ArchiveIcon,
PaperclipIcon,
ContrastIcon,
TriangleIcon,
LayoutGridIcon,
SignalMediumIcon,
MessageSquareIcon,
} from "lucide-react";
import {
BlockedIcon,
BlockerIcon,
RelatedIcon,
StackedLayersHorizontalIcon,
} from "components/icons";
// helpers // helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper"; import { capitalizeFirstLetter } from "helpers/string.helper";
// types // types
import { IIssueActivity } from "types"; import { IIssueActivity } from "types";
// fetch-keys
import { WORKSPACE_LABELS } from "constants/fetch-keys";
const IssueLink = ({ activity }: { activity: IIssueActivity }) => { const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
const router = useRouter(); const router = useRouter();
@ -29,7 +56,7 @@ const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
{activity.issue_detail {activity.issue_detail
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}` ? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
: "Issue"} : "Issue"}
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
</Tooltip> </Tooltip>
); );
@ -51,6 +78,38 @@ const UserLink = ({ activity }: { activity: IIssueActivity }) => {
); );
}; };
const LabelPill = ({ labelId }: { labelId: string }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: labels } = useSWR(
workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null,
workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null
);
return (
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: labels?.find((l) => l.id === labelId)?.color ?? "#000000",
}}
aria-hidden="true"
/>
);
};
const EstimatePoint = ({ point }: { point: string }) => {
const { estimateValue, isEstimateActive } = useEstimateOption(Number(point));
const currentPoint = Number(point) + 1;
return (
<span className="font-medium text-custom-text-100">
{isEstimateActive
? estimateValue
: `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`}
</span>
);
};
const activityDetails: { const activityDetails: {
[key: string]: { [key: string]: {
message: ( message: (
@ -90,14 +149,14 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="group" className="!text-sm" aria-hidden="true" />, icon: <Users2Icon size={12} color="#6b7280" aria-hidden="true" />,
}, },
archived_at: { archived_at: {
message: (activity) => { message: (activity) => {
if (activity.new_value === "restore") return "restored the issue."; if (activity.new_value === "restore") return "restored the issue.";
else return "archived the issue."; else return "archived the issue.";
}, },
icon: <Icon iconName="archive" className="!text-sm" aria-hidden="true" />, icon: <ArchiveIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
attachment: { attachment: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -112,7 +171,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
attachment attachment
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
{showIssue && ( {showIssue && (
<> <>
@ -136,7 +195,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="attach_file" className="!text-sm" aria-hidden="true" />, icon: <PaperclipIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
blocking: { blocking: {
message: (activity) => { message: (activity) => {
@ -157,7 +216,7 @@ const activityDetails: {
}, },
icon: <BlockerIcon height="12" width="12" color="#6b7280" />, icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
}, },
blocks: { blocked_by: {
message: (activity) => { message: (activity) => {
if (activity.old_value === "") if (activity.old_value === "")
return ( return (
@ -176,6 +235,44 @@ const activityDetails: {
}, },
icon: <BlockedIcon height="12" width="12" color="#6b7280" />, icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
}, },
duplicate: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
marked this issue as duplicate of{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed this issue as a duplicate of{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <CopyPlus size={12} color="#6b7280" />,
},
relates_to: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
marked that this issue relates to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the relation from{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <RelatedIcon height="12" width="12" color="#6b7280" />,
},
cycles: { cycles: {
message: (activity, showIssue, workspaceSlug) => { message: (activity, showIssue, workspaceSlug) => {
if (activity.verb === "created") if (activity.verb === "created")
@ -189,7 +286,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
{activity.new_value} {activity.new_value}
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
</> </>
); );
@ -204,7 +301,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
{activity.new_value} {activity.new_value}
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
</> </>
); );
@ -219,12 +316,12 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
{activity.old_value} {activity.old_value}
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
</> </>
); );
}, },
icon: <Icon iconName="contrast" className="!text-sm" aria-hidden="true" />, icon: <ContrastIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
description: { description: {
message: (activity, showIssue) => ( message: (activity, showIssue) => (
@ -239,7 +336,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />, icon: <MessageSquareIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
estimate_point: { estimate_point: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -259,8 +356,7 @@ const activityDetails: {
else else
return ( return (
<> <>
set the estimate point to{" "} set the estimate point to <EstimatePoint point={activity.new_value} />
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue && ( {showIssue && (
<> <>
{" "} {" "}
@ -271,14 +367,14 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="change_history" className="!text-sm" aria-hidden="true" />, icon: <TriangleIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
issue: { issue: {
message: (activity) => { message: (activity) => {
if (activity.verb === "created") return "created the issue."; if (activity.verb === "created") return "created the issue.";
else return "deleted an issue."; else return "deleted an issue.";
}, },
icon: <Icon iconName="stack" className="!text-sm" aria-hidden="true" />, icon: <StackedLayersHorizontalIcon width={12} height={12} color="#6b7280" aria-hidden="true" />,
}, },
labels: { labels: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -286,14 +382,8 @@ const activityDetails: {
return ( return (
<> <>
added a new label{" "} added a new label{" "}
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs"> <span className="inline-flex items-center gap-2 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span <LabelPill labelId={activity.new_identifier ?? ""} />
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: "#000000",
}}
aria-hidden="true"
/>
<span className="font-medium text-custom-text-100">{activity.new_value}</span> <span className="font-medium text-custom-text-100">{activity.new_value}</span>
</span> </span>
{showIssue && ( {showIssue && (
@ -309,13 +399,7 @@ const activityDetails: {
<> <>
removed the label{" "} removed the label{" "}
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs"> <span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span <LabelPill labelId={activity.old_identifier ?? ""} />
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: "#000000",
}}
aria-hidden="true"
/>
<span className="font-medium text-custom-text-100">{activity.old_value}</span> <span className="font-medium text-custom-text-100">{activity.old_value}</span>
</span> </span>
{showIssue && ( {showIssue && (
@ -327,7 +411,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="sell" className="!text-sm" aria-hidden="true" />, icon: <TagIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
link: { link: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -342,7 +426,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
link link
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
{showIssue && ( {showIssue && (
<> <>
@ -364,7 +448,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
link link
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
{showIssue && ( {showIssue && (
<> <>
@ -386,7 +470,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
link link
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
{showIssue && ( {showIssue && (
<> <>
@ -398,7 +482,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="link" className="!text-sm" aria-hidden="true" />, icon: <Link2Icon size={12} color="#6b7280" aria-hidden="true" />,
}, },
modules: { modules: {
message: (activity, showIssue, workspaceSlug) => { message: (activity, showIssue, workspaceSlug) => {
@ -413,7 +497,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
{activity.new_value} {activity.new_value}
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
</> </>
); );
@ -428,7 +512,7 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
{activity.new_value} {activity.new_value}
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
</> </>
); );
@ -443,12 +527,12 @@ const activityDetails: {
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
> >
{activity.old_value} {activity.old_value}
<Icon iconName="launch" className="!text-xs" /> <RocketIcon size={12} color="#6b7280" />
</a> </a>
</> </>
); );
}, },
icon: <Icon iconName="dataset" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="dataset" className="!text-xs !text-[#6b7280]" aria-hidden="true" />,
}, },
name: { name: {
message: (activity, showIssue) => ( message: (activity, showIssue) => (
@ -463,7 +547,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />, icon: <MessageSquareIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
parent: { parent: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -496,7 +580,13 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="supervised_user_circle" className="!text-sm" aria-hidden="true" />, icon: (
<Icon
iconName="supervised_user_circle"
className="!text-xs !text-[#6b7280]"
aria-hidden="true"
/>
),
}, },
priority: { priority: {
message: (activity, showIssue) => ( message: (activity, showIssue) => (
@ -514,7 +604,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="signal_cellular_alt" className="!text-sm" aria-hidden="true" />, icon: <SignalMediumIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
start_date: { start_date: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -548,7 +638,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />, icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
}, },
state: { state: {
message: (activity, showIssue) => ( message: (activity, showIssue) => (
@ -564,7 +654,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Squares2X2Icon className="h-3 w-3" aria-hidden="true" />, icon: <LayoutGridIcon size={12} color="#6b7280" aria-hidden="true" />,
}, },
target_date: { target_date: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -598,7 +688,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />, icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
}, },
}; };

View File

@ -167,7 +167,9 @@ export const FiltersList: React.FC<Props> = ({
className="cursor-pointer" className="cursor-pointer"
onClick={() => onClick={() =>
setFilters({ setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId), subscriber: filters.subscriber?.filter(
(p: any) => p !== memberId
),
}) })
} }
> >

View File

@ -1,4 +1,5 @@
export * from "./date-filter-modal"; export * from "./date-filter-modal";
export * from "./date-filter-select"; export * from "./date-filter-select";
export * from "./filters-list"; export * from "./filters-list";
export * from "./workspace-filters-list";
export * from "./issues-view-filter"; export * from "./issues-view-filter";

View File

@ -52,22 +52,26 @@ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
}, },
]; ];
const issueViewForDraftIssues: { type: TIssueViewOptions; Icon: any }[] = [
{
type: "list",
Icon: FormatListBulletedOutlined,
},
{
type: "kanban",
Icon: GridViewOutlined,
},
];
export const IssuesFilterView: React.FC = () => { export const IssuesFilterView: React.FC = () => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query; const { workspaceSlug, projectId, viewId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues"); const isArchivedIssues = router.pathname.includes("archived-issues");
const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues";
const { const {
issueView, displayFilters,
setIssueView, setDisplayFilters,
groupByProperty,
setGroupByProperty,
orderBy,
setOrderBy,
showEmptyGroups,
showSubIssues,
setShowSubIssues,
setShowEmptyGroups,
filters, filters,
setFilters, setFilters,
resetFilterToDefault, resetFilterToDefault,
@ -83,9 +87,41 @@ export const IssuesFilterView: React.FC = () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isArchivedIssues && ( {!isArchivedIssues && !isDraftIssues && (
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
{issueViewOptions.map((option) => ( {issueViewOptions.map((option) => (
<Tooltip
key={option.type}
tooltipContent={
<span className="capitalize">
{replaceUnderscoreIfSnakeCase(option.type)} Layout
</span>
}
position="bottom"
>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${
displayFilters.layout === option.type
? "bg-custom-sidebar-background-80"
: "text-custom-sidebar-text-200"
}`}
onClick={() => setDisplayFilters({ layout: option.type })}
>
<option.Icon
sx={{
fontSize: 16,
}}
className={option.type === "gantt_chart" ? "rotate-90" : ""}
/>
</button>
</Tooltip>
))}
</div>
)}
{isDraftIssues && (
<div className="flex items-center gap-x-1">
{issueViewForDraftIssues.map((option) => (
<Tooltip <Tooltip
key={option.type} key={option.type}
tooltipContent={ tooltipContent={
@ -96,11 +132,11 @@ export const IssuesFilterView: React.FC = () => {
<button <button
type="button" type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${ className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${
issueView === option.type displayFilters.layout === option.type
? "bg-custom-sidebar-background-80" ? "bg-custom-sidebar-background-80"
: "text-custom-sidebar-text-200" : "text-custom-sidebar-text-200"
}`} }`}
onClick={() => setIssueView(option.type)} onClick={() => setDisplayFilters({ layout: option.type })}
> >
<option.Icon <option.Icon
sx={{ sx={{
@ -174,28 +210,33 @@ export const IssuesFilterView: React.FC = () => {
<Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-custom-border-200 bg-custom-background-90 p-3 shadow-lg"> <Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-custom-border-200 bg-custom-background-90 p-3 shadow-lg">
<div className="relative divide-y-2 divide-custom-border-200"> <div className="relative divide-y-2 divide-custom-border-200">
<div className="space-y-4 pb-3 text-xs"> <div className="space-y-4 pb-3 text-xs">
{issueView !== "calendar" && {displayFilters.layout !== "calendar" &&
issueView !== "spreadsheet" && displayFilters.layout !== "spreadsheet" &&
issueView !== "gantt_chart" && ( displayFilters.layout !== "gantt_chart" && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Group by</h4> <h4 className="text-custom-text-200">Group by</h4>
<div className="w-28"> <div className="w-28">
<CustomMenu <CustomMenu
label={ label={
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty) GROUP_BY_OPTIONS.find(
?.name ?? "Select" (option) => option.key === displayFilters.group_by
)?.name ?? "Select"
} }
className="!w-full" className="!w-full"
buttonClassName="w-full" buttonClassName="w-full"
> >
{GROUP_BY_OPTIONS.map((option) => { {GROUP_BY_OPTIONS.map((option) => {
if (issueView === "kanban" && option.key === null) return null; if (displayFilters.layout === "kanban" && option.key === null)
return null;
if (option.key === "project") return null; if (option.key === "project") return null;
if (isDraftIssues && option.key === "state_detail.group")
return null;
return ( return (
<CustomMenu.MenuItem <CustomMenu.MenuItem
key={option.key} key={option.key}
onClick={() => setGroupByProperty(option.key)} onClick={() => setDisplayFilters({ group_by: option.key })}
> >
{option.name} {option.name}
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
@ -205,88 +246,101 @@ export const IssuesFilterView: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{issueView !== "calendar" && issueView !== "spreadsheet" && ( {displayFilters.layout !== "calendar" &&
displayFilters.layout !== "spreadsheet" && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4>
<div className="w-28">
<CustomMenu
label={
ORDER_BY_OPTIONS.find(
(option) => option.key === displayFilters.order_by
)?.name ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{ORDER_BY_OPTIONS.map((option) =>
displayFilters.group_by === "priority" &&
option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setDisplayFilters({ order_by: option.key });
}}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
</div>
)}
{!isArchivedIssues && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4> <h4 className="text-custom-text-200">Issue type</h4>
<div className="w-28"> <div className="w-28">
<CustomMenu <CustomMenu
label={ label={
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ?? FILTER_ISSUE_OPTIONS.find(
"Select" (option) => option.key === displayFilters.type
)?.name ?? "Select"
} }
className="!w-full" className="!w-full"
buttonClassName="w-full" buttonClassName="w-full"
> >
{ORDER_BY_OPTIONS.map((option) => {FILTER_ISSUE_OPTIONS.map((option) => (
groupByProperty === "priority" && option.key === "priority" ? null : ( <CustomMenu.MenuItem
<CustomMenu.MenuItem key={option.key}
key={option.key} onClick={() =>
onClick={() => { setDisplayFilters({
setOrderBy(option.key); type: option.key,
}} })
> }
{option.name} >
</CustomMenu.MenuItem> {option.name}
) </CustomMenu.MenuItem>
)} ))}
</CustomMenu> </CustomMenu>
</div> </div>
</div> </div>
)} )}
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4>
<div className="w-28">
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters.type)
?.name ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setFilters({
type: option.key,
})
}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
</div>
{issueView !== "calendar" && issueView !== "spreadsheet" && ( {displayFilters.layout !== "calendar" &&
<div className="flex items-center justify-between"> displayFilters.layout !== "spreadsheet" && (
<h4 className="text-custom-text-200">Show sub-issues</h4>
<div className="w-28">
<ToggleSwitch
value={showSubIssues}
onChange={() => setShowSubIssues(!showSubIssues)}
/>
</div>
</div>
)}
{issueView !== "calendar" &&
issueView !== "spreadsheet" &&
issueView !== "gantt_chart" && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty states</h4> <h4 className="text-custom-text-200">Show sub-issues</h4>
<div className="w-28"> <div className="w-28">
<ToggleSwitch <ToggleSwitch
value={showEmptyGroups} value={displayFilters.sub_issue ?? true}
onChange={() => setShowEmptyGroups(!showEmptyGroups)} onChange={() =>
setDisplayFilters({ sub_issue: !displayFilters.sub_issue })
}
/> />
</div> </div>
</div> </div>
)} )}
{issueView !== "calendar" && {displayFilters.layout !== "calendar" &&
issueView !== "spreadsheet" && displayFilters.layout !== "spreadsheet" &&
issueView !== "gantt_chart" && ( displayFilters.layout !== "gantt_chart" && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty groups</h4>
<div className="w-28">
<ToggleSwitch
value={displayFilters.show_empty_groups ?? true}
onChange={() =>
setDisplayFilters({
show_empty_groups: !displayFilters.show_empty_groups,
})
}
/>
</div>
</div>
)}
{displayFilters.layout !== "calendar" &&
displayFilters.layout !== "spreadsheet" &&
displayFilters.layout !== "gantt_chart" && (
<div className="relative flex justify-end gap-x-3"> <div className="relative flex justify-end gap-x-3">
<button type="button" onClick={() => resetFilterToDefault()}> <button type="button" onClick={() => resetFilterToDefault()}>
Reset to default Reset to default
@ -302,7 +356,7 @@ export const IssuesFilterView: React.FC = () => {
)} )}
</div> </div>
{issueView !== "gantt_chart" && ( {displayFilters.layout !== "gantt_chart" && (
<div className="space-y-2 py-3"> <div className="space-y-2 py-3">
<h4 className="text-sm text-custom-text-200">Display Properties</h4> <h4 className="text-sm text-custom-text-200">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2 text-custom-text-200"> <div className="flex flex-wrap items-center gap-2 text-custom-text-200">
@ -310,7 +364,7 @@ export const IssuesFilterView: React.FC = () => {
if (key === "estimate" && !isEstimateActive) return null; if (key === "estimate" && !isEstimateActive) return null;
if ( if (
issueView === "spreadsheet" && displayFilters.layout === "spreadsheet" &&
(key === "attachment_count" || (key === "attachment_count" ||
key === "link" || key === "link" ||
key === "sub_issue_count") key === "sub_issue_count")
@ -318,7 +372,7 @@ export const IssuesFilterView: React.FC = () => {
return null; return null;
if ( if (
issueView !== "spreadsheet" && displayFilters.layout !== "spreadsheet" &&
(key === "created_on" || key === "updated_on") (key === "created_on" || key === "updated_on")
) )
return null; return null;

View File

@ -0,0 +1,364 @@
import React from "react";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
import { PriorityIcon, StateGroupIcon } from "components/icons";
// ui
import { Avatar } from "components/ui";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types
import {
IIssueLabels,
IProject,
IUserLite,
IWorkspaceIssueFilterOptions,
TStateGroups,
} from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
type Props = {
filters: Partial<IWorkspaceIssueFilterOptions>;
setFilters: (updatedFilter: Partial<IWorkspaceIssueFilterOptions>) => void;
clearAllFilters: (...args: any) => void;
labels: IIssueLabels[] | undefined;
members: IUserLite[] | undefined;
stateGroup: string[] | undefined;
project?: IProject[] | undefined;
};
export const WorkspaceFiltersList: React.FC<Props> = ({
filters,
setFilters,
clearAllFilters,
labels,
members,
stateGroup,
project,
}) => {
if (!filters) return <></>;
const nullFilters = Object.keys(filters).filter(
(key) => filters[key as keyof IWorkspaceIssueFilterOptions] === null
);
return (
<div className="flex flex-1 flex-wrap items-center gap-2 text-xs">
{Object.keys(filters).map((filterKey) => {
const key = filterKey as keyof typeof filters;
if (filters[key] === null || (filters[key]?.length ?? 0) <= 0) return null;
return (
<div
key={key}
className="flex items-center gap-x-2 rounded-full border border-custom-border-200 bg-custom-background-80 px-2 py-1"
>
<span className="capitalize text-custom-text-200">
{key === "target_date" ? "Due Date" : replaceUnderscoreIfSnakeCase(key)}:
</span>
{filters[key] === null || (filters[key]?.length ?? 0) <= 0 ? (
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
) : Array.isArray(filters[key]) ? (
<div className="space-x-2">
<div className="flex flex-wrap items-center gap-1">
{key === "state_group"
? filters.state_group?.map((stateGroup) => {
const group = stateGroup as TStateGroups;
return (
<p
key={group}
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
style={{
color: STATE_GROUP_COLORS[group],
backgroundColor: `${STATE_GROUP_COLORS[group]}20`,
}}
>
<span>
<StateGroupIcon stateGroup={group} color={undefined} />
</span>
<span>{group}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
state_group: filters.state_group?.filter((g) => g !== group),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
);
})
: key === "priority"
? filters.priority?.map((priority: any) => (
<p
key={priority}
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize ${
priority === "urgent"
? "bg-red-500/20 text-red-500"
: priority === "high"
? "bg-orange-500/20 text-orange-500"
: priority === "medium"
? "bg-yellow-500/20 text-yellow-500"
: priority === "low"
? "bg-green-500/20 text-green-500"
: "bg-custom-background-90 text-custom-text-200"
}`}
>
<span>
<PriorityIcon priority={priority} />
</span>
<span>{priority === "null" ? "None" : priority}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
priority: filters.priority?.filter((p: any) => p !== priority),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
))
: key === "assignees"
? filters.assignees?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})
: key === "subscriber"
? filters.subscriber?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})
: key === "created_by"
? filters.created_by?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={`${memberId}-${key}`}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
created_by: filters.created_by?.filter(
(p: any) => p !== memberId
),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})
: key === "labels"
? filters.labels?.map((labelId: string) => {
const label = labels?.find((l) => l.id === labelId);
if (!label) return null;
const color = label.color !== "" ? label.color : "#0f172a";
return (
<div
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5"
style={{
color: color,
backgroundColor: `${color}20`, // add 20% opacity
}}
key={labelId}
>
<div
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: color,
}}
/>
<span>{label.name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
labels: filters.labels?.filter((l: any) => l !== labelId),
})
}
>
<XMarkIcon
className="h-3 w-3"
style={{
color: color,
}}
/>
</span>
</div>
);
})
: key === "start_date"
? filters.start_date?.map((date: string) => {
if (filters.start_date && filters.start_date.length <= 0) return null;
const splitDate = date.split(";");
return (
<div
key={date}
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
>
<div className="h-1.5 w-1.5 rounded-full" />
<span className="capitalize">
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
start_date: filters.start_date?.filter((d: any) => d !== date),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})
: key === "target_date"
? filters.target_date?.map((date: string) => {
if (filters.target_date && filters.target_date.length <= 0) return null;
const splitDate = date.split(";");
return (
<div
key={date}
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
>
<div className="h-1.5 w-1.5 rounded-full" />
<span className="capitalize">
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
target_date: filters.target_date?.filter((d: any) => d !== date),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})
: key === "project"
? filters.project?.map((projectId) => {
const currentProject = project?.find((p) => p.id === projectId);
return (
<p
key={currentProject?.id}
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
>
<span>{currentProject?.name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
project: filters.project?.filter((p) => p !== projectId),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
);
})
: (filters[key] as any)?.join(", ")}
<button
type="button"
onClick={() =>
setFilters({
[key]: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
</div>
) : (
<div className="flex items-center gap-x-1 capitalize">
{filters[key as keyof typeof filters]}
<button
type="button"
onClick={() =>
setFilters({
[key]: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
)}
</div>
);
})}
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
<button
type="button"
onClick={clearAllFilters}
className="flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-80 px-3 py-1.5 text-xs"
>
<span>Clear all filters</span>
<XMarkIcon className="h-3 w-3" />
</button>
)}
</div>
);
};

View File

@ -20,6 +20,7 @@ import fileService from "services/file.service";
import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui"; import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui";
// hooks // hooks
import useWorkspaceDetails from "hooks/use-workspace-details"; import useWorkspaceDetails from "hooks/use-workspace-details";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
const unsplashEnabled = const unsplashEnabled =
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" || process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
@ -67,6 +68,8 @@ export const ImagePickerPopover: React.FC<Props> = ({
fileService.getUnsplashImages(1, searchParams) fileService.getUnsplashImages(1, searchParams)
); );
const imagePickerRef = useRef<HTMLDivElement>(null);
const { workspaceDetails } = useWorkspaceDetails(); const { workspaceDetails } = useWorkspaceDetails();
const onDrop = useCallback((acceptedFiles: File[]) => { const onDrop = useCallback((acceptedFiles: File[]) => {
@ -116,12 +119,14 @@ export const ImagePickerPopover: React.FC<Props> = ({
onChange(images[0].urls.regular); onChange(images[0].urls.regular);
}, [value, onChange, images]); }, [value, onChange, images]);
useOutsideClickDetector(imagePickerRef, () => setIsOpen(false));
if (!unsplashEnabled) return null; if (!unsplashEnabled) return null;
return ( return (
<Popover className="relative z-[2]" ref={ref}> <Popover className="relative z-[2]" ref={ref}>
<Popover.Button <Popover.Button
className="rounded-md border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100" className="rounded-sm border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
onClick={() => setIsOpen((prev) => !prev)} onClick={() => setIsOpen((prev) => !prev)}
disabled={disabled} disabled={disabled}
> >
@ -137,7 +142,10 @@ export const ImagePickerPopover: React.FC<Props> = ({
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-80 shadow-lg"> <Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-80 shadow-lg">
<div className="h-96 md:h-[28rem] w-80 md:w-[36rem] flex flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl"> <div
ref={imagePickerRef}
className="h-96 md:h-[28rem] w-80 md:w-[36rem] flex flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl"
>
<Tab.Group> <Tab.Group>
<div> <div>
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1"> <Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">

View File

@ -58,7 +58,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
); );
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { issueView, params } = useIssuesView(); const { displayFilters, params } = useIssuesView();
const { params: calendarParams } = useCalendarIssuesView(); const { params: calendarParams } = useCalendarIssuesView();
const { order_by, group_by, ...viewGanttParams } = params; const { order_by, group_by, ...viewGanttParams } = params;
@ -126,8 +126,8 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
message: "Issues deleted successfully!", message: "Issues deleted successfully!",
}); });
if (issueView === "calendar") mutate(calendarFetchKey); if (displayFilters.layout === "calendar") mutate(calendarFetchKey);
else if (issueView === "gantt_chart") mutate(ganttFetchKey); else if (displayFilters.layout === "gantt_chart") mutate(ganttFetchKey);
else { else {
if (cycleId) { if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)); mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params));

View File

@ -1,6 +1,5 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import NextImage from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// react-dropzone // react-dropzone
@ -12,7 +11,7 @@ import fileServices from "services/file.service";
// hooks // hooks
import useWorkspaceDetails from "hooks/use-workspace-details"; import useWorkspaceDetails from "hooks/use-workspace-details";
// ui // ui
import { PrimaryButton, SecondaryButton } from "components/ui"; import { DangerButton, PrimaryButton, SecondaryButton } from "components/ui";
// icons // icons
import { UserCircleIcon } from "components/icons"; import { UserCircleIcon } from "components/icons";
@ -21,6 +20,8 @@ type Props = {
onClose: () => void; onClose: () => void;
isOpen: boolean; isOpen: boolean;
onSuccess: (url: string) => void; onSuccess: (url: string) => void;
isRemoving: boolean;
handleDelete: () => void;
userImage?: boolean; userImage?: boolean;
}; };
@ -29,6 +30,8 @@ export const ImageUploadModal: React.FC<Props> = ({
onSuccess, onSuccess,
isOpen, isOpen,
onClose, onClose,
isRemoving,
handleDelete,
userImage, userImage,
}) => { }) => {
const [image, setImage] = useState<File | null>(null); const [image, setImage] = useState<File | null>(null);
@ -148,12 +151,10 @@ export const ImageUploadModal: React.FC<Props> = ({
> >
Edit Edit
</button> </button>
<NextImage <img
layout="fill"
objectFit="cover"
src={image ? URL.createObjectURL(image) : value ? value : ""} src={image ? URL.createObjectURL(image) : value ? value : ""}
alt="image" alt="image"
className="rounded-lg" className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
/> />
</> </>
) : ( ) : (
@ -182,15 +183,22 @@ export const ImageUploadModal: React.FC<Props> = ({
<p className="my-4 text-custom-text-200 text-sm"> <p className="my-4 text-custom-text-200 text-sm">
File formats supported- .jpeg, .jpg, .png, .webp, .svg File formats supported- .jpeg, .jpg, .png, .webp, .svg
</p> </p>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-between">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton> <div className="flex items-center">
<PrimaryButton <DangerButton onClick={handleDelete} outline disabled={!value}>
onClick={handleSubmit} {isRemoving ? "Removing..." : "Remove"}
disabled={!image} </DangerButton>
loading={isImageUploading} </div>
> <div className="flex items-center gap-2">
{isImageUploading ? "Uploading..." : "Upload & Save"} <SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
</PrimaryButton> <PrimaryButton
onClick={handleSubmit}
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
</PrimaryButton>
</div>
</div> </div>
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>

View File

@ -1,4 +1,4 @@
import React, { useCallback } from "react"; import React, { useCallback, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -12,6 +12,7 @@ import stateService from "services/state.service";
// hooks // hooks
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
import { useProjectMyMembership } from "contexts/project-member.context"; import { useProjectMyMembership } from "contexts/project-member.context";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
// components // components
import { import {
AllLists, AllLists,
@ -50,6 +51,7 @@ type Props = {
secondaryButton?: React.ReactNode; secondaryButton?: React.ReactNode;
}; };
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleOnDragEnd: (result: DropResult) => Promise<void>; handleOnDragEnd: (result: DropResult) => Promise<void>;
openIssuesListModal: (() => void) | null; openIssuesListModal: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null;
@ -66,6 +68,7 @@ export const AllViews: React.FC<Props> = ({
dragDisabled = false, dragDisabled = false,
emptyState, emptyState,
handleIssueAction, handleIssueAction,
handleDraftIssueAction,
handleOnDragEnd, handleOnDragEnd,
openIssuesListModal, openIssuesListModal,
removeIssue, removeIssue,
@ -77,10 +80,14 @@ export const AllViews: React.FC<Props> = ({
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const [myIssueProjectId, setMyIssueProjectId] = useState<string | null>(null);
const { user } = useUser(); const { user } = useUser();
const { memberRole } = useProjectMyMembership(); const { memberRole } = useProjectMyMembership();
const { groupedIssues, isEmpty, issueView } = viewProps; const { groupedIssues, isEmpty, displayFilters } = viewProps;
const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView();
const { data: stateGroups } = useSWR( const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
@ -90,6 +97,10 @@ export const AllViews: React.FC<Props> = ({
); );
const states = getStatesList(stateGroups); const states = getStatesList(stateGroups);
const handleMyIssueOpen = (issue: IIssue) => {
setMyIssueProjectId(issue.project);
};
const handleTrashBox = useCallback( const handleTrashBox = useCallback(
(isDragging: boolean) => { (isDragging: boolean) => {
if (isDragging && !trashBox) setTrashBox(true); if (isDragging && !trashBox) setTrashBox(true);
@ -117,39 +128,45 @@ export const AllViews: React.FC<Props> = ({
</StrictModeDroppable> </StrictModeDroppable>
{groupedIssues ? ( {groupedIssues ? (
!isEmpty || !isEmpty ||
issueView === "kanban" || displayFilters?.layout === "kanban" ||
issueView === "calendar" || displayFilters?.layout === "calendar" ||
issueView === "gantt_chart" ? ( displayFilters?.layout === "gantt_chart" ? (
<> <>
{issueView === "list" ? ( {displayFilters?.layout === "list" ? (
<AllLists <AllLists
states={states} states={states}
addIssueToGroup={addIssueToGroup} addIssueToGroup={addIssueToGroup}
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
handleDraftIssueAction={handleDraftIssueAction}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null} openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
removeIssue={removeIssue} removeIssue={removeIssue}
myIssueProjectId={myIssueProjectId}
handleMyIssueOpen={handleMyIssueOpen}
disableUserActions={disableUserActions} disableUserActions={disableUserActions}
disableAddIssueOption={disableAddIssueOption} disableAddIssueOption={disableAddIssueOption}
user={user} user={user}
userAuth={memberRole} userAuth={memberRole}
viewProps={viewProps} viewProps={viewProps}
/> />
) : issueView === "kanban" ? ( ) : displayFilters?.layout === "kanban" ? (
<AllBoards <AllBoards
addIssueToGroup={addIssueToGroup} addIssueToGroup={addIssueToGroup}
disableUserActions={disableUserActions} disableUserActions={disableUserActions}
disableAddIssueOption={disableAddIssueOption} disableAddIssueOption={disableAddIssueOption}
dragDisabled={dragDisabled} dragDisabled={dragDisabled}
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
handleDraftIssueAction={handleDraftIssueAction}
handleTrashBox={handleTrashBox} handleTrashBox={handleTrashBox}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null} openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
myIssueProjectId={myIssueProjectId}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={removeIssue} removeIssue={removeIssue}
states={states} states={states}
user={user} user={user}
userAuth={memberRole} userAuth={memberRole}
viewProps={viewProps} viewProps={viewProps}
/> />
) : issueView === "calendar" ? ( ) : displayFilters?.layout === "calendar" ? (
<CalendarView <CalendarView
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
addIssueToDate={addIssueToDate} addIssueToDate={addIssueToDate}
@ -157,16 +174,20 @@ export const AllViews: React.FC<Props> = ({
user={user} user={user}
userAuth={memberRole} userAuth={memberRole}
/> />
) : issueView === "spreadsheet" ? ( ) : displayFilters?.layout === "spreadsheet" ? (
<SpreadsheetView <SpreadsheetView
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
spreadsheetIssues={spreadsheetIssues}
mutateIssues={mutateIssues}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null} openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
disableUserActions={disableUserActions} disableUserActions={disableUserActions}
user={user} user={user}
userAuth={memberRole} userAuth={memberRole}
/> />
) : ( ) : (
issueView === "gantt_chart" && <GanttChartView /> displayFilters?.layout === "gantt_chart" && (
<GanttChartView disableUserActions={disableUserActions} />
)
)} )}
</> </>
) : router.pathname.includes("archived-issues") ? ( ) : router.pathname.includes("archived-issues") ? (

View File

@ -1,5 +1,12 @@
import { useRouter } from "next/router";
//hook
import useMyIssues from "hooks/my-issues/use-my-issues";
import useIssuesView from "hooks/use-issues-view";
import useProfileIssues from "hooks/use-profile-issues";
// components // components
import { SingleBoard } from "components/core/views/board-view/single-board"; import { SingleBoard } from "components/core/views/board-view/single-board";
import { IssuePeekOverview } from "components/issues";
// icons // icons
import { StateGroupIcon } from "components/icons"; import { StateGroupIcon } from "components/icons";
// helpers // helpers
@ -13,9 +20,12 @@ type Props = {
disableAddIssueOption?: boolean; disableAddIssueOption?: boolean;
dragDisabled: boolean; dragDisabled: boolean;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleTrashBox: (isDragging: boolean) => void; handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null;
myIssueProjectId?: string | null;
handleMyIssueOpen?: (issue: IIssue) => void;
states: IState[] | undefined; states: IState[] | undefined;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
@ -28,25 +38,53 @@ export const AllBoards: React.FC<Props> = ({
disableAddIssueOption = false, disableAddIssueOption = false,
dragDisabled, dragDisabled,
handleIssueAction, handleIssueAction,
handleDraftIssueAction,
handleTrashBox, handleTrashBox,
openIssuesListModal, openIssuesListModal,
myIssueProjectId,
handleMyIssueOpen,
removeIssue, removeIssue,
states, states,
user, user,
userAuth, userAuth,
viewProps, viewProps,
}) => { }) => {
const { groupByProperty: selectedGroup, groupedIssues, showEmptyGroups } = viewProps; const router = useRouter();
const { workspaceSlug, projectId, userId } = router.query;
const isProfileIssue =
router.pathname.includes("assigned") ||
router.pathname.includes("created") ||
router.pathname.includes("subscribed");
const isMyIssue = router.pathname.includes("my-issues");
const { mutateIssues } = useIssuesView();
const { mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
const { mutateProfileIssues } = useProfileIssues(workspaceSlug?.toString(), userId?.toString());
const { displayFilters, groupedIssues } = viewProps;
return ( return (
<> <>
<IssuePeekOverview
handleMutation={() =>
isMyIssue ? mutateMyIssues() : isProfileIssue ? mutateProfileIssues() : mutateIssues()
}
projectId={myIssueProjectId ? myIssueProjectId : projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={disableUserActions}
/>
{groupedIssues ? ( {groupedIssues ? (
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8"> <div className="horizontal-scroll-enable flex h-full gap-x-4 p-8 bg-custom-background-90">
{Object.keys(groupedIssues).map((singleGroup, index) => { {Object.keys(groupedIssues).map((singleGroup, index) => {
const currentState = const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null; displayFilters?.group_by === "state"
? states?.find((s) => s.id === singleGroup)
: null;
if (!showEmptyGroups && groupedIssues[singleGroup].length === 0) return null; if (!displayFilters?.show_empty_groups && groupedIssues[singleGroup].length === 0)
return null;
return ( return (
<SingleBoard <SingleBoard
@ -58,8 +96,10 @@ export const AllBoards: React.FC<Props> = ({
dragDisabled={dragDisabled} dragDisabled={dragDisabled}
groupTitle={singleGroup} groupTitle={singleGroup}
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
handleDraftIssueAction={handleDraftIssueAction}
handleTrashBox={handleTrashBox} handleTrashBox={handleTrashBox}
openIssuesListModal={openIssuesListModal ?? null} openIssuesListModal={openIssuesListModal ?? null}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={removeIssue} removeIssue={removeIssue}
user={user} user={user}
userAuth={userAuth} userAuth={userAuth}
@ -67,13 +107,15 @@ export const AllBoards: React.FC<Props> = ({
/> />
); );
})} })}
{!showEmptyGroups && ( {!displayFilters?.show_empty_groups && (
<div className="h-full w-96 flex-shrink-0 space-y-2 p-1"> <div className="h-full w-96 flex-shrink-0 space-y-2 p-1">
<h2 className="text-lg font-semibold">Hidden groups</h2> <h2 className="text-lg font-semibold">Hidden groups</h2>
<div className="space-y-3"> <div className="space-y-3">
{Object.keys(groupedIssues).map((singleGroup, index) => { {Object.keys(groupedIssues).map((singleGroup, index) => {
const currentState = const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null; displayFilters?.group_by === "state"
? states?.find((s) => s.id === singleGroup)
: null;
if (groupedIssues[singleGroup].length === 0) if (groupedIssues[singleGroup].length === 0)
return ( return (
@ -91,7 +133,7 @@ export const AllBoards: React.FC<Props> = ({
/> />
)} )}
<h4 className="text-sm capitalize"> <h4 className="text-sm capitalize">
{selectedGroup === "state" {displayFilters?.group_by === "state"
? addSpaceIfCamelCase(currentState?.name ?? "") ? addSpaceIfCamelCase(currentState?.name ?? "")
: addSpaceIfCamelCase(singleGroup)} : addSpaceIfCamelCase(singleGroup)}
</h4> </h4>

View File

@ -20,7 +20,7 @@ import { renderEmoji } from "helpers/emoji.helper";
// types // types
import { IIssueViewProps, IState, TIssuePriorities, TStateGroups } from "types"; import { IIssueViewProps, IState, TIssuePriorities, TStateGroups } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys"; import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, WORKSPACE_LABELS } from "constants/fetch-keys";
// constants // constants
import { STATE_GROUP_COLORS } from "constants/state"; import { STATE_GROUP_COLORS } from "constants/state";
@ -48,22 +48,35 @@ export const BoardHeader: React.FC<Props> = ({
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { groupedIssues, groupByProperty: selectedGroup } = viewProps; const { displayFilters, groupedIssues } = viewProps;
const { data: issueLabels } = useSWR( const { data: issueLabels } = useSWR(
workspaceSlug && projectId && selectedGroup === "labels" workspaceSlug && projectId && displayFilters?.group_by === "labels"
? PROJECT_ISSUE_LABELS(projectId.toString()) ? PROJECT_ISSUE_LABELS(projectId.toString())
: null, : null,
workspaceSlug && projectId && selectedGroup === "labels" workspaceSlug && projectId && displayFilters?.group_by === "labels"
? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString()) ? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString())
: null : null
); );
const { data: workspaceLabels } = useSWR(
workspaceSlug && displayFilters?.group_by === "labels"
? WORKSPACE_LABELS(workspaceSlug.toString())
: null,
workspaceSlug && displayFilters?.group_by === "labels"
? () => issuesService.getWorkspaceLabels(workspaceSlug.toString())
: null
);
const { data: members } = useSWR( const { data: members } = useSWR(
workspaceSlug && projectId && (selectedGroup === "created_by" || selectedGroup === "assignees") workspaceSlug &&
projectId &&
(displayFilters?.group_by === "created_by" || displayFilters?.group_by === "assignees")
? PROJECT_MEMBERS(projectId.toString()) ? PROJECT_MEMBERS(projectId.toString())
: null, : null,
workspaceSlug && projectId && (selectedGroup === "created_by" || selectedGroup === "assignees") workspaceSlug &&
projectId &&
(displayFilters?.group_by === "created_by" || displayFilters?.group_by === "assignees")
? () => projectService.projectMembers(workspaceSlug.toString(), projectId.toString()) ? () => projectService.projectMembers(workspaceSlug.toString(), projectId.toString())
: null : null
); );
@ -73,12 +86,15 @@ export const BoardHeader: React.FC<Props> = ({
const getGroupTitle = () => { const getGroupTitle = () => {
let title = addSpaceIfCamelCase(groupTitle); let title = addSpaceIfCamelCase(groupTitle);
switch (selectedGroup) { switch (displayFilters?.group_by) {
case "state": case "state":
title = addSpaceIfCamelCase(currentState?.name ?? ""); title = addSpaceIfCamelCase(currentState?.name ?? "");
break; break;
case "labels": case "labels":
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None"; title =
[...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find(
(label) => label.id === groupTitle
)?.name ?? "None";
break; break;
case "project": case "project":
title = projects?.find((p) => p.id === groupTitle)?.name ?? "None"; title = projects?.find((p) => p.id === groupTitle)?.name ?? "None";
@ -97,7 +113,7 @@ export const BoardHeader: React.FC<Props> = ({
const getGroupIcon = () => { const getGroupIcon = () => {
let icon; let icon;
switch (selectedGroup) { switch (displayFilters?.group_by) {
case "state": case "state":
icon = currentState && ( icon = currentState && (
<StateGroupIcon <StateGroupIcon
@ -133,7 +149,9 @@ export const BoardHeader: React.FC<Props> = ({
break; break;
case "labels": case "labels":
const labelColor = const labelColor =
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000"; [...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find(
(label) => label.id === groupTitle
)?.color ?? "#000000";
icon = ( icon = (
<span <span
className="h-3.5 w-3.5 flex-shrink-0 rounded-full" className="h-3.5 w-3.5 flex-shrink-0 rounded-full"
@ -167,7 +185,7 @@ export const BoardHeader: React.FC<Props> = ({
<span className="flex items-center">{getGroupIcon()}</span> <span className="flex items-center">{getGroupIcon()}</span>
<h2 <h2
className={`text-lg font-semibold truncate ${ className={`text-lg font-semibold truncate ${
selectedGroup === "created_by" ? "" : "capitalize" displayFilters?.group_by === "created_by" ? "" : "capitalize"
}`} }`}
style={{ style={{
writingMode: isCollapsed ? "horizontal-tb" : "vertical-rl", writingMode: isCollapsed ? "horizontal-tb" : "vertical-rl",
@ -198,7 +216,7 @@ export const BoardHeader: React.FC<Props> = ({
<Icon iconName="open_in_full" className="text-base font-medium text-custom-text-900" /> <Icon iconName="open_in_full" className="text-base font-medium text-custom-text-900" />
)} )}
</button> </button>
{!disableAddIssue && !disableUserActions && selectedGroup !== "created_by" && ( {!disableAddIssue && !disableUserActions && displayFilters?.group_by !== "created_by" && (
<button <button
type="button" type="button"
className="grid h-7 w-7 place-items-center rounded p-1 text-custom-text-200 outline-none duration-300 hover:bg-custom-background-80" className="grid h-7 w-7 place-items-center rounded p-1 text-custom-text-200 outline-none duration-300 hover:bg-custom-background-80"

View File

@ -2,3 +2,4 @@ export * from "./all-boards";
export * from "./board-header"; export * from "./board-header";
export * from "./single-board"; export * from "./single-board";
export * from "./single-issue"; export * from "./single-issue";
export * from "./inline-create-issue-form";

View File

@ -0,0 +1,62 @@
import { useEffect } from "react";
// react hook form
import { useFormContext } from "react-hook-form";
// components
import { InlineCreateIssueFormWrapper } from "components/core";
// hooks
import useProjectDetails from "hooks/use-project-details";
// types
import { IIssue } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<div>
<h4 className="text-sm font-medium leading-5 text-custom-text-300">
{projectDetails?.identifier ?? "..."}
</h4>
<input
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 pl-0 py-1.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</div>
);
};
export const BoardInlineCreateIssueForm: React.FC<Props> = (props) => (
<>
<InlineCreateIssueFormWrapper
className="flex flex-col border-[0.5px] border-custom-border-100 justify-between gap-1.5 group/card relative select-none px-3.5 py-3 h-[118px] mb-3 rounded bg-custom-background-100 shadow-custom-shadow-sm"
{...props}
>
<InlineInput />
</InlineCreateIssueFormWrapper>
{props.isOpen && (
<p className="text-xs ml-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
</>
);

View File

@ -6,7 +6,7 @@ import { useRouter } from "next/router";
import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd"; import { Draggable } from "react-beautiful-dnd";
// components // components
import { BoardHeader, SingleBoardIssue } from "components/core"; import { BoardHeader, SingleBoardIssue, BoardInlineCreateIssueForm } from "components/core";
// ui // ui
import { CustomMenu } from "components/ui"; import { CustomMenu } from "components/ui";
// icons // icons
@ -24,37 +24,49 @@ type Props = {
dragDisabled: boolean; dragDisabled: boolean;
groupTitle: string; groupTitle: string;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleTrashBox: (isDragging: boolean) => void; handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
handleMyIssueOpen?: (issue: IIssue) => void;
removeIssue: ((bridgeId: string, issueId: string) => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
viewProps: IIssueViewProps; viewProps: IIssueViewProps;
}; };
export const SingleBoard: React.FC<Props> = ({ export const SingleBoard: React.FC<Props> = (props) => {
addIssueToGroup, const {
currentState, addIssueToGroup,
groupTitle, currentState,
disableUserActions, groupTitle,
disableAddIssueOption = false, disableUserActions,
dragDisabled, disableAddIssueOption = false,
handleIssueAction, dragDisabled,
handleTrashBox, handleIssueAction,
openIssuesListModal, handleDraftIssueAction,
removeIssue, handleTrashBox,
user, openIssuesListModal,
userAuth, handleMyIssueOpen,
viewProps, removeIssue,
}) => { user,
userAuth,
viewProps,
} = props;
// collapse/expand // collapse/expand
const [isCollapsed, setIsCollapsed] = useState(true); const [isCollapsed, setIsCollapsed] = useState(true);
const { groupedIssues, groupByProperty: selectedGroup, orderBy, properties } = viewProps; const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false);
const { displayFilters, groupedIssues } = viewProps;
const router = useRouter(); const router = useRouter();
const { cycleId, moduleId } = router.query; const { cycleId, moduleId } = router.query;
const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues";
const isProfileIssuesPage = router.pathname.split("/")[2] === "profile";
const isDraftIssuesPage = router.pathname.split("/")[4] === "draft-issues";
const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
// Check if it has at least 4 tickets since it is enough to accommodate the Calendar height // Check if it has at least 4 tickets since it is enough to accommodate the Calendar height
@ -63,6 +75,27 @@ export const SingleBoard: React.FC<Props> = ({
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
const scrollToBottom = () => {
const boardListElement = document.getElementById(`board-list-${groupTitle}`);
// timeout is needed because the animation
// takes time to complete & we can scroll only after that
const timeoutId = setTimeout(() => {
if (boardListElement)
boardListElement.scrollBy({
top: boardListElement.scrollHeight,
left: 0,
behavior: "smooth",
});
clearTimeout(timeoutId);
}, 10);
};
const onCreateClick = () => {
setIsInlineCreateIssueFormOpen(true);
scrollToBottom();
};
return ( return (
<div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}> <div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}>
<BoardHeader <BoardHeader
@ -80,14 +113,14 @@ export const SingleBoard: React.FC<Props> = ({
{(provided, snapshot) => ( {(provided, snapshot) => (
<div <div
className={`relative h-full ${ className={`relative h-full ${
orderBy !== "sort_order" && snapshot.isDraggingOver displayFilters?.order_by !== "sort_order" && snapshot.isDraggingOver
? "bg-custom-background-100/20" ? "bg-custom-background-100/20"
: "" : ""
} ${!isCollapsed ? "hidden" : "flex flex-col"}`} } ${!isCollapsed ? "hidden" : "flex flex-col"}`}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.droppableProps} {...provided.droppableProps}
> >
{orderBy !== "sort_order" && ( {displayFilters?.order_by !== "sort_order" && (
<> <>
<div <div
className={`absolute ${ className={`absolute ${
@ -101,12 +134,17 @@ export const SingleBoard: React.FC<Props> = ({
> >
This board is ordered by{" "} This board is ordered by{" "}
{replaceUnderscoreIfSnakeCase( {replaceUnderscoreIfSnakeCase(
orderBy ? (orderBy[0] === "-" ? orderBy.slice(1) : orderBy) : "created_at" displayFilters?.order_by
? displayFilters?.order_by[0] === "-"
? displayFilters?.order_by.slice(1)
: displayFilters?.order_by
: "created_at"
)} )}
</div> </div>
</> </>
)} )}
<div <div
id={`board-list-${groupTitle}`}
className={`pt-3 ${ className={`pt-3 ${
hasMinimumNumberOfCards ? "overflow-hidden overflow-y-scroll" : "" hasMinimumNumberOfCards ? "overflow-hidden overflow-y-scroll" : ""
} `} } `}
@ -126,11 +164,23 @@ export const SingleBoard: React.FC<Props> = ({
type={type} type={type}
index={index} index={index}
issue={issue} issue={issue}
projectId={issue.project_detail.id}
groupTitle={groupTitle} groupTitle={groupTitle}
editIssue={() => handleIssueAction(issue, "edit")} editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")} makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")} handleDeleteIssue={() => handleIssueAction(issue, "delete")}
handleDraftIssueEdit={
handleDraftIssueAction
? () => handleDraftIssueAction(issue, "edit")
: undefined
}
handleDraftIssueDelete={() =>
handleDraftIssueAction
? handleDraftIssueAction(issue, "delete")
: undefined
}
handleTrashBox={handleTrashBox} handleTrashBox={handleTrashBox}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={() => { removeIssue={() => {
if (removeIssue && issue.bridge_id) if (removeIssue && issue.bridge_id)
removeIssue(issue.bridge_id, issue.id); removeIssue(issue.bridge_id, issue.id);
@ -145,20 +195,38 @@ export const SingleBoard: React.FC<Props> = ({
))} ))}
<span <span
style={{ style={{
display: orderBy === "sort_order" ? "inline" : "none", display: displayFilters?.order_by === "sort_order" ? "inline" : "none",
}} }}
> >
{provided.placeholder} <>{provided.placeholder}</>
</span> </span>
<BoardInlineCreateIssueForm
isOpen={isInlineCreateIssueFormOpen}
handleClose={() => setIsInlineCreateIssueFormOpen(false)}
onSuccess={() => scrollToBottom()}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
[displayFilters?.group_by! === "labels"
? "labels_list"
: displayFilters?.group_by!]:
displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle,
}}
/>
</div> </div>
{selectedGroup !== "created_by" && ( {displayFilters?.group_by !== "created_by" && (
<div> <div>
{type === "issue" {type === "issue"
? !disableAddIssueOption && ( ? !disableAddIssueOption && (
<button <button
type="button" type="button"
className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1" className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1"
onClick={addIssueToGroup} onClick={() => {
if (isDraftIssuesPage || isMyIssuesPage || isProfileIssuesPage) {
addIssueToGroup();
} else onCreateClick();
}}
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Add Issue Add Issue
@ -178,7 +246,7 @@ export const SingleBoard: React.FC<Props> = ({
position="left" position="left"
noBorder noBorder
> >
<CustomMenu.MenuItem onClick={addIssueToGroup}> <CustomMenu.MenuItem onClick={() => onCreateClick()}>
Create new Create new
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{openIssuesListModal && ( {openIssuesListModal && (

View File

@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
@ -14,19 +13,14 @@ import {
} from "react-beautiful-dnd"; } from "react-beautiful-dnd";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
import trackEventServices from "services/track-event.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useOutsideClickDetector from "hooks/use-outside-click-detector"; import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components // components
import { import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues";
ViewAssigneeSelect, import { MembersSelect, LabelSelect, PrioritySelect } from "components/project";
ViewDueDateSelect, import { StateSelect } from "components/states";
ViewEstimateSelect,
ViewIssueLabel,
ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect,
} from "components/issues";
// ui // ui
import { ContextMenu, CustomMenu, Tooltip } from "components/ui"; import { ContextMenu, CustomMenu, Tooltip } from "components/ui";
// icons // icons
@ -45,7 +39,15 @@ import { LayerDiagonalIcon } from "components/icons";
import { handleIssuesMutation } from "constants/issue"; import { handleIssuesMutation } from "constants/issue";
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types"; import {
ICurrentUserResponse,
IIssue,
IIssueViewProps,
IState,
ISubIssueResponse,
TIssuePriorities,
UserAuth,
} from "types";
// fetch-keys // fetch-keys
import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys"; import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys";
@ -54,12 +56,16 @@ type Props = {
provided: DraggableProvided; provided: DraggableProvided;
snapshot: DraggableStateSnapshot; snapshot: DraggableStateSnapshot;
issue: IIssue; issue: IIssue;
projectId: string;
groupTitle?: string; groupTitle?: string;
index: number; index: number;
editIssue: () => void; editIssue: () => void;
makeIssueCopy: () => void; makeIssueCopy: () => void;
handleMyIssueOpen?: (issue: IIssue) => void;
removeIssue?: (() => void) | null; removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void; handleDeleteIssue: (issue: IIssue) => void;
handleDraftIssueEdit?: () => void;
handleDraftIssueDelete?: () => void;
handleTrashBox: (isDragging: boolean) => void; handleTrashBox: (isDragging: boolean) => void;
disableUserActions: boolean; disableUserActions: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
@ -72,12 +78,16 @@ export const SingleBoardIssue: React.FC<Props> = ({
provided, provided,
snapshot, snapshot,
issue, issue,
projectId,
index, index,
editIssue, editIssue,
makeIssueCopy, makeIssueCopy,
handleMyIssueOpen,
removeIssue, removeIssue,
groupTitle, groupTitle,
handleDeleteIssue, handleDeleteIssue,
handleDraftIssueEdit,
handleDraftIssueDelete,
handleTrashBox, handleTrashBox,
disableUserActions, disableUserActions,
user, user,
@ -93,10 +103,12 @@ export const SingleBoardIssue: React.FC<Props> = ({
const actionSectionRef = useRef<HTMLDivElement | null>(null); const actionSectionRef = useRef<HTMLDivElement | null>(null);
const { groupByProperty: selectedGroup, orderBy, properties, mutateIssues } = viewProps; const { displayFilters, properties, mutateIssues } = viewProps;
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, cycleId, moduleId } = router.query;
const isDraftIssue = router.pathname.includes("draft-issues");
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -131,9 +143,9 @@ export const SingleBoardIssue: React.FC<Props> = ({
handleIssuesMutation( handleIssuesMutation(
formData, formData,
groupTitle ?? "", groupTitle ?? "",
selectedGroup, displayFilters?.group_by ?? null,
index, index,
orderBy, displayFilters?.order_by ?? "-created_at",
prevData prevData
), ),
false false
@ -149,24 +161,14 @@ export const SingleBoardIssue: React.FC<Props> = ({
if (moduleId) mutate(MODULE_DETAILS(moduleId as string)); if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
}); });
}, },
[ [displayFilters, workspaceSlug, cycleId, moduleId, groupTitle, index, mutateIssues, user]
workspaceSlug,
cycleId,
moduleId,
groupTitle,
index,
selectedGroup,
mutateIssues,
orderBy,
user,
]
); );
const getStyle = ( const getStyle = (
style: DraggingStyle | NotDraggingStyle | undefined, style: DraggingStyle | NotDraggingStyle | undefined,
snapshot: DraggableStateSnapshot snapshot: DraggableStateSnapshot
) => { ) => {
if (orderBy === "sort_order") return style; if (displayFilters?.order_by === "sort_order") return style;
if (!snapshot.isDragging) return {}; if (!snapshot.isDragging) return {};
if (!snapshot.isDropAnimating) return style; if (!snapshot.isDropAnimating) return style;
@ -191,12 +193,103 @@ export const SingleBoardIssue: React.FC<Props> = ({
}); });
}; };
const handleStateChange = (data: string, states: IState[] | undefined) => {
const oldState = states?.find((s) => s.id === issue.state);
const newState = states?.find((s) => s.id === data);
partialUpdateIssue(
{
state: data,
state_detail: newState,
},
issue
);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_STATE",
user
);
if (oldState?.group !== "completed" && newState?.group !== "completed") {
trackEventServices.trackIssueMarkedAsDoneEvent(
{
workspaceSlug: issue.workspace_detail.slug,
workspaceId: issue.workspace_detail.id,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
user
);
}
};
const handleAssigneeChange = (data: any) => {
const newData = issue.assignees ?? [];
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
else newData.push(data);
partialUpdateIssue({ assignees_list: data }, issue);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_ASSIGNEE",
user
);
};
const handleLabelChange = (data: any) => {
partialUpdateIssue({ labels_list: data }, issue);
};
const handlePriorityChange = (data: TIssuePriorities) => {
partialUpdateIssue({ priority: data }, issue);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_PRIORITY",
user
);
};
useEffect(() => { useEffect(() => {
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging); if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
}, [snapshot, handleTrashBox]); }, [snapshot, handleTrashBox]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
const openPeekOverview = () => {
const { query } = router;
if (handleMyIssueOpen) handleMyIssueOpen(issue);
router.push({
pathname: router.pathname,
query: { ...query, peekIssue: issue.id },
});
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
return ( return (
@ -209,29 +302,47 @@ export const SingleBoardIssue: React.FC<Props> = ({
> >
{!isNotAllowed && ( {!isNotAllowed && (
<> <>
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}> <ContextMenu.Item
Icon={PencilIcon}
onClick={() => {
if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
else editIssue();
}}
>
Edit issue Edit issue
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}> {!isDraftIssue && (
Make a copy... <ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
</ContextMenu.Item> Make a copy...
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}> </ContextMenu.Item>
)}
<ContextMenu.Item
Icon={TrashIcon}
onClick={() => {
if (isDraftIssue && handleDraftIssueDelete) handleDraftIssueDelete();
else handleDeleteIssue(issue);
}}
>
Delete issue Delete issue
</ContextMenu.Item> </ContextMenu.Item>
</> </>
)} )}
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}> {!isDraftIssue && (
Copy issue link <ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
</ContextMenu.Item> Copy issue link
<a
href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
</ContextMenu.Item> </ContextMenu.Item>
</a> )}
{!isDraftIssue && (
<a
href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
</ContextMenu.Item>
</a>
)}
</ContextMenu> </ContextMenu>
<div <div
className={`mb-3 rounded bg-custom-background-100 shadow ${ className={`mb-3 rounded bg-custom-background-100 shadow ${
@ -266,13 +377,18 @@ export const SingleBoardIssue: React.FC<Props> = ({
</button> </button>
} }
> >
<CustomMenu.MenuItem onClick={editIssue}> <CustomMenu.MenuItem
onClick={() => {
if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
else editIssue();
}}
>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" /> <PencilIcon className="h-4 w-4" />
<span>Edit issue</span> <span>Edit issue</span>
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{type !== "issue" && removeIssue && ( {type !== "issue" && removeIssue && !isDraftIssue && (
<CustomMenu.MenuItem onClick={removeIssue}> <CustomMenu.MenuItem onClick={removeIssue}>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" /> <XMarkIcon className="h-4 w-4" />
@ -280,53 +396,67 @@ export const SingleBoardIssue: React.FC<Props> = ({
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}> <CustomMenu.MenuItem
onClick={() => {
if (isDraftIssue && handleDraftIssueDelete) handleDraftIssueDelete();
else handleDeleteIssue(issue);
}}
>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
<span>Delete issue</span> <span>Delete issue</span>
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}> {!isDraftIssue && (
<div className="flex items-center justify-start gap-2"> <CustomMenu.MenuItem onClick={handleCopyText}>
<LinkIcon className="h-4 w-4" /> <div className="flex items-center justify-start gap-2">
<span>Copy issue Link</span> <LinkIcon className="h-4 w-4" />
</div> <span>Copy issue Link</span>
</CustomMenu.MenuItem> </div>
</CustomMenu.MenuItem>
)}
</CustomMenu> </CustomMenu>
)} )}
</div> </div>
)} )}
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{properties.key && ( {properties.key && (
<div className="text-xs font-medium text-custom-text-200"> <div className="text-xs font-medium text-custom-text-200">
{issue.project_detail.identifier}-{issue.sequence_id} {issue.project_detail.identifier}-{issue.sequence_id}
</div> </div>
)} )}
<h5 className="text-sm break-words line-clamp-2">{issue.name}</h5> <button
</a> type="button"
</Link> onClick={() => {
if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
else openPeekOverview();
}}
>
<span className="text-sm text-left break-words line-clamp-2">{issue.name}</span>
</button>
</div>
<div <div
className={`flex items-center gap-2 text-xs ${ className={`flex items-center gap-2 text-xs ${
isDropdownActive ? "" : "overflow-x-scroll" isDropdownActive ? "" : "overflow-x-scroll"
}`} }`}
> >
{properties.priority && ( {properties.priority && (
<ViewPrioritySelect <PrioritySelect
issue={issue} value={issue.priority}
partialUpdateIssue={partialUpdateIssue} onChange={handlePriorityChange}
isNotAllowed={isNotAllowed} hideDropdownArrow
user={user} disabled={isNotAllowed}
selfPositioned
/> />
)} )}
{properties.state && ( {properties.state && (
<ViewStateSelect <StateSelect
issue={issue} value={issue.state_detail}
partialUpdateIssue={partialUpdateIssue} onChange={handleStateChange}
isNotAllowed={isNotAllowed} projectId={projectId}
user={user} hideDropdownArrow
selfPositioned disabled={isNotAllowed}
/> />
)} )}
{properties.start_date && issue.start_date && ( {properties.start_date && issue.start_date && (
@ -350,16 +480,24 @@ export const SingleBoardIssue: React.FC<Props> = ({
/> />
)} )}
{properties.labels && issue.labels.length > 0 && ( {properties.labels && issue.labels.length > 0 && (
<ViewIssueLabel labelDetails={issue.label_details} maxRender={2} /> <LabelSelect
value={issue.labels}
projectId={projectId}
onChange={handleLabelChange}
labelsDetails={issue.label_details}
hideDropdownArrow
user={user}
disabled={isNotAllowed}
/>
)} )}
{properties.assignee && ( {properties.assignee && (
<ViewAssigneeSelect <MembersSelect
issue={issue} value={issue.assignees}
partialUpdateIssue={partialUpdateIssue} projectId={projectId}
isNotAllowed={isNotAllowed} onChange={handleAssigneeChange}
customButton membersDetails={issue.assignee_details}
user={user} hideDropdownArrow
selfPositioned disabled={isNotAllowed}
/> />
)} )}
{properties.estimate && issue.estimate_point !== null && ( {properties.estimate && issue.estimate_point !== null && (

View File

@ -5,25 +5,12 @@ import { Popover, Transition } from "@headlessui/react";
// ui // ui
import { CustomMenu, ToggleSwitch } from "components/ui"; import { CustomMenu, ToggleSwitch } from "components/ui";
// icons // icons
import { import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "@heroicons/react/24/outline";
// helpers // helpers
import { import {
addMonths,
addSevenDaysToDate,
formatDate, formatDate,
getCurrentWeekEndDate,
getCurrentWeekStartDate,
isSameMonth, isSameMonth,
isSameYear, isSameYear,
lastDayOfWeek,
startOfWeek,
subtract7DaysToDate,
subtractMonths,
updateDateWithMonth, updateDateWithMonth,
updateDateWithYear, updateDateWithYear,
} from "helpers/calendar.helper"; } from "helpers/calendar.helper";
@ -31,190 +18,136 @@ import {
import { MONTHS_LIST, YEARS_LIST } from "constants/calendar"; import { MONTHS_LIST, YEARS_LIST } from "constants/calendar";
type Props = { type Props = {
isMonthlyView: boolean;
setIsMonthlyView: React.Dispatch<React.SetStateAction<boolean>>;
currentDate: Date; currentDate: Date;
setCurrentDate: React.Dispatch<React.SetStateAction<Date>>; setCurrentDate: React.Dispatch<React.SetStateAction<Date>>;
showWeekEnds: boolean; showWeekEnds: boolean;
setShowWeekEnds: React.Dispatch<React.SetStateAction<boolean>>; setShowWeekEnds: React.Dispatch<React.SetStateAction<boolean>>;
changeDateRange: (startDate: Date, endDate: Date) => void;
}; };
export const CalendarHeader: React.FC<Props> = ({ export const CalendarHeader: React.FC<Props> = ({
setIsMonthlyView,
isMonthlyView,
currentDate, currentDate,
setCurrentDate, setCurrentDate,
showWeekEnds, showWeekEnds,
setShowWeekEnds, setShowWeekEnds,
changeDateRange, }) => (
}) => { <div className="mb-4 flex items-center justify-between">
const updateDate = (date: Date) => { <div className="relative flex h-full w-full items-center justify-start gap-2 text-sm ">
setCurrentDate(date); <Popover className="flex h-full items-center justify-start rounded-lg">
{({ open }) => (
<>
<Popover.Button>
<div className="flex items-center justify-center gap-2 text-2xl font-semibold text-custom-text-100">
<span>{formatDate(currentDate, "Month")}</span>{" "}
<span>{formatDate(currentDate, "yyyy")}</span>
</div>
</Popover.Button>
changeDateRange(startOfWeek(date), lastDayOfWeek(date)); <Transition
}; as={React.Fragment}
enter="transition ease-out duration-200"
return ( enterFrom="opacity-0 translate-y-1"
<div className="mb-4 flex items-center justify-between"> enterTo="opacity-100 translate-y-0"
<div className="relative flex h-full w-full items-center justify-start gap-2 text-sm "> leave="transition ease-in duration-150"
<Popover className="flex h-full items-center justify-start rounded-lg"> leaveFrom="opacity-100 translate-y-0"
{({ open }) => ( leaveTo="opacity-0 translate-y-1"
<> >
<Popover.Button> <Popover.Panel className="absolute top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-custom-background-80 shadow-lg">
<div className="flex items-center justify-center gap-2 text-2xl font-semibold text-custom-text-100"> <div className="flex items-center justify-center gap-5 px-2 py-2 text-sm">
<span>{formatDate(currentDate, "Month")}</span>{" "} {YEARS_LIST.map((year) => (
<span>{formatDate(currentDate, "yyyy")}</span> <button
onClick={() => setCurrentDate(updateDateWithYear(year.label, currentDate))}
className={` ${
isSameYear(year.value, currentDate)
? "text-sm font-medium text-custom-text-100"
: "text-xs text-custom-text-200 "
} hover:text-sm hover:font-medium hover:text-custom-text-100`}
>
{year.label}
</button>
))}
</div> </div>
</Popover.Button> <div className="grid grid-cols-4 border-t border-custom-border-200 px-2">
{MONTHS_LIST.map((month) => (
<button
onClick={() =>
setCurrentDate(updateDateWithMonth(`${month.value}`, currentDate))
}
className={`px-2 py-2 text-xs text-custom-text-200 hover:font-medium hover:text-custom-text-100 ${
isSameMonth(`${month.value}`, currentDate)
? "font-medium text-custom-text-100"
: ""
}`}
>
{month.label}
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<Transition <div className="flex items-center gap-2">
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-custom-background-80 shadow-lg">
<div className="flex items-center justify-center gap-5 px-2 py-2 text-sm">
{YEARS_LIST.map((year) => (
<button
onClick={() => updateDate(updateDateWithYear(year.label, currentDate))}
className={` ${
isSameYear(year.value, currentDate)
? "text-sm font-medium text-custom-text-100"
: "text-xs text-custom-text-200 "
} hover:text-sm hover:font-medium hover:text-custom-text-100`}
>
{year.label}
</button>
))}
</div>
<div className="grid grid-cols-4 border-t border-custom-border-200 px-2">
{MONTHS_LIST.map((month) => (
<button
onClick={() =>
updateDate(updateDateWithMonth(`${month.value}`, currentDate))
}
className={`px-2 py-2 text-xs text-custom-text-200 hover:font-medium hover:text-custom-text-100 ${
isSameMonth(`${month.value}`, currentDate)
? "font-medium text-custom-text-100"
: ""
}`}
>
{month.label}
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<div className="flex items-center gap-2">
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(subtractMonths(currentDate, 1));
} else {
setCurrentDate(subtract7DaysToDate(currentDate));
changeDateRange(
getCurrentWeekStartDate(subtract7DaysToDate(currentDate)),
getCurrentWeekEndDate(subtract7DaysToDate(currentDate))
);
}
}}
>
<ChevronLeftIcon className="h-4 w-4" />
</button>
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(addMonths(currentDate, 1));
} else {
setCurrentDate(addSevenDaysToDate(currentDate));
changeDateRange(
getCurrentWeekStartDate(addSevenDaysToDate(currentDate)),
getCurrentWeekEndDate(addSevenDaysToDate(currentDate))
);
}
}}
>
<ChevronRightIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex w-full items-center justify-end gap-2">
<button <button
className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none" className="cursor-pointer"
onClick={() => { onClick={() => {
if (isMonthlyView) { const previousMonthYear =
updateDate(new Date()); currentDate.getMonth() === 0
} else { ? currentDate.getFullYear() - 1
setCurrentDate(new Date()); : currentDate.getFullYear();
changeDateRange( const previousMonthMonth =
getCurrentWeekStartDate(new Date()), currentDate.getMonth() === 0 ? 11 : currentDate.getMonth() - 1;
getCurrentWeekEndDate(new Date())
); const previousMonthFirstDate = new Date(previousMonthYear, previousMonthMonth, 1);
}
setCurrentDate(previousMonthFirstDate);
}} }}
> >
Today <ChevronLeftIcon className="h-4 w-4" />
</button> </button>
<button
className="cursor-pointer"
onClick={() => {
const nextMonthYear =
currentDate.getMonth() === 11
? currentDate.getFullYear() + 1
: currentDate.getFullYear();
const nextMonthMonth = (currentDate.getMonth() + 1) % 12;
<CustomMenu const nextMonthFirstDate = new Date(nextMonthYear, nextMonthMonth, 1);
customButton={
<div className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none "> setCurrentDate(nextMonthFirstDate);
{isMonthlyView ? "Monthly" : "Weekly"} }}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
> >
<CustomMenu.MenuItem <ChevronRightIcon className="h-4 w-4" />
onClick={() => { </button>
setIsMonthlyView(true);
changeDateRange(startOfWeek(currentDate), lastDayOfWeek(currentDate));
}}
className="w-52 text-sm text-custom-text-200"
>
<div className="flex w-full max-w-[260px] items-center justify-between gap-2">
<span className="flex items-center gap-2">Monthly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${isMonthlyView ? "opacity-100" : "opacity-0"}`}
/>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setIsMonthlyView(false);
changeDateRange(
getCurrentWeekStartDate(currentDate),
getCurrentWeekEndDate(currentDate)
);
}}
className="w-52 text-sm text-custom-text-200"
>
<div className="flex w-full items-center justify-between gap-2">
<span className="flex items-center gap-2">Weekly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${isMonthlyView ? "opacity-0" : "opacity-100"}`}
/>
</div>
</CustomMenu.MenuItem>
<div className="mt-1 flex w-52 items-center justify-between border-t border-custom-border-200 py-2 px-1 text-sm text-custom-text-200">
<h4>Show weekends</h4>
<ToggleSwitch value={showWeekEnds} onChange={() => setShowWeekEnds(!showWeekEnds)} />
</div>
</CustomMenu>
</div> </div>
</div> </div>
);
}; <div className="flex w-full items-center justify-end gap-2">
<button
className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none"
onClick={() => setCurrentDate(new Date())}
>
Today
</button>
<CustomMenu
customButton={
<div className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none">
Options
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
>
<div className="flex w-52 items-center justify-between px-1 text-sm text-custom-text-200">
<h4>Show weekends</h4>
<ToggleSwitch value={showWeekEnds} onChange={() => setShowWeekEnds(!showWeekEnds)} />
</div>
</CustomMenu>
</div>
</div>
);
export default CalendarHeader; export default CalendarHeader;

View File

@ -1,10 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd"; import { DragDropContext, DropResult } from "react-beautiful-dnd";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
@ -12,6 +8,7 @@ import issuesService from "services/issues.service";
import useCalendarIssuesView from "hooks/use-calendar-issues-view"; import useCalendarIssuesView from "hooks/use-calendar-issues-view";
// components // components
import { SingleCalendarDate, CalendarHeader } from "components/core"; import { SingleCalendarDate, CalendarHeader } from "components/core";
import { IssuePeekOverview } from "components/issues";
// ui // ui
import { Spinner } from "components/ui"; import { Spinner } from "components/ui";
// helpers // helpers
@ -49,30 +46,27 @@ export const CalendarView: React.FC<Props> = ({
userAuth, userAuth,
}) => { }) => {
const [showWeekEnds, setShowWeekEnds] = useState(false); const [showWeekEnds, setShowWeekEnds] = useState(false);
const [currentDate, setCurrentDate] = useState(new Date());
const [isMonthlyView, setIsMonthlyView] = useState(true); const { calendarIssues, mutateIssues, params, activeMonthDate, setActiveMonthDate } =
useCalendarIssuesView();
const [calendarDates, setCalendarDates] = useState<ICalendarRange>({ const [calendarDates, setCalendarDates] = useState<ICalendarRange>({
startDate: startOfWeek(currentDate), startDate: startOfWeek(activeMonthDate),
endDate: lastDayOfWeek(currentDate), endDate: lastDayOfWeek(activeMonthDate),
}); });
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { calendarIssues, params, setCalendarDateRange } = useCalendarIssuesView(); const currentViewDays = showWeekEnds
? eachDayOfInterval({
const totalDate = eachDayOfInterval({ start: calendarDates.startDate,
start: calendarDates.startDate, end: calendarDates.endDate,
end: calendarDates.endDate, })
}); : weekDayInterval({
start: calendarDates.startDate,
const onlyWeekDays = weekDayInterval({ end: calendarDates.endDate,
start: calendarDates.startDate, });
end: calendarDates.endDate,
});
const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays;
const currentViewDaysData = currentViewDays.map((date: Date) => { const currentViewDaysData = currentViewDays.map((date: Date) => {
const filterIssue = const filterIssue =
@ -146,96 +140,78 @@ export const CalendarView: React.FC<Props> = ({
.then(() => mutate(fetchKey)); .then(() => mutate(fetchKey));
}; };
const changeDateRange = (startDate: Date, endDate: Date) => {
setCalendarDates({
startDate,
endDate,
});
setCalendarDateRange(
`${renderDateFormat(startDate)};after,${renderDateFormat(endDate)};before`
);
};
useEffect(() => { useEffect(() => {
setCalendarDateRange( setCalendarDates({
`${renderDateFormat(startOfWeek(currentDate))};after,${renderDateFormat( startDate: startOfWeek(activeMonthDate),
lastDayOfWeek(currentDate) endDate: lastDayOfWeek(activeMonthDate),
)};before` });
); }, [activeMonthDate]);
}, [currentDate]);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
return calendarIssues ? ( return (
<div className="h-full overflow-y-auto"> <>
<DragDropContext onDragEnd={onDragEnd}> <IssuePeekOverview
<div className="h-full rounded-lg p-8 text-custom-text-200"> handleMutation={() => mutateIssues()}
<CalendarHeader projectId={projectId?.toString() ?? ""}
isMonthlyView={isMonthlyView} workspaceSlug={workspaceSlug?.toString() ?? ""}
setIsMonthlyView={setIsMonthlyView} readOnly={disableUserActions}
showWeekEnds={showWeekEnds} />
setShowWeekEnds={setShowWeekEnds} {calendarIssues ? (
currentDate={currentDate} <div className="h-full overflow-y-auto">
setCurrentDate={setCurrentDate} <DragDropContext onDragEnd={onDragEnd}>
changeDateRange={changeDateRange} <div
/> id={`calendar-view-${cycleId ?? moduleId ?? viewId ?? ""}`}
className="h-full rounded-lg p-8 text-custom-text-200"
>
<CalendarHeader
showWeekEnds={showWeekEnds}
setShowWeekEnds={setShowWeekEnds}
currentDate={activeMonthDate}
setCurrentDate={setActiveMonthDate}
/>
<div
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
}`}
>
{weeks.map((date, index) => (
<div <div
key={index} className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
className={`flex items-center justify-start gap-2 border-custom-border-200 bg-custom-background-90 p-1.5 text-base font-medium text-custom-text-200 ${ showWeekEnds ? "grid-cols-7" : "grid-cols-5"
!isMonthlyView
? showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
: ""
}`} }`}
> >
<span> {weeks.map((date, index) => (
{isMonthlyView <div
? formatDate(date, "eee").substring(0, 3) key={index}
: formatDate(date, "eee")} className={`flex items-center justify-start gap-2 border-custom-border-200 bg-custom-background-90 p-1.5 text-base font-medium text-custom-text-200`}
</span> >
{!isMonthlyView && <span>{formatDate(date, "d")}</span>} <span>{formatDate(date, "eee").substring(0, 3)}</span>
</div>
))}
</div> </div>
))}
</div>
<div <div
className={`grid h-full ${isMonthlyView ? "auto-rows-min" : ""} ${ className={`grid h-full auto-rows-min ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5" showWeekEnds ? "grid-cols-7" : "grid-cols-5"
} `} } `}
> >
{currentViewDaysData.map((date, index) => ( {currentViewDaysData.map((date, index) => (
<SingleCalendarDate <SingleCalendarDate
key={`${date}-${index}`} key={`${date}-${index}`}
index={index} index={index}
date={date} date={date}
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
addIssueToDate={addIssueToDate} addIssueToDate={addIssueToDate}
isMonthlyView={isMonthlyView} showWeekEnds={showWeekEnds}
showWeekEnds={showWeekEnds} user={user}
user={user} isNotAllowed={isNotAllowed}
isNotAllowed={isNotAllowed} />
/> ))}
))} </div>
</div> </div>
</DragDropContext>
</div> </div>
</DragDropContext> ) : (
</div> <div className="flex h-full w-full items-center justify-center">
) : ( <Spinner />
<div className="flex h-full w-full items-center justify-center"> </div>
<Spinner /> )}
</div> </>
); );
}; };

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