Compare commits

...

45 Commits

Author SHA1 Message Date
NarayanBavisetti
edc5cd32d2 chore: changed old values 2023-09-15 14:16:18 +05:30
Bavisetti Narayan
b274a21ca5 chore: changed issue relation history logs (#2192)
* chore: changed issue relation history logs

* chore: change field name
2023-09-15 13:12:28 +05:30
Dakshesh Jain
32d945be0d fix: edit/delete for draft issue (#2190)
* fix: edit/delete

* fix: build issue

* fix: draft issue modal opening in kanban card
2023-09-15 12:51:10 +05:30
Dakshesh Jain
eda4da8aed feat: draft issues (#2188)
* feat: draft issue

issues can be saved as draft

* style: modal position
2023-09-14 18:38:31 +05:30
sriram veeraghanta
759a604cb8 fix: posthog integration (#2186) 2023-09-14 16:38:41 +05:30
sriram veeraghanta
6659cfc8b0 fix: track events issue and env variables fixes (#2184)
* fix: track event fixes

* fix: adding env variables to trubo
2023-09-14 16:05:31 +05:30
Bavisetti Narayan
a53b428bbd chore: endpoints and history logs for issue draft (#2180)
* chore: history logs for issue draft

* fix: created seperated endpoints for issue drafts

* fix: fixed the typo
2023-09-14 15:38:11 +05:30
Bavisetti Narayan
4e0e02522f fix: changed payload for issue subgroups (#2181)
* fix: sub groups in cycle module and my issues

* fix: changed payload for issue subgroups
2023-09-14 15:29:35 +05:30
sriram veeraghanta
f983d787b4 env and docker fixes (#2182) 2023-09-14 12:26:07 +05:30
Anmol Singh Bhatia
87abf3ccb1 style: project setting ui revamp (#2177)
* style: project settings navigation sidebar added

* chore: emoji and image picker close on outside click added

* style: project setting general page revamp

* style: project setting member page revamp

* style: project setting features page revamp

* style: project setting state page revamp

* style: project setting integrations page revamp

* style: project setting estimates page revamp

* style: project setting automation page revamp

* style: project setting label page revamp

* chore: member select improvement for member setting page

* chore: toggle switch component improvement

* style: project automation setting ui improvement

* style: module icon added

* style: toggle switch improvement

* style: ui and spacing consistency

* style: project label setting revamp

* style: project state setting ui improvement

* chore: integration setting repo select validation

* chore: code refactor

* fix: build fix
2023-09-13 23:09:55 +05:30
Henit Chobisa
d0f6ca3bac [chore] Update setup.sh, with removed replacement script & added project-level ENVs (#2115)
* chore: Updated Setup Script for Splitting Env File

* chore: updated dockerfile for using inproject env varaibles

* chore: removed husky replacement script

* chore: updated shell script using sed

* chore: updated dockerfiles with removed cp statement

* chore: added example env for apiserver

* chore: refactored secret generation for backend

* chore: removed replacement script

* chore: updated docker-compose with removed env variables

* chore: resolved comments in setup.sh and docker-compose

* chore: removed secret key placeholder in apiserver example env

* chore: updated root env for project less env variables

* chore: removed project level env update from root env logic

* chore: updated API_BASE_URL in .env.example

* chore: restored docker argument as env NEXT_PUBLIC_API_BASE_URL

* chore: added pg missing env variables

* [chore] Updated web and deploy backend configuration for reverse proxy & decoupled Plane Deploy URL generation for web (#2135)

* chore: removed api url build arg from compose

* chore: set public api default argument to black string for self hosted

* chore: updated web services to accept blank string as API URL

* chore: added env variables for pg compose service

* chore: modified space app services to use accept empty string as api base

* chore: conditionally trigger web url value based on argument

* fix: made web to use identical host with spaces suffix on absense of Deploy URL for deploy

* chore: added example env for PUBLIC_DEPLOY Env

* chore: updated web dockerfile with addition as PLANE_DEPLOY Argument

* API BASE URL global update

* API BASE URL replace with api server

* api base url fixes

* typo  fixes

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* dev: remove API_BASE_URL from environment variable

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2023-09-13 20:21:02 +05:30
Ankush Deshmukh
af73bbe718 typo: changed customize to customise in project automation settings (#2153)
Co-authored-by: Neo <neo@Neos-MacBook-Pro.local>
2023-09-13 20:15:48 +05:30
Bavisetti Narayan
9033ceb03c fix: sub groups in cycle module and my issues (#2176) 2023-09-13 19:50:34 +05:30
Dakshesh Jain
9bac7cb036 feat: issue link to create relation between issues (#2171)
* feat: issue linking

* fix: search params to filter out selected issue

* style: changed icons

* fix: build error on web-view

* fix: build error

* fix: build error on web-view component
2023-09-13 19:41:11 +05:30
Anmol Singh Bhatia
32d08570e7 chore: peek overview for issue view and my issue view (#2172)
* chore: peak overview for issue view and my issue view

* fix: profile issue peak overview mutation fix

* chore: code refactor

* fix: image prefix url fix
2023-09-13 19:33:58 +05:30
Bavisetti Narayan
1b1ed37405 chore: changed default props for worskpace and project members (#2175) 2023-09-13 19:13:31 +05:30
Bavisetti Narayan
42d38f7531 feat: changed payload for swimlanes (#2173) 2023-09-13 18:25:57 +05:30
Bavisetti Narayan
61672f47ac fix: migration files (#2169) 2023-09-13 13:23:40 +05:30
Dakshesh Jain
23e62c83eb refactor: switched priority null -> 'none' (#2166) 2023-09-13 13:22:31 +05:30
sriram veeraghanta
e58b76c8a6 fix: tailwind common config (#2168) 2023-09-13 12:50:04 +05:30
Anmol Singh Bhatia
4ce01ca4f8 fix: calendar issues display filters loop fix (#2167) 2023-09-13 12:37:58 +05:30
Bavisetti Narayan
a34b0b059d feat: add a relation to an issue (#1995)
* feat: add issue relation to an issue

* fix: deleted the migration file

* fix: changed link to relates to in choice fields

* fix: added the migration file

* fix: changed migration file

* fix: project id issue fixed

* fix: added issue in the payload

* fix: changed the query param for blocker
2023-09-13 12:25:10 +05:30
Bavisetti Narayan
164e0b9301 chore: changed view props (#2146)
* chore: changed view props

* fix: changed the keywords
2023-09-13 12:12:21 +05:30
Bavisetti Narayan
5a91031243 feat: issue drafts (#2161) 2023-09-13 12:10:22 +05:30
Bavisetti Narayan
47bec7704b chore: priority migration (#2162) 2023-09-13 12:06:38 +05:30
sriram veeraghanta
b9c935092a chore: eslint config package fixes (#2165)
* eslint fixes

* lint rules added
2023-09-13 12:06:17 +05:30
Aaryan Khandelwal
3a2a329000 fix: view props undefined (#2160) 2023-09-12 22:51:13 +05:30
Aaryan Khandelwal
8e9a4dca78 refactor: view props structure (#2159)
* chore: update view_props types

* refactor: view props structure
2023-09-12 22:27:15 +05:30
sriram veeraghanta
cdb888c23e fix: selfhosted fixes (#2154)
* fix: selfhosted fixes

* fix: updated env example
2023-09-12 20:32:26 +05:30
Dakshesh Jain
2186db8bba feat: users can select timezone during onboarding (#2148)
feat: using Intl timezone will be automatically selected but they have the option to change it
2023-09-12 13:35:15 +05:30
Bavisetti Narayan
9bff10de6d chore: changed issue priority from NULL to none (#2142)
* chore: changed issue priority from NULL to none

* fix: deleted the migration file
2023-09-12 13:06:49 +05:30
Henit Chobisa
6867154963 chore: added pre-release tag for workflow publications (#2133)
* chore: added pre-release tag for workflow publications

* chore: added backend services under a single image

* chore: exposed backend port for compose hub

* chore: removed backend exposed ports
2023-09-11 18:02:56 +05:30
Aaryan Khandelwal
7bb73b74ba refactor: priority icon component (#2132) 2023-09-11 14:35:58 +05:30
Dakshesh Jain
991258084e fix: query checking (#2137)
fix: the logic should be to check if object exist not if it's true or false
2023-09-11 13:21:50 +05:30
Thomas
1a37668f0b fix: husky was removed in commit #2086, but prepare still uses it (#2128) 2023-09-11 13:05:09 +05:30
M. Palanikannan
4447a4b519 fix: Tiptap comment card fix for space (#2129)
* fix:(space) fixed comment card's editor integration

* regression: removed content being set twice

* chore: added controller to manage tiptap editor

* chore: remove unused functions

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-09-11 12:54:19 +05:30
Dakshesh Jain
7842c4b2ea fix: authorize editor (#2122) 2023-09-11 12:24:46 +05:30
Aaryan Khandelwal
8de93d0081 chore: remove getServerSideProps (#2130) 2023-09-11 12:13:00 +05:30
Aaryan Khandelwal
5b228bd1eb chore: update state icons and colors (#2126)
* chore: update state icons and colors

* chore: update icons
2023-09-11 11:45:28 +05:30
Dakshesh Jain
ad8a011bb9 fix: issue activity (#2127) 2023-09-11 11:44:16 +05:30
Aaryan Khandelwal
49d0b3f4a1 fix: handleClose function of the export modal (#2124) 2023-09-08 13:29:06 +05:30
Aaryan Khandelwal
1872dff00d fix: custom date filter not working on my issues and profile issues (#2123) 2023-09-08 13:28:32 +05:30
Dakshesh Jain
faa6a2bcbc feat: select blocker, blocking, and parent (#2121)
* feat: update, delete link

refactor: using old fetch-key

* feat: issue activity with ability to view & add comment

feat: click on view more to view more options in the issue detail

* fix: upload image not working on mobile

* feat: select blocker, blocking, and parent

dev: auth layout for web-view, console.log callback for web-view

* style: made design consistant

* fix: displaying page only on web-view

* style: removed overflow hidden
2023-09-07 18:42:24 +05:30
sriram veeraghanta
6d52707ff5 editor fixes for space (#2119) 2023-09-07 15:09:16 +05:30
Nikhil
8ba482bc9c chore: response status for project views update (#2111)
* chore: response status for project views update

* dev: remove 200 OK response from empty contents
2023-09-07 14:49:45 +05:30
283 changed files with 9580 additions and 5461 deletions

View File

@@ -1,36 +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=""
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
# Error logs
SENTRY_DSN=""
# Database Settings
PGUSER="plane"
PGPASSWORD="plane"
@@ -43,15 +10,6 @@ REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# Email Settings
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS="1"
EMAIL_USE_SSL="0"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
@@ -67,9 +25,6 @@ OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint
OPENAI_API_KEY="sk-" # add your openai key here
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access
# Github
GITHUB_CLIENT_SECRET="" # For fetching release notes
# Settings related to Docker
DOCKERIZED=1
# set to 1 If using the pre-configured minio setup
@@ -78,10 +33,3 @@ USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# Default Creds
DEFAULT_EMAIL="captain@plane.so"
DEFAULT_PASSWORD="password123"
# SignUps
ENABLE_SIGNUP="1"
# Auto generated and Required that will be generated from setup.sh

View File

@@ -2,7 +2,7 @@ name: Update Docker Images for Plane on Release
on:
release:
types: [released]
types: [released, prereleased]
jobs:
build_push_backend:

60
apiserver/.env.example Normal file
View File

@@ -0,0 +1,60 @@
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
# Error logs
SENTRY_DSN=""
# Database Settings
PGUSER="plane"
PGPASSWORD="plane"
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

@@ -31,8 +31,6 @@ from .issue import (
IssueActivitySerializer,
IssueCommentSerializer,
IssuePropertySerializer,
BlockerIssueSerializer,
BlockedIssueSerializer,
IssueAssigneeSerializer,
LabelSerializer,
IssueSerializer,
@@ -45,6 +43,8 @@ from .issue import (
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer,
)

View File

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

View File

@@ -90,7 +90,9 @@ from plane.api.views import (
IssueSubscriberViewSet,
IssueCommentPublicViewSet,
IssueReactionViewSet,
IssueRelationViewSet,
CommentReactionViewSet,
IssueDraftViewSet,
## End Issues
# States
StateViewSet,
@@ -1010,6 +1012,49 @@ urlpatterns = [
name="project-issue-archive",
),
## 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
path(
"workspaces/<str:slug>/file-assets/",

View File

@@ -86,8 +86,10 @@ from .issue import (
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
IssueRelationViewSet,
IssueRetrievePublicEndpoint,
ProjectIssuesPublicEndpoint,
IssueDraftViewSet,
)
from .auth_extended import (
@@ -167,6 +169,4 @@ from .analytic import (
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
from .exporter import (
ExportIssuesEndpoint,
)
from .exporter import ExportIssuesEndpoint

View File

@@ -517,6 +517,7 @@ class CycleIssueViewSet(BaseViewSet):
try:
order_by = request.GET.get("order_by", "created_at")
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
@@ -555,9 +556,15 @@ class CycleIssueViewSet(BaseViewSet):
issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues_data, group_by),
group_results(issues_data, group_by, sub_group_by),
status=status.HTTP_200_OK,
)

View File

@@ -24,6 +24,7 @@ from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError
from django.conf import settings
from django.db import IntegrityError
# Third Party imports
from rest_framework.response import Response
@@ -51,6 +52,8 @@ from plane.api.serializers import (
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer,
)
from plane.api.permissions import (
@@ -76,6 +79,7 @@ from plane.db.models import (
CommentReaction,
ProjectDeployBoard,
IssueVote,
IssueRelation,
ProjectPublicMember,
)
from plane.bgtasks.issue_activites_task import issue_activity
@@ -178,7 +182,7 @@ class IssueViewSet(BaseViewSet):
filters = issue_filters(request.query_params, "GET")
# 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"]
order_by_param = request.GET.get("order_by", "-created_at")
@@ -266,9 +270,16 @@ class IssueViewSet(BaseViewSet):
## Grouping the results
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues, group_by), status=status.HTTP_200_OK
group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
@@ -331,7 +342,7 @@ class UserWorkSpaceIssues(BaseAPIView):
try:
filters = issue_filters(request.query_params, "GET")
# 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"]
order_by_param = request.GET.get("order_by", "-created_at")
@@ -443,9 +454,16 @@ class UserWorkSpaceIssues(BaseAPIView):
## Grouping the results
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues, group_by), status=status.HTTP_200_OK
group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
@@ -491,7 +509,7 @@ class IssueActivityEndpoint(BaseAPIView):
issue_activities = (
IssueActivity.objects.filter(issue_id=issue_id)
.filter(
~Q(field__in=["comment", "vote", "reaction"]),
~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user,
)
.select_related("actor", "workspace", "issue", "project")
@@ -1068,7 +1086,7 @@ class IssueArchiveViewSet(BaseViewSet):
show_sub_issues = request.GET.get("show_sub_issues", "true")
# 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"]
order_by_param = request.GET.get("order_by", "-created_at")
@@ -2040,6 +2058,105 @@ 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,
),
)
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,
)
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):
permission_classes = [
AllowAny,
@@ -2078,7 +2195,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
filters = issue_filters(request.query_params, "GET")
# 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"]
order_by_param = request.GET.get("order_by", "-created_at")
@@ -2240,3 +2357,233 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueDraftViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = IssueFlatSerializer
model = Issue
def perform_update(self, serializer):
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
current_instance = (
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None:
issue_activity.delay(
type="issue_draft.activity.updated",
requested_data=requested_data,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
)
return super().perform_update(serializer)
def perform_destroy(self, instance):
current_instance = (
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None:
issue_activity.delay(
type="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
),
)
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,
)
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 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

@@ -308,6 +308,7 @@ class ModuleIssueViewSet(BaseViewSet):
try:
order_by = request.GET.get("order_by", "created_at")
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_module__module_id=module_id)
@@ -346,9 +347,15 @@ class ModuleIssueViewSet(BaseViewSet):
issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues_data, group_by),
group_results(issues_data, group_by, sub_group_by),
status=status.HTTP_200_OK,
)

View File

@@ -482,7 +482,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
# Delete joined project invites
project_invitations.delete()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
@@ -924,8 +924,7 @@ class ProjectUserViewsEndpoint(BaseAPIView):
project_member.save()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except Project.DoesNotExist:
return Response(
{"error": "The requested resource does not exists"},

View File

@@ -220,7 +220,7 @@ class IssueSearchEndpoint(BaseAPIView):
query = request.query_params.get("search", False)
workspace_search = request.query_params.get("workspace_search", "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")
module = request.query_params.get("module", "false")
sub_issue = request.query_params.get("sub_issue", "false")
@@ -247,12 +247,12 @@ class IssueSearchEndpoint(BaseAPIView):
"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)
issues = issues.filter(
~Q(pk=issue_id),
~Q(blocked_issues__block=issue),
~Q(blocker_issues__blocked_by=issue),
~Q(issue_related__issue=issue),
~Q(issue_relation__related_issue=issue),
)
if sub_issue == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)

View File

@@ -532,7 +532,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
# Delete joined workspace invites
workspace_invitations.delete()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
@@ -846,7 +846,7 @@ class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
workspace_member.view_props = request.data.get("view_props", {})
workspace_member.save()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except WorkspaceMember.DoesNotExist:
return Response(
{"error": "User not a member of workspace"},
@@ -1072,7 +1072,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
.order_by("state_group")
)
priority_order = ["urgent", "high", "medium", "low", None]
priority_order = ["urgent", "high", "medium", "low", "none"]
priority_distribution = (
Issue.issue_objects.filter(

View File

@@ -393,143 +393,19 @@ def track_assignees(
)
# 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(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"created the issue",
verb="created",
actor=actor,
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"created the issue",
verb="created",
actor=actor,
)
)
)
def track_estimate_points(
@@ -637,18 +513,11 @@ def update_issue_activity(
"start_date": track_start_date,
"labels_list": track_labels,
"assignees_list": track_assignees,
"blocks_list": track_blocks,
"blockers_list": track_blockings,
"estimate_point": track_estimate_points,
"archived_at": track_archive_at,
"closed_to": track_closed_to,
}
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
)
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
if func is not None:
@@ -1170,6 +1039,158 @@ def delete_issue_vote_activity(
)
def create_issue_relation_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
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"),
)
)
def delete_issue_relation_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
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"),
)
)
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"),
)
)
def create_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"drafted the issue",
field="draft",
verb="created",
actor=actor,
)
)
def update_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
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,
)
)
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,
)
)
def delete_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
issue_activities.append(
IssueActivity(
project=project,
workspace=project.workspace,
comment=f"deleted the draft issue",
field="draft",
verb="deleted",
actor=actor,
)
)
# Receive message from room group
@shared_task
def issue_activity(
@@ -1233,12 +1254,17 @@ def issue_activity(
"link.activity.deleted": delete_link_activity,
"attachment.activity.created": create_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.deleted": delete_issue_reaction_activity,
"comment_reaction.activity.created": create_comment_reaction_activity,
"comment_reaction.activity.deleted": delete_comment_reaction_activity,
"issue_vote.activity.created": create_issue_vote_activity,
"issue_vote.activity.deleted": delete_issue_vote_activity,
"issue_draft.activity.created": create_draft_issue_activity,
"issue_draft.activity.updated": update_draft_issue_activity,
"issue_draft.activity.deleted": delete_draft_issue_activity,
}
func = ACTIVITY_MAPPER.get(type)

View File

@@ -0,0 +1,84 @@
# 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.all():
if obj.priority is 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",None),
"attachment_count": old_props.get("properties", {}).get("attachment_count", None),
"created_on": old_props.get("properties", {}).get("created_on", None),
"due_date": old_props.get("properties", {}).get("due_date", None),
"estimate": old_props.get("properties", {}).get("estimate", None),
"key": old_props.get("properties", {}).get("key", None),
"labels": old_props.get("properties", {}).get("labels", None),
"link": old_props.get("properties", {}).get("link", None),
"priority": old_props.get("properties", {}).get("priority", None),
"start_date": old_props.get("properties", {}).get("start_date", None),
"state": old_props.get("properties", {}).get("state", None),
"sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", None),
"updated_on": old_props.get("properties", {}).get("updated_on", None),
},
}
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,23 @@
# Generated by Django 4.2.3 on 2023-09-15 06:55
from django.db import migrations
def update_issue_activity(apps, schema_editor):
IssueActivityModel = apps.get_model("db", "IssueActivity")
updated_issue_activity = []
for obj in IssueActivityModel.objects.all():
if obj.field == "blocks":
obj.field = "blocked_by"
updated_issue_activity.append(obj)
IssueActivityModel.objects.bulk_update(updated_issue_activity, ["field"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('db', '0044_auto_20230913_0709'),
]
operations = [
migrations.RunPython(update_issue_activity),
]

View File

@@ -32,6 +32,7 @@ from .issue import (
IssueAssignee,
Label,
IssueBlocker,
IssueRelation,
IssueLink,
IssueSequence,
IssueAttachment,

View File

@@ -29,6 +29,7 @@ class IssueManager(models.Manager):
| models.Q(issue_inbox__isnull=True)
)
.exclude(archived_at__isnull=False)
.exclude(is_draft=True)
)
@@ -38,6 +39,7 @@ class Issue(ProjectBaseModel):
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("none", "None")
)
parent = models.ForeignKey(
"self",
@@ -64,8 +66,7 @@ class Issue(ProjectBaseModel):
max_length=30,
choices=PRIORITY_CHOICES,
verbose_name="Issue Priority",
null=True,
blank=True,
default="none",
)
start_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)
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
is_draft = models.BooleanField(default=False)
objects = models.Manager()
issue_objects = IssueManager()
@@ -178,6 +180,37 @@ class IssueBlocker(ProjectBaseModel):
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):
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_assignee"

View File

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

View File

@@ -16,26 +16,41 @@ ROLE_CHOICES = (
def get_default_props():
return {
"filters": {"type": None},
"groupByProperty": None,
"issueView": "list",
"orderBy": "-created_at",
"properties": {
"filters": {
"priority": None,
"state": None,
"state_group": None,
"assignees": None,
"created_by": None,
"labels": None,
"start_date": None,
"target_date": None,
"subscriber": None,
},
"display_filters": {
"group_by": None,
"order_by": '-created_at',
"type": None,
"sub_issue": True,
"show_empty_groups": True,
"layout": "list",
"calendar_date_range": "",
},
"display_properties": {
"assignee": True,
"attachment_count": True,
"created_on": True,
"due_date": True,
"estimate": True,
"key": True,
"labels": True,
"link": True,
"priority": True,
"start_date": True,
"state": True,
"sub_issue_count": True,
"attachment_count": True,
"link": True,
"estimate": True,
"created_on": True,
"updated_on": True,
"start_date": True,
},
"showEmptyGroups": True,
}
}

View File

@@ -15,7 +15,7 @@ def resolve_keys(group_keys, 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
Args:
@@ -25,38 +25,140 @@ def group_results(results_data, group_by):
Returns:
obj: grouped results
"""
response_dict = dict()
if sub_group_by:
main_responsive_dict = dict()
if group_by == "priority":
response_dict = {
"urgent": [],
"high": [],
"medium": [],
"low": [],
"None": [],
}
if sub_group_by == "priority":
main_responsive_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)
for value in results_data:
main_group_attribute = resolve_keys(sub_group_by, value)
group_attribute = resolve_keys(group_by, value)
if isinstance(main_group_attribute, list) and not isinstance(group_attribute, list):
if len(main_group_attribute):
for attrib in main_group_attribute:
if str(attrib) not in main_responsive_dict:
main_responsive_dict[str(attrib)] = {}
if str(group_attribute) in main_responsive_dict[str(attrib)]:
main_responsive_dict[str(attrib)][str(group_attribute)].append(value)
else:
main_responsive_dict[str(attrib)][str(group_attribute)] = []
main_responsive_dict[str(attrib)][str(group_attribute)].append(value)
else:
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)
if str(None) not in main_responsive_dict:
main_responsive_dict[str(None)] = {}
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.dateparse import parse_datetime
def filter_state(params, filter, method):
if method == "GET":
states = params.get("state").split(",")
@@ -23,7 +24,6 @@ def filter_state_group(params, filter, method):
return filter
def filter_estimate_point(params, filter, method):
if method == "GET":
estimate_points = params.get("estimate_point").split(",")
@@ -39,25 +39,10 @@ def filter_priority(params, filter, method):
if method == "GET":
priorities = params.get("priority").split(",")
if len(priorities) and "" not in priorities:
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"]
filter["priority__in"] = priorities
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"]
filter["priority__in"] = params.get("priority")
return filter
@@ -229,7 +214,6 @@ def filter_issue_state_type(params, filter, method):
return filter
def filter_project(params, filter, method):
if method == "GET":
projects = params.get("project").split(",")
@@ -329,7 +313,7 @@ def issue_filters(query_params, method):
"module": filter_module,
"inbox_status": filter_inbox_status,
"sub_issue": filter_sub_issue_toggle,
"subscriber": filter_subscribed_issues,
"subscriber": filter_subscribed_issues,
"start_target_date": filter_start_target_date_issues,
}

View File

@@ -85,7 +85,7 @@ services:
plane-worker:
container_name: planebgworker
image: makeplane/plane-worker:latest
image: makeplane/plane-backend:latest
restart: always
command: ./bin/worker
env_file:
@@ -99,7 +99,7 @@ services:
plane-beat-worker:
container_name: planebeatworker
image: makeplane/plane-worker:latest
image: makeplane/plane-backend:latest
restart: always
command: ./bin/beat
env_file:

View File

@@ -1,37 +1,5 @@
version: "3.8"
x-api-and-worker-env: &api-and-worker-env
DEBUG: ${DEBUG}
SENTRY_DSN: ${SENTRY_DSN}
DJANGO_SETTINGS_MODULE: plane.settings.production
DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE}
REDIS_URL: redis://plane-redis:6379/
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
EMAIL_PORT: ${EMAIL_PORT}
EMAIL_FROM: ${EMAIL_FROM}
EMAIL_USE_TLS: ${EMAIL_USE_TLS}
EMAIL_USE_SSL: ${EMAIL_USE_SSL}
AWS_REGION: ${AWS_REGION}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT}
WEB_URL: ${WEB_URL}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
DISABLE_COLLECTSTATIC: 1
DOCKERIZED: 1
OPENAI_API_BASE: ${OPENAI_API_BASE}
OPENAI_API_KEY: ${OPENAI_API_KEY}
GPT_ENGINE: ${GPT_ENGINE}
SECRET_KEY: ${SECRET_KEY}
DEFAULT_EMAIL: ${DEFAULT_EMAIL}
DEFAULT_PASSWORD: ${DEFAULT_PASSWORD}
USE_MINIO: ${USE_MINIO}
ENABLE_SIGNUP: ${ENABLE_SIGNUP}
services:
plane-web:
container_name: planefrontend
@@ -40,23 +8,8 @@ services:
dockerfile: ./web/Dockerfile.web
args:
DOCKER_BUILDKIT: 1
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
NEXT_PUBLIC_DEPLOY_URL: http://localhost/spaces
restart: always
command: /usr/local/bin/start.sh web/server.js web
env_file:
- .env
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
NEXT_PUBLIC_GITHUB_ID: "0"
NEXT_PUBLIC_SENTRY_DSN: "0"
NEXT_PUBLIC_ENABLE_OAUTH: "0"
NEXT_PUBLIC_ENABLE_SENTRY: "0"
NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0"
NEXT_PUBLIC_TRACK_EVENTS: "0"
depends_on:
- plane-api
- plane-worker
@@ -68,14 +21,8 @@ services:
dockerfile: ./space/Dockerfile.space
args:
DOCKER_BUILDKIT: 1
NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
restart: always
command: /usr/local/bin/start.sh space/server.js space
env_file:
- .env
environment:
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
depends_on:
- plane-api
- plane-worker
@@ -90,12 +37,8 @@ services:
DOCKER_BUILDKIT: 1
restart: always
command: ./bin/takeoff
ports:
- 8000:8000
env_file:
- .env
environment:
<<: *api-and-worker-env
- ./apiserver/.env
depends_on:
- plane-db
- plane-redis
@@ -110,9 +53,7 @@ services:
restart: always
command: ./bin/worker
env_file:
- .env
environment:
<<: *api-and-worker-env
- ./apiserver/.env
depends_on:
- plane-api
- plane-db
@@ -128,9 +69,7 @@ services:
restart: always
command: ./bin/beat
env_file:
- .env
environment:
<<: *api-and-worker-env
- ./apiserver/.env
depends_on:
- plane-api
- plane-db
@@ -165,8 +104,6 @@ services:
command: server /export --console-address ":9090"
volumes:
- uploads:/export
env_file:
- .env
environment:
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID}
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
@@ -189,8 +126,6 @@ services:
restart: always
ports:
- ${NGINX_PORT}:80
env_file:
- .env
environment:
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}

View File

@@ -1,30 +1,29 @@
events { }
http {
sendfile on;
sendfile on;
server {
listen 80;
root /www/data/;
access_log /var/log/nginx/access.log;
server {
listen 80;
root /www/data/;
access_log /var/log/nginx/access.log;
client_max_body_size ${FILE_SIZE_LIMIT};
client_max_body_size ${FILE_SIZE_LIMIT};
location / {
proxy_pass http://planefrontend:3000/;
location / {
proxy_pass http://planefrontend:3000/;
}
location /api/ {
proxy_pass http://planebackend:8000/api/;
}
location /spaces/ {
proxy_pass http://planedeploy:3000/spaces/;
}
location /${BUCKET_NAME}/ {
proxy_pass http://plane-minio:9000/uploads/;
}
}
location /api/ {
proxy_pass http://planebackend:8000/api/;
}
location /spaces/ {
proxy_pass http://planedeploy:3000/spaces/;
}
location /${BUCKET_NAME}/ {
proxy_pass http://plane-minio:9000/uploads/;
}
}
}

View File

@@ -8,7 +8,6 @@
"packages/*"
],
"scripts": {
"prepare": "husky install",
"build": "turbo run build",
"dev": "turbo run dev",
"start": "turbo run start",
@@ -17,8 +16,12 @@
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"devDependencies": {
"autoprefixer": "^10.4.15",
"eslint-config-custom": "*",
"postcss": "^8.4.29",
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "latest"
},
"packageManager": "yarn@1.22.19"

View File

@@ -16,5 +16,7 @@ module.exports = {
"no-duplicate-imports": "error",
"arrow-body-style": ["error", "as-needed"],
"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",
"react": "^18.2.0",
"tsconfig": "*",
"tailwind-config-custom": "*",
"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": ["."],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["DOM"]
}
"exclude": ["dist", "build", "node_modules"]
}

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}" apps/$DIRECTORY/.next | xargs -I{} sed -i "s|$FROM|$TO|g" "{}"

View File

@@ -5,15 +5,12 @@ cp ./.env.example ./.env
export LC_ALL=C
export LC_CTYPE=C
# Generate the NEXT_PUBLIC_API_BASE_URL with given IP
echo -e "\nNEXT_PUBLIC_API_BASE_URL=$1" >> ./.env
cp ./web/.env.example ./web/.env
cp ./space/.env.example ./space/.env
cp ./apiserver/.env.example ./apiserver/.env
# Generate the SECRET_KEY that will be used by django
echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./.env
# WEB_URL for email redirection and image saving
echo -e "WEB_URL=$1" >> ./.env
echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.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"
@@ -21,9 +18,7 @@ echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token
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
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
//registry.tiptap.dev/:_authToken=${authToken}" > .npmrc

View File

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

View File

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

View File

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

2
space/additional.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
// additional.d.ts
/// <reference types="next-images" />

View File

@@ -1,9 +1,6 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
// react hook form
import { useForm } from "react-hook-form";
// components
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 { useRouter } from "next/router";
@@ -13,7 +13,7 @@ import useToast from "hooks/use-toast";
// components
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
// images
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
export const SignInView = observer(() => {
const { user: userStore } = useMobxStore();
@@ -112,7 +112,7 @@ export const SignInView = observer(() => {
<div className="fixed grid place-items-center bg-custom-background-100 sm:py-5 top-11 sm:top-12 left-7 sm:left-16 lg:left-28">
<div className="grid place-items-center bg-custom-background-100">
<div className="h-[30px] w-[30px]">
<Image src={BluePlaneLogoWithoutText} alt="Plane Logo" />
<img src={`${imagePrefix}/plane-logos/blue-without-text.png`} alt="Plane Logo" />
</div>
</div>
</div>

View File

@@ -3,7 +3,7 @@ import React, { useState } from "react";
// mobx
import { observer } from "mobx-react-lite";
// react-hook-form
import { useForm, Controller } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
// headless ui
import { Menu, Transition } from "@headlessui/react";
// lib
@@ -30,10 +30,13 @@ export const CommentCard: React.FC<Props> = observer((props) => {
// states
const [isEditing, setIsEditing] = useState(false);
const editorRef = React.useRef<any>(null);
const showEditorRef = React.useRef<any>(null);
const {
control,
formState: { isSubmitting },
handleSubmit,
control,
} = useForm<any>({
defaultValues: { comment_html: comment.comment_html },
});
@@ -47,6 +50,9 @@ export const CommentCard: React.FC<Props> = observer((props) => {
if (!workspaceSlug || !issueDetailStore.peekId) return;
issueDetailStore.updateIssueComment(workspaceSlug, comment.project, issueDetailStore.peekId, comment.id, formData);
setIsEditing(false);
editorRef.current?.setEditorValue(formData.comment_html);
showEditorRef.current?.setEditorValue(formData.comment_html);
};
return (
@@ -96,6 +102,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
render={({ field: { onChange, value } }) => (
<TipTapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
@@ -125,7 +132,8 @@ export const CommentCard: React.FC<Props> = observer((props) => {
</form>
<div className={`${isEditing ? "hidden" : ""}`}>
<TipTapEditor
workspaceSlug={workspaceSlug.toString()}
workspaceSlug={workspaceSlug as string}
ref={showEditorRef}
value={comment.comment_html}
editable={false}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"

View File

@@ -44,7 +44,6 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mod
{mode === "full" && (
<div className="flex justify-between gap-2 pb-3">
<h6 className="flex items-center gap-2 font-medium">
{/* {getStateGroupIcon(issue.state_detail.group, "16", "16", issue.state_detail.color)} */}
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
</h6>
<div className="flex items-center gap-2">

View File

@@ -77,14 +77,16 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
{...bubbleMenuProps}
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
>
<NodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsLinkSelectorOpen(false);
}}
/>
{!props.editor.isActive("table") && (
<NodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsLinkSelectorOpen(false);
}}
/>
)}
<LinkSelector
editor={props.editor!!}
isOpen={isLinkSelectorOpen}

View File

@@ -28,7 +28,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
name: "Text",
icon: TextIcon,
command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () => editor.isActive("paragraph") && !editor.isActive("bulletList") && !editor.isActive("orderedList"),
isActive: () =>
editor.isActive("paragraph") &&
!editor.isActive("bulletList") &&
!editor.isActive("orderedList"),
},
{
name: "H1",
@@ -69,7 +72,8 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
{
name: "Quote",
icon: TextQuote,
command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
isActive: () => editor.isActive("blockquote"),
},
{

View File

@@ -13,6 +13,7 @@ import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { lowlight } from "lowlight/lib/core";
import SlashCommand from "../slash-command";
import { InputRule } from "@tiptap/core";
import Gapcursor from "@tiptap/extension-gapcursor";
import ts from "highlight.js/lib/languages/typescript";
@@ -20,6 +21,10 @@ import "highlight.js/styles/github-dark.css";
import UniqueID from "@tiptap-pro/extension-unique-id";
import UpdatedImage from "./updated-image";
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
import { CustomTableCell } from "./table/table-cell";
import { Table } from "./table/table";
import { TableHeader } from "./table/table-header";
import { TableRow } from "@tiptap/extension-table-row";
lowlight.registerLanguage("ts", ts);
@@ -27,113 +32,122 @@ export const TiptapExtensions = (
workspaceSlug: string,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
},
code: {
HTMLAttributes: {
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
code: {
HTMLAttributes: {
class:
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
width: 2,
},
gapcursor: false,
}),
CodeBlockLowlight.configure({
lowlight,
}),
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false,
}),
CodeBlockLowlight.configure({
lowlight,
}),
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
UpdatedImage.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
UpdatedImage.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return "";
}
return "Press '/' for commands...";
},
includeChildren: true,
}),
UniqueID.configure({
types: ["image"],
}),
SlashCommand(workspaceSlug, setIsSubmitting),
TiptapUnderline,
TextStyle,
Color,
Highlight.configure({
multicolor: true,
}),
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
];
return "Press '/' for commands...";
},
includeChildren: true,
}),
UniqueID.configure({
types: ["image"],
}),
SlashCommand(workspaceSlug, setIsSubmitting),
TiptapUnderline,
TextStyle,
Color,
Highlight.configure({
multicolor: true,
}),
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
CustomTableCell,
TableRow,
];

View File

@@ -0,0 +1,32 @@
import { TableCell } from "@tiptap/extension-table-cell";
export const CustomTableCell = TableCell.extend({
addAttributes() {
return {
...this.parent?.(),
isHeader: {
default: false,
parseHTML: (element) => {
isHeader: element.tagName === "TD";
},
renderHTML: (attributes) => {
tag: attributes.isHeader ? "th" : "td";
},
},
};
},
renderHTML({ HTMLAttributes }) {
if (HTMLAttributes.isHeader) {
return [
"th",
{
...HTMLAttributes,
class: `relative ${HTMLAttributes.class}`,
},
["span", { class: "absolute top-0 right-0" }],
0,
];
}
return ["td", HTMLAttributes, 0];
},
});

View File

@@ -0,0 +1,7 @@
import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";
const TableHeader = BaseTableHeader.extend({
content: "paragraph",
});
export { TableHeader };

View File

@@ -0,0 +1,9 @@
import { Table as BaseTable } from "@tiptap/extension-table";
const Table = BaseTable.configure({
resizable: true,
cellMinWidth: 100,
allowTableNodeSelection: true,
});
export { Table };

View File

@@ -6,6 +6,7 @@ import { EditorBubbleMenu } from "./bubble-menu";
import { TiptapExtensions } from "./extensions";
import { TiptapEditorProps } from "./props";
import { ImageResizer } from "./extensions/image-resize";
import { TableMenu } from "./table-menu";
export interface ITipTapRichTextEditor {
value: string;
@@ -37,6 +38,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
borderOnFocus,
customClassName,
} = props;
const editor = useEditor({
editable: editable ?? true,
editorProps: TiptapEditorProps(workspaceSlug, setIsSubmitting),
@@ -54,12 +56,6 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
},
});
useEffect(() => {
if (editor) {
editor.commands.setContent(value);
}
}, [value]);
const editorRef: React.MutableRefObject<Editor | null> = useRef(null);
useImperativeHandle(forwardedRef, () => ({
@@ -81,8 +77,8 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
const editorClassNames = `relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md
${noBorder ? "" : "border border-custom-border-200"} ${
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
} ${customClassName}`;
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
} ${customClassName}`;
if (!editor) return null;
editorRef.current = editor;
@@ -98,6 +94,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
{editor && <EditorBubbleMenu editor={editor} />}
<div className={`${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
<TableMenu editor={editor} />
{editor?.isActive("image") && <ImageResizer editor={editor} />}
</div>
</div>

View File

@@ -1,43 +1,51 @@
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import fileService from "services/file.service";
const deleteKey = new PluginKey("delete-image");
const IMAGE_NODE_TYPE = "image";
const TrackImageDeletionPlugin = () =>
interface ImageNode extends ProseMirrorNode {
attrs: {
src: string;
id: string;
};
}
const TrackImageDeletionPlugin = (): Plugin =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions, oldState, newState) => {
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
newImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const removedImages: ProseMirrorNode[] = [];
const removedImages: ImageNode[] = [];
oldState.doc.descendants((oldNode, oldPos) => {
if (oldNode.type.name !== "image") return;
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
if (oldPos < 0 || oldPos > newState.doc.content.size) return;
if (!newState.doc.resolve(oldPos).parent) return;
const newNode = newState.doc.nodeAt(oldPos);
// Check if the node has been deleted or replaced
if (!newNode || newNode.type.name !== "image") {
// Check if the node still exists elsewhere in the document
let nodeExists = false;
newState.doc.descendants((node) => {
if (node.attrs.id === oldNode.attrs.id) {
nodeExists = true;
}
});
if (!nodeExists) {
removedImages.push(oldNode as ProseMirrorNode);
if (!newNode || newNode.type.name !== IMAGE_NODE_TYPE) {
if (!newImageSources.has(oldNode.attrs.src)) {
removedImages.push(oldNode as ImageNode);
}
}
});
removedImages.forEach((node) => {
removedImages.forEach(async (node) => {
const src = node.attrs.src;
onNodeDeleted(src);
await onNodeDeleted(src);
});
});
@@ -47,10 +55,14 @@ const TrackImageDeletionPlugin = () =>
export default TrackImageDeletionPlugin;
async function onNodeDeleted(src: string) {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("Image deleted successfully");
async function onNodeDeleted(src: string): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("Image deleted successfully");
}
} catch (error) {
console.error("Error deleting image: ", error);
}
}

View File

@@ -1,4 +1,3 @@
// @ts-nocheck
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import fileService from "services/file.service";
@@ -46,7 +45,11 @@ export default UploadImagesPlugin;
function findPlaceholder(state: EditorState, id: {}) {
const decos = uploadKey.getState(state);
const found = decos.find(undefined, undefined, (spec: { id: number | undefined }) => spec.id == id);
const found = decos.find(
undefined,
undefined,
(spec: { id: number | undefined }) => spec.id == id
);
return found.length ? found[0].from : null;
}
@@ -59,8 +62,6 @@ export async function startImageUpload(
) {
if (!file.type.includes("image/")) {
return;
} else if (file.size / 1024 / 1024 > 20) {
return;
}
const id = {};
@@ -93,7 +94,9 @@ export async function startImageUpload(
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr.replaceWith(pos, pos, node).setMeta(uploadKey, { remove: { id } });
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
}
@@ -107,7 +110,9 @@ const UploadImageHandler = (file: File, workspaceSlug: string): Promise<string>
formData.append("attributes", JSON.stringify({}));
return new Promise(async (resolve, reject) => {
const imageUrl = await fileService.uploadFile(workspaceSlug, formData).then((response) => response.asset);
const imageUrl = await fileService
.uploadFile(workspaceSlug, formData)
.then((response) => response.asset);
const image = new Image();
image.src = imageUrl;

View File

@@ -1,5 +1,6 @@
import { EditorProps } from "@tiptap/pm/view";
import { startImageUpload } from "./plugins/upload-image";
import { findTableAncestor } from "./table-menu";
export function TiptapEditorProps(
workspaceSlug: string,
@@ -21,6 +22,15 @@ export function TiptapEditorProps(
},
},
handlePaste: (view, event) => {
if (typeof window !== "undefined") {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
}
}
}
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) {
event.preventDefault();
const file = event.clipboardData.files[0];
@@ -31,6 +41,15 @@ export function TiptapEditorProps(
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (typeof window !== "undefined") {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
}
}
}
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
event.preventDefault();
const file = event.dataTransfer.files[0];

View File

@@ -15,6 +15,7 @@ import {
MinusSquare,
CheckSquare,
ImageIcon,
Table,
} from "lucide-react";
import { startImageUpload } from "../plugins/upload-image";
import { cn } from "../utils";
@@ -46,6 +47,9 @@ const Command = Extension.create({
return [
Suggestion({
editor: this.editor,
allow({ editor }) {
return !editor.isActive("table");
},
...this.options.suggestion,
}),
];
@@ -53,7 +57,10 @@ const Command = Extension.create({
});
const getSuggestionItems =
(workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) =>
(
workspaceSlug: string,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) =>
({ query }: { query: string }) =>
[
{
@@ -119,6 +126,20 @@ const getSuggestionItems =
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: "Table",
description: "Create a Table",
searchTerms: ["table", "cell", "db", "data", "tabular"],
icon: <Table size={18} />,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
@@ -134,14 +155,21 @@ const getSuggestionItems =
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
},
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Image",
@@ -190,7 +218,15 @@ export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
}
};
const CommandList = ({ items, command }: { items: CommandItemProps[]; command: any; editor: any; range: any }) => {
const CommandList = ({
items,
command,
}: {
items: CommandItemProps[];
command: any;
editor: any;
range: any;
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = useCallback(

View File

@@ -0,0 +1,16 @@
const InsertBottomTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M212.309-152.31q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-375.383q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm535.382-219.998H212.309q-4.616 0-8.463 3.846-3.846 3.846-3.846 8.462V-600q0 4.616 3.846 8.462 3.847 3.847 8.463 3.847h535.382q4.616 0 8.463-3.847Q760-595.384 760-600v-135.383q0-4.616-3.846-8.462-3.847-3.846-8.463-3.846ZM200-587.691v-160 160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertBottomTableIcon;

View File

@@ -0,0 +1,15 @@
const InsertLeftTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M224.617-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm147.691-607.69q0-4.616-3.846-8.463-3.846-3.846-8.462-3.846H600q-4.616 0-8.462 3.846-3.847 3.847-3.847 8.463v535.382q0 4.616 3.847 8.463Q595.384-200 600-200h135.383q4.616 0 8.462-3.846 3.846-3.847 3.846-8.463v-535.382ZM587.691-200h160-160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertLeftTableIcon;

View File

@@ -0,0 +1,16 @@
const InsertRightTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M600-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm-375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm-12.308-607.69v535.382q0 4.616 3.846 8.463 3.846 3.846 8.462 3.846H360q4.616 0 8.462-3.846 3.847-3.847 3.847-8.463v-535.382q0-4.616-3.847-8.463Q364.616-760 360-760H224.617q-4.616 0-8.462 3.846-3.846 3.847-3.846 8.463Zm160 547.691h-160 160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertRightTableIcon;

View File

@@ -0,0 +1,15 @@
const InsertTopTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M212.309-527.693q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0 375.383q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-59.999h535.382q4.616 0 8.463-3.846 3.846-3.846 3.846-8.462V-360q0-4.616-3.846-8.462-3.847-3.847-8.463-3.847H212.309q-4.616 0-8.463 3.847Q200-364.616 200-360v135.383q0 4.616 3.846 8.462 3.847 3.846 8.463 3.846Zm-12.309-160v160-160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertTopTableIcon;

View File

@@ -0,0 +1,143 @@
import { useState, useEffect } from "react";
import { Rows, Columns, ToggleRight } from "lucide-react";
import { cn } from "../utils";
import { Tooltip } from "components/ui";
import InsertLeftTableIcon from "./InsertLeftTableIcon";
import InsertRightTableIcon from "./InsertRightTableIcon";
import InsertTopTableIcon from "./InsertTopTableIcon";
import InsertBottomTableIcon from "./InsertBottomTableIcon";
interface TableMenuItem {
command: () => void;
icon: any;
key: string;
name: string;
}
export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
while (node !== null && node.nodeName !== "TABLE") {
node = node.parentNode;
}
return node as HTMLTableElement;
};
export const TableMenu = ({ editor }: { editor: any }) => {
const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 });
const isOpen = editor?.isActive("table");
const items: TableMenuItem[] = [
{
command: () => editor.chain().focus().addColumnBefore().run(),
icon: InsertLeftTableIcon,
key: "insert-column-left",
name: "Insert 1 column left",
},
{
command: () => editor.chain().focus().addColumnAfter().run(),
icon: InsertRightTableIcon,
key: "insert-column-right",
name: "Insert 1 column right",
},
{
command: () => editor.chain().focus().addRowBefore().run(),
icon: InsertTopTableIcon,
key: "insert-row-above",
name: "Insert 1 row above",
},
{
command: () => editor.chain().focus().addRowAfter().run(),
icon: InsertBottomTableIcon,
key: "insert-row-below",
name: "Insert 1 row below",
},
{
command: () => editor.chain().focus().deleteColumn().run(),
icon: Columns,
key: "delete-column",
name: "Delete column",
},
{
command: () => editor.chain().focus().deleteRow().run(),
icon: Rows,
key: "delete-row",
name: "Delete row",
},
{
command: () => editor.chain().focus().toggleHeaderRow().run(),
icon: ToggleRight,
key: "toggle-header-row",
name: "Toggle header row",
},
];
useEffect(() => {
if (!window) return;
const handleWindowClick = () => {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
const tableNode = findTableAncestor(range.startContainer);
let parent = tableNode?.parentElement;
if (tableNode) {
const tableRect = tableNode.getBoundingClientRect();
const tableCenter = tableRect.left + tableRect.width / 2;
const menuWidth = 45;
const menuLeft = tableCenter - menuWidth / 2;
const tableBottom = tableRect.bottom;
setTableLocation({ bottom: tableBottom, left: menuLeft });
while (parent) {
if (!parent.classList.contains("disable-scroll"))
parent.classList.add("disable-scroll");
parent = parent.parentElement;
}
} else {
const scrollDisabledContainers = document.querySelectorAll(".disable-scroll");
scrollDisabledContainers.forEach((container) => {
container.classList.remove("disable-scroll");
});
}
}
};
window.addEventListener("click", handleWindowClick);
return () => {
window.removeEventListener("click", handleWindowClick);
};
}, [tableLocation, editor]);
return (
<section
className={`fixed left-1/2 transform -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-sm p-1 ${
isOpen ? "block" : "hidden"
}`}
style={{
bottom: `calc(100vh - ${tableLocation.bottom + 45}px)`,
left: `${tableLocation.left}px`,
}}
>
{items.map((item, index) => (
<Tooltip key={index} tooltipContent={item.name}>
<button
onClick={item.command}
className="p-1.5 text-custom-text-200 hover:bg-text-custom-text-100 hover:bg-custom-background-80 active:bg-custom-background-80 rounded"
title={item.name}
>
<item.icon
className={cn("h-4 w-4 text-lg", {
"text-red-600": item.key.includes("delete"),
})}
/>
</button>
</Tooltip>
))}
</section>
);
};

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

@@ -12,7 +12,10 @@ const nextConfig = {
};
if (parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) {
const nextConfigWithNginx = withImages({ basePath: "/spaces", ...nextConfig });
const nextConfigWithNginx = withImages({
basePath: "/spaces",
...nextConfig,
});
module.exports = nextConfigWithNginx;
} else {
module.exports = nextConfig;

View File

@@ -17,7 +17,6 @@
"@heroicons/react": "^2.0.12",
"@mui/icons-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-color": "^2.0.4",
@@ -62,19 +61,17 @@
"uuid": "^9.0.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@types/js-cookie": "^3.0.3",
"@types/node": "18.14.1",
"@types/nprogress": "^0.2.0",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/uuid": "^9.0.1",
"autoprefixer": "^10.4.13",
"@typescript-eslint/eslint-plugin": "^5.48.2",
"eslint": "8.34.0",
"eslint-config-custom": "*",
"eslint-config-next": "13.2.1",
"postcss": "^8.4.21",
"tsconfig": "*",
"tailwindcss": "^3.2.7"
"tailwind-config-custom": "*"
}
}

View File

@@ -1,7 +1,8 @@
import useSWR from "swr";
import type { GetServerSideProps } from "next";
import { useRouter } from "next/router";
import Head from "next/head";
import { useRouter } from "next/router";
import useSWR from "swr";
/// layouts
import ProjectLayout from "layouts/project-layout";
// components
@@ -39,12 +40,4 @@ const WorkspaceProjectPage = (props: any) => {
);
};
// export const getServerSideProps: GetServerSideProps<any> = async ({ query: { workspace_slug, project_slug } }) => {
// const res = await fetch(
// `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/public/workspaces/${workspace_slug}/project-boards/${project_slug}/settings/`
// );
// const project_settings = await res.json();
// return { props: { project_settings } };
// };
export default WorkspaceProjectPage;

View File

@@ -1,24 +1,17 @@
import React, { useEffect } from "react";
import Image from "next/image";
// assets
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// services
import authenticationService from "services/authentication.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { OnBoardingForm } from "components/accounts/onboarding-form";
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
const OnBoardingPage = () => {
const { user: userStore } = useMobxStore();
const user = userStore?.currentUser;
const { setToastAlert } = useToast();
useEffect(() => {
const user = userStore?.currentUser;
@@ -34,7 +27,7 @@ const OnBoardingPage = () => {
<div className="absolute border-b-[0.5px] sm:border-r-[0.5px] border-custom-border-200 h-[0.5px] w-full top-1/2 left-0 -translate-y-1/2 sm:h-screen sm:w-[0.5px] sm:top-0 sm:left-1/2 md:left-1/3 sm:-translate-x-1/2 sm:translate-y-0 z-10" />
<div className="absolute grid place-items-center bg-custom-background-100 px-3 sm:px-0 py-5 left-2 sm:left-1/2 md:left-1/3 sm:-translate-x-1/2 top-1/2 -translate-y-1/2 sm:translate-y-0 sm:top-12 z-10">
<div className="h-[30px] w-[30px]">
<Image src={BluePlaneLogoWithoutText} alt="Plane logo" />
<img src={`${imagePrefix}/plane-logos/blue-without-text.png`} alt="Plane logo" />
</div>
</div>
<div className="absolute sm:fixed text-custom-text-100 text-sm font-medium right-4 top-1/4 sm:top-12 -translate-y-1/2 sm:translate-y-0 sm:right-16 sm:py-5">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,203 +1 @@
/** @type {import('tailwindcss').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")],
};
module.exports = require("tailwind-config-custom/tailwind.config");

View File

@@ -1,6 +1,6 @@
{
"extends": "tsconfig/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "additional.d.ts"],
"exclude": ["node_modules"],
"compilerOptions": {
"baseUrl": ".",

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
# 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
@@ -23,4 +21,4 @@ NEXT_PUBLIC_SLACK_CLIENT_ID=""
# For Telemetry, set it to "app.plane.so"
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
# Public boards deploy URL
NEXT_PUBLIC_DEPLOY_URL=""
NEXT_PUBLIC_DEPLOY_URL="http://localhost:3000/spaces"

View File

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

View File

@@ -1,13 +1,13 @@
// nivo
import { BarDatum } from "@nivo/bar";
// icons
import { getPriorityIcon } from "components/icons";
import { PriorityIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// helpers
import { generateBarColor, renderMonthAndYear } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
import { IAnalyticsParams, IAnalyticsResponse, TIssuePriorities } from "types";
// constants
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, DATE_KEYS } from "constants/analytics";
@@ -53,7 +53,7 @@ export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, param
>
<div className="flex items-center gap-2">
{params.segment === "priority" ? (
getPriorityIcon(key)
<PriorityIcon priority={key as TIssuePriorities} />
) : (
<span
className="h-3 w-3 flex-shrink-0 rounded"
@@ -91,7 +91,7 @@ export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, param
}`}
>
{params.x_axis === "priority" ? (
getPriorityIcon(`${item.name}`)
<PriorityIcon priority={item.name as TIssuePriorities} />
) : (
<span
className="h-3 w-3 rounded"

View File

@@ -1,7 +1,7 @@
// icons
import { PlayIcon } from "@heroicons/react/24/outline";
// types
import { IDefaultAnalyticsResponse } from "types";
import { IDefaultAnalyticsResponse, TStateGroups } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
@@ -27,7 +27,7 @@ export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
<span
className="h-2 w-2 rounded-full"
style={{
backgroundColor: STATE_GROUP_COLORS[group.state_group],
backgroundColor: STATE_GROUP_COLORS[group.state_group as TStateGroups],
}}
/>
<h6 className="capitalize">{group.state_group}</h6>
@@ -42,7 +42,7 @@ export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
className="absolute top-0 left-0 h-1 rounded duration-300"
style={{
width: `${percentage}%`,
backgroundColor: STATE_GROUP_COLORS[group.state_group],
backgroundColor: STATE_GROUP_COLORS[group.state_group as TStateGroups],
}}
/>
</div>

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import { useRouter } from "next/router";
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// cmdk
@@ -7,12 +9,12 @@ import { Command } from "cmdk";
// services
import issuesService from "services/issues.service";
// types
import { ICurrentUserResponse, IIssue } from "types";
import { ICurrentUserResponse, IIssue, TIssuePriorities } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import { PRIORITIES } from "constants/project";
// icons
import { CheckIcon, getPriorityIcon } from "components/icons";
import { CheckIcon, PriorityIcon } from "components/icons";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
@@ -54,7 +56,7 @@ export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue,
[workspaceSlug, issueId, projectId, user]
);
const handleIssueState = (priority: string | null) => {
const handleIssueState = (priority: TIssuePriorities) => {
submitChanges({ priority });
setIsPaletteOpen(false);
};
@@ -68,7 +70,7 @@ export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue,
className="focus:outline-none"
>
<div className="flex items-center space-x-3">
{getPriorityIcon(priority)}
<PriorityIcon priority={priority} />
<span className="capitalize">{priority ?? "None"}</span>
</div>
<div>{priority === issue.priority && <CheckIcon className="h-3 w-3" />}</div>

View File

@@ -1,22 +1,24 @@
import { useRouter } from "next/router";
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// ui
import { Spinner } from "components/ui";
// helpers
import { getStatesList } from "helpers/state.helper";
// services
import issuesService from "services/issues.service";
import stateService from "services/state.service";
// ui
import { Spinner } from "components/ui";
// icons
import { CheckIcon, StateGroupIcon } from "components/icons";
// helpers
import { getStatesList } from "helpers/state.helper";
// types
import { ICurrentUserResponse, IIssue } from "types";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, STATES_LIST } from "constants/fetch-keys";
// icons
import { CheckIcon, getStateGroupIcon } from "components/icons";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
@@ -82,7 +84,12 @@ export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue, use
className="focus:outline-none"
>
<div className="flex items-center space-x-3">
{getStateGroupIcon(state.group, "16", "16", state.color)}
<StateGroupIcon
stateGroup={state.group}
color={state.color}
height="16px"
width="16px"
/>
<p>{state.name}</p>
</div>
<div>{state.id === issue.state && <CheckIcon className="h-3 w-3" />}</div>

View File

@@ -1,15 +1,11 @@
import { Fragment } from "react";
import { useRouter } from "next/router";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// react-datepicker
import DatePicker from "react-datepicker";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// hooks
import useIssuesView from "hooks/use-issues-view";
// components
import { DateFilterSelect } from "./date-filter-select";
// ui
@@ -23,8 +19,10 @@ import { IIssueFilterOptions } from "types";
type Props = {
title: string;
field: keyof IIssueFilterOptions;
isOpen: boolean;
filters: IIssueFilterOptions;
handleClose: () => void;
isOpen: boolean;
onSelect: (option: any) => void;
};
type TFormValues = {
@@ -39,12 +37,14 @@ const defaultValues: TFormValues = {
date2: new Date(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()),
};
export const DateFilterModal: React.FC<Props> = ({ title, field, isOpen, handleClose }) => {
const { filters, setFilters } = useIssuesView();
const router = useRouter();
const { viewId } = router.query;
export const DateFilterModal: React.FC<Props> = ({
title,
field,
filters,
handleClose,
isOpen,
onSelect,
}) => {
const { handleSubmit, watch, control } = useForm<TFormValues>({
defaultValues,
});
@@ -53,10 +53,10 @@ export const DateFilterModal: React.FC<Props> = ({ title, field, isOpen, handleC
const { filterType, date1, date2 } = formData;
if (filterType === "range") {
setFilters(
{ [field]: [`${renderDateFormat(date1)};after`, `${renderDateFormat(date2)};before`] },
!Boolean(viewId)
);
onSelect({
key: field,
value: [`${renderDateFormat(date1)};after`, `${renderDateFormat(date2)};before`],
});
} else {
const filteredArray = (filters?.[field] as string[])?.filter((item) => {
if (item?.includes(filterType)) return false;
@@ -66,17 +66,12 @@ export const DateFilterModal: React.FC<Props> = ({ title, field, isOpen, handleC
const filterOne = filteredArray && filteredArray?.length > 0 ? filteredArray[0] : null;
if (filterOne)
setFilters(
{ [field]: [filterOne, `${renderDateFormat(date1)};${filterType}`] },
!Boolean(viewId)
);
onSelect({ key: field, value: [filterOne, `${renderDateFormat(date1)};${filterType}`] });
else
setFilters(
{
[field]: [`${renderDateFormat(date1)};${filterType}`],
},
!Boolean(viewId)
);
onSelect({
key: field,
value: [`${renderDateFormat(date1)};${filterType}`],
});
}
handleClose();
};

View File

@@ -2,7 +2,7 @@ import React from "react";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
import { PriorityIcon, StateGroupIcon } from "components/icons";
// ui
import { Avatar } from "components/ui";
// helpers
@@ -71,12 +71,10 @@ export const FiltersList: React.FC<Props> = ({
}}
>
<span>
{getStateGroupIcon(
state?.group ?? "backlog",
"12",
"12",
state?.color
)}
<StateGroupIcon
stateGroup={state?.group ?? "backlog"}
color={state?.color}
/>
</span>
<span>{state?.name ?? ""}</span>
<span
@@ -105,7 +103,9 @@ export const FiltersList: React.FC<Props> = ({
backgroundColor: `${STATE_GROUP_COLORS[group]}20`,
}}
>
<span>{getStateGroupIcon(group, "16", "16")}</span>
<span>
<StateGroupIcon stateGroup={group} color={undefined} />
</span>
<span>{group}</span>
<span
className="cursor-pointer"
@@ -136,7 +136,9 @@ export const FiltersList: React.FC<Props> = ({
: "bg-custom-background-90 text-custom-text-200"
}`}
>
<span>{getPriorityIcon(priority)}</span>
<span>
<PriorityIcon priority={priority} />
</span>
<span>{priority === "null" ? "None" : priority}</span>
<span
className="cursor-pointer"

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 = () => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues");
const isDraftIssues = router.pathname.includes("draft-issues");
const {
issueView,
setIssueView,
groupByProperty,
setGroupByProperty,
orderBy,
setOrderBy,
showEmptyGroups,
showSubIssues,
setShowSubIssues,
setShowEmptyGroups,
displayFilters,
setDisplayFilters,
filters,
setFilters,
resetFilterToDefault,
@@ -83,7 +87,7 @@ export const IssuesFilterView: React.FC = () => {
return (
<div className="flex items-center gap-2">
{!isArchivedIssues && (
{!isArchivedIssues && !isDraftIssues && (
<div className="flex items-center gap-x-1">
{issueViewOptions.map((option) => (
<Tooltip
@@ -96,11 +100,41 @@ export const IssuesFilterView: React.FC = () => {
<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 ${
issueView === option.type
displayFilters.layout === option.type
? "bg-custom-sidebar-background-80"
: "text-custom-sidebar-text-200"
}`}
onClick={() => setIssueView(option.type)}
onClick={() => setDisplayFilters({ layout: option.type })}
>
<option.Icon
sx={{
fontSize: 16,
}}
className={option.type === "gantt_chart" ? "rotate-90" : ""}
/>
</button>
</Tooltip>
))}
</div>
)}
{isDraftIssues && (
<div className="flex items-center gap-x-1">
{issueViewForDraftIssues.map((option) => (
<Tooltip
key={option.type}
tooltipContent={
<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} View</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={{
@@ -174,28 +208,30 @@ 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">
<div className="relative divide-y-2 divide-custom-border-200">
<div className="space-y-4 pb-3 text-xs">
{issueView !== "calendar" &&
issueView !== "spreadsheet" &&
issueView !== "gantt_chart" && (
{displayFilters.layout !== "calendar" &&
displayFilters.layout !== "spreadsheet" &&
displayFilters.layout !== "gantt_chart" && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Group by</h4>
<div className="w-28">
<CustomMenu
label={
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
?.name ?? "Select"
GROUP_BY_OPTIONS.find(
(option) => option.key === displayFilters.group_by
)?.name ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{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;
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
onClick={() => setDisplayFilters({ group_by: option.key })}
>
{option.name}
</CustomMenu.MenuItem>
@@ -205,41 +241,45 @@ export const IssuesFilterView: React.FC = () => {
</div>
</div>
)}
{issueView !== "calendar" && issueView !== "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 === orderBy)?.name ??
"Select"
}
className="!w-full"
buttonClassName="w-full"
>
{ORDER_BY_OPTIONS.map((option) =>
groupByProperty === "priority" && option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
{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>
</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"
FILTER_ISSUE_OPTIONS.find(
(option) => option.key === displayFilters.type
)?.name ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
@@ -248,7 +288,7 @@ export const IssuesFilterView: React.FC = () => {
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setFilters({
setDisplayFilters({
type: option.key,
})
}
@@ -260,33 +300,40 @@ export const IssuesFilterView: React.FC = () => {
</div>
</div>
{issueView !== "calendar" && issueView !== "spreadsheet" && (
<div className="flex items-center justify-between">
<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" && (
{displayFilters.layout !== "calendar" &&
displayFilters.layout !== "spreadsheet" && (
<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">
<ToggleSwitch
value={showEmptyGroups}
onChange={() => setShowEmptyGroups(!showEmptyGroups)}
value={displayFilters.sub_issue ?? true}
onChange={() =>
setDisplayFilters({ sub_issue: !displayFilters.sub_issue })
}
/>
</div>
</div>
)}
{issueView !== "calendar" &&
issueView !== "spreadsheet" &&
issueView !== "gantt_chart" && (
{displayFilters.layout !== "calendar" &&
displayFilters.layout !== "spreadsheet" &&
displayFilters.layout !== "gantt_chart" && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty states</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">
<button type="button" onClick={() => resetFilterToDefault()}>
Reset to default
@@ -302,7 +349,7 @@ export const IssuesFilterView: React.FC = () => {
)}
</div>
{issueView !== "gantt_chart" && (
{displayFilters.layout !== "gantt_chart" && (
<div className="space-y-2 py-3">
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2 text-custom-text-200">
@@ -310,7 +357,7 @@ export const IssuesFilterView: React.FC = () => {
if (key === "estimate" && !isEstimateActive) return null;
if (
issueView === "spreadsheet" &&
displayFilters.layout === "spreadsheet" &&
(key === "attachment_count" ||
key === "link" ||
key === "sub_issue_count")
@@ -318,7 +365,7 @@ export const IssuesFilterView: React.FC = () => {
return null;
if (
issueView !== "spreadsheet" &&
displayFilters.layout !== "spreadsheet" &&
(key === "created_on" || key === "updated_on")
)
return null;

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ import {
TAssigneesDistribution,
TCompletionChartDistribution,
TLabelsDistribution,
TStateGroups,
} from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
@@ -215,7 +216,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
<span
className="block h-3 w-3 rounded-full "
style={{
backgroundColor: STATE_GROUP_COLORS[group],
backgroundColor: STATE_GROUP_COLORS[group as TStateGroups],
}}
/>
<span className="text-xs capitalize">{group}</span>

View File

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

View File

@@ -1,7 +1,14 @@
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
import { SingleBoard } from "components/core/views/board-view/single-board";
import { IssuePeekOverview } from "components/issues";
// icons
import { getStateGroupIcon } from "components/icons";
import { StateGroupIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
@@ -13,9 +20,12 @@ type Props = {
disableAddIssueOption?: boolean;
dragDisabled: boolean;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
myIssueProjectId?: string | null;
handleMyIssueOpen?: (issue: IIssue) => void;
states: IState[] | undefined;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
@@ -28,25 +38,53 @@ export const AllBoards: React.FC<Props> = ({
disableAddIssueOption = false,
dragDisabled,
handleIssueAction,
handleDraftIssueAction,
handleTrashBox,
openIssuesListModal,
myIssueProjectId,
handleMyIssueOpen,
removeIssue,
states,
user,
userAuth,
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 (
<>
<IssuePeekOverview
handleMutation={() =>
isMyIssue ? mutateMyIssues() : isProfileIssue ? mutateProfileIssues() : mutateIssues()
}
projectId={myIssueProjectId ? myIssueProjectId : projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={disableUserActions}
/>
{groupedIssues ? (
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8">
{Object.keys(groupedIssues).map((singleGroup, index) => {
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 (
<SingleBoard
@@ -58,8 +96,10 @@ export const AllBoards: React.FC<Props> = ({
dragDisabled={dragDisabled}
groupTitle={singleGroup}
handleIssueAction={handleIssueAction}
handleDraftIssueAction={handleDraftIssueAction}
handleTrashBox={handleTrashBox}
openIssuesListModal={openIssuesListModal ?? null}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={removeIssue}
user={user}
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">
<h2 className="text-lg font-semibold">Hidden groups</h2>
<div className="space-y-3">
{Object.keys(groupedIssues).map((singleGroup, index) => {
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)
return (
@@ -82,10 +124,16 @@ export const AllBoards: React.FC<Props> = ({
className="flex items-center justify-between gap-2 rounded bg-custom-background-90 p-2 shadow"
>
<div className="flex items-center gap-2">
{currentState &&
getStateGroupIcon(currentState.group, "16", "16", currentState.color)}
{currentState && (
<StateGroupIcon
stateGroup={currentState.group}
color={currentState.color}
height="16px"
width="16px"
/>
)}
<h4 className="text-sm capitalize">
{selectedGroup === "state"
{displayFilters?.group_by === "state"
? addSpaceIfCamelCase(currentState?.name ?? "")
: addSpaceIfCamelCase(singleGroup)}
</h4>

View File

@@ -13,14 +13,16 @@ import useProjects from "hooks/use-projects";
import { Avatar, Icon } from "components/ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
import { PriorityIcon, StateGroupIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
// types
import { IIssueViewProps, IState } from "types";
import { IIssueViewProps, IState, TIssuePriorities, TStateGroups } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
type Props = {
currentState?: IState | null;
@@ -46,22 +48,28 @@ export const BoardHeader: React.FC<Props> = ({
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { groupedIssues, groupByProperty: selectedGroup } = viewProps;
const { displayFilters, groupedIssues } = viewProps;
console.log("dF", displayFilters);
const { data: issueLabels } = useSWR(
workspaceSlug && projectId && selectedGroup === "labels"
workspaceSlug && projectId && displayFilters?.group_by === "labels"
? PROJECT_ISSUE_LABELS(projectId.toString())
: null,
workspaceSlug && projectId && selectedGroup === "labels"
workspaceSlug && projectId && displayFilters?.group_by === "labels"
? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString())
: null
);
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())
: 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())
: null
);
@@ -71,7 +79,7 @@ export const BoardHeader: React.FC<Props> = ({
const getGroupTitle = () => {
let title = addSpaceIfCamelCase(groupTitle);
switch (selectedGroup) {
switch (displayFilters?.group_by) {
case "state":
title = addSpaceIfCamelCase(currentState?.name ?? "");
break;
@@ -95,16 +103,29 @@ export const BoardHeader: React.FC<Props> = ({
const getGroupIcon = () => {
let icon;
switch (selectedGroup) {
switch (displayFilters?.group_by) {
case "state":
icon =
currentState && getStateGroupIcon(currentState.group, "16", "16", currentState.color);
icon = currentState && (
<StateGroupIcon
stateGroup={currentState.group}
color={currentState.color}
height="16px"
width="16px"
/>
);
break;
case "state_detail.group":
icon = getStateGroupIcon(groupTitle as any, "16", "16");
icon = (
<StateGroupIcon
stateGroup={groupTitle as TStateGroups}
color={STATE_GROUP_COLORS[groupTitle as TStateGroups]}
height="16px"
width="16px"
/>
);
break;
case "priority":
icon = getPriorityIcon(groupTitle, "text-lg");
icon = <PriorityIcon priority={groupTitle as TIssuePriorities} className="text-lg" />;
break;
case "project":
const project = projects?.find((p) => p.id === groupTitle);
@@ -152,7 +173,7 @@ export const BoardHeader: React.FC<Props> = ({
<span className="flex items-center">{getGroupIcon()}</span>
<h2
className={`text-lg font-semibold truncate ${
selectedGroup === "created_by" ? "" : "capitalize"
displayFilters?.group_by === "created_by" ? "" : "capitalize"
}`}
style={{
writingMode: isCollapsed ? "horizontal-tb" : "vertical-rl",
@@ -183,7 +204,7 @@ export const BoardHeader: React.FC<Props> = ({
<Icon iconName="open_in_full" className="text-base font-medium text-custom-text-900" />
)}
</button>
{!disableAddIssue && !disableUserActions && selectedGroup !== "created_by" && (
{!disableAddIssue && !disableUserActions && displayFilters?.group_by !== "created_by" && (
<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"

View File

@@ -24,8 +24,10 @@ type Props = {
dragDisabled: boolean;
groupTitle: string;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null;
handleMyIssueOpen?: (issue: IIssue) => void;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
@@ -40,8 +42,10 @@ export const SingleBoard: React.FC<Props> = ({
disableAddIssueOption = false,
dragDisabled,
handleIssueAction,
handleDraftIssueAction,
handleTrashBox,
openIssuesListModal,
handleMyIssueOpen,
removeIssue,
user,
userAuth,
@@ -50,7 +54,7 @@ export const SingleBoard: React.FC<Props> = ({
// collapse/expand
const [isCollapsed, setIsCollapsed] = useState(true);
const { groupedIssues, groupByProperty: selectedGroup, orderBy, properties } = viewProps;
const { displayFilters, groupedIssues } = viewProps;
const router = useRouter();
const { cycleId, moduleId } = router.query;
@@ -80,14 +84,14 @@ export const SingleBoard: React.FC<Props> = ({
{(provided, snapshot) => (
<div
className={`relative h-full ${
orderBy !== "sort_order" && snapshot.isDraggingOver
displayFilters?.order_by !== "sort_order" && snapshot.isDraggingOver
? "bg-custom-background-100/20"
: ""
} ${!isCollapsed ? "hidden" : "flex flex-col"}`}
ref={provided.innerRef}
{...provided.droppableProps}
>
{orderBy !== "sort_order" && (
{displayFilters?.order_by !== "sort_order" && (
<>
<div
className={`absolute ${
@@ -101,7 +105,11 @@ export const SingleBoard: React.FC<Props> = ({
>
This board is ordered by{" "}
{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>
</>
@@ -130,7 +138,18 @@ export const SingleBoard: React.FC<Props> = ({
editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
handleDraftIssueEdit={
handleDraftIssueAction
? () => handleDraftIssueAction(issue, "edit")
: undefined
}
handleDraftIssueDelete={() =>
handleDraftIssueAction
? handleDraftIssueAction(issue, "delete")
: undefined
}
handleTrashBox={handleTrashBox}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={() => {
if (removeIssue && issue.bridge_id)
removeIssue(issue.bridge_id, issue.id);
@@ -145,13 +164,13 @@ export const SingleBoard: React.FC<Props> = ({
))}
<span
style={{
display: orderBy === "sort_order" ? "inline" : "none",
display: displayFilters?.order_by === "sort_order" ? "inline" : "none",
}}
>
{provided.placeholder}
<>{provided.placeholder}</>
</span>
</div>
{selectedGroup !== "created_by" && (
{displayFilters?.group_by !== "created_by" && (
<div>
{type === "issue"
? !disableAddIssueOption && (

View File

@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
@@ -58,8 +57,11 @@ type Props = {
index: number;
editIssue: () => void;
makeIssueCopy: () => void;
handleMyIssueOpen?: (issue: IIssue) => void;
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
handleDraftIssueEdit?: () => void;
handleDraftIssueDelete?: () => void;
handleTrashBox: (isDragging: boolean) => void;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
@@ -75,9 +77,12 @@ export const SingleBoardIssue: React.FC<Props> = ({
index,
editIssue,
makeIssueCopy,
handleMyIssueOpen,
removeIssue,
groupTitle,
handleDeleteIssue,
handleDraftIssueEdit,
handleDraftIssueDelete,
handleTrashBox,
disableUserActions,
user,
@@ -93,11 +98,13 @@ export const SingleBoardIssue: React.FC<Props> = ({
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const { groupByProperty: selectedGroup, orderBy, properties, mutateIssues } = viewProps;
const { displayFilters, properties, mutateIssues } = viewProps;
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const isDraftIssue = router.pathname.includes("draft-issues");
const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback(
@@ -131,9 +138,9 @@ export const SingleBoardIssue: React.FC<Props> = ({
handleIssuesMutation(
formData,
groupTitle ?? "",
selectedGroup,
displayFilters?.group_by ?? null,
index,
orderBy,
displayFilters?.order_by ?? "-created_at",
prevData
),
false
@@ -149,24 +156,14 @@ export const SingleBoardIssue: React.FC<Props> = ({
if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
});
},
[
workspaceSlug,
cycleId,
moduleId,
groupTitle,
index,
selectedGroup,
mutateIssues,
orderBy,
user,
]
[displayFilters, workspaceSlug, cycleId, moduleId, groupTitle, index, mutateIssues, user]
);
const getStyle = (
style: DraggingStyle | NotDraggingStyle | undefined,
snapshot: DraggableStateSnapshot
) => {
if (orderBy === "sort_order") return style;
if (displayFilters?.order_by === "sort_order") return style;
if (!snapshot.isDragging) return {};
if (!snapshot.isDropAnimating) return style;
@@ -197,6 +194,17 @@ export const SingleBoardIssue: React.FC<Props> = ({
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;
return (
@@ -209,29 +217,47 @@ export const SingleBoardIssue: React.FC<Props> = ({
>
{!isNotAllowed && (
<>
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
<ContextMenu.Item
Icon={PencilIcon}
onClick={() => {
if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
else editIssue();
}}
>
Edit issue
</ContextMenu.Item>
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
Make a copy...
</ContextMenu.Item>
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
{!isDraftIssue && (
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
Make a copy...
</ContextMenu.Item>
)}
<ContextMenu.Item
Icon={TrashIcon}
onClick={() => {
if (isDraftIssue && handleDraftIssueDelete) handleDraftIssueDelete();
else handleDeleteIssue(issue);
}}
>
Delete issue
</ContextMenu.Item>
</>
)}
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link
</ContextMenu.Item>
<a
href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
{!isDraftIssue && (
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link
</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>
<div
className={`mb-3 rounded bg-custom-background-100 shadow ${
@@ -266,13 +292,18 @@ export const SingleBoardIssue: React.FC<Props> = ({
</button>
}
>
<CustomMenu.MenuItem onClick={editIssue}>
<CustomMenu.MenuItem
onClick={() => {
if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
else editIssue();
}}
>
<div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
<span>Edit issue</span>
</div>
</CustomMenu.MenuItem>
{type !== "issue" && removeIssue && (
{type !== "issue" && removeIssue && !isDraftIssue && (
<CustomMenu.MenuItem onClick={removeIssue}>
<div className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" />
@@ -280,32 +311,48 @@ export const SingleBoardIssue: React.FC<Props> = ({
</div>
</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">
<TrashIcon className="h-4 w-4" />
<span>Delete issue</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy issue Link</span>
</div>
</CustomMenu.MenuItem>
{!isDraftIssue && (
<CustomMenu.MenuItem onClick={handleCopyText}>
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy issue Link</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
)}
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="flex flex-col gap-1.5">
{properties.key && (
<div className="text-xs font-medium text-custom-text-200">
{issue.project_detail.identifier}-{issue.sequence_id}
</div>
)}
<h5 className="text-sm break-words line-clamp-2">{issue.name}</h5>
</a>
</Link>
<div className="flex flex-col gap-1.5">
{properties.key && (
<div className="text-xs font-medium text-custom-text-200">
{issue.project_detail.identifier}-{issue.sequence_id}
</div>
)}
<button
type="button"
className="text-sm text-left break-words line-clamp-2"
onClick={() => {
if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
else openPeekOverview();
}}
>
{issue.name}
</button>
</div>
<div
className={`flex items-center gap-2 text-xs ${
isDropdownActive ? "" : "overflow-x-scroll"

View File

@@ -12,6 +12,7 @@ import issuesService from "services/issues.service";
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
// components
import { SingleCalendarDate, CalendarHeader } from "components/core";
import { IssuePeekOverview } from "components/issues";
// ui
import { Spinner } from "components/ui";
// helpers
@@ -60,7 +61,8 @@ export const CalendarView: React.FC<Props> = ({
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { calendarIssues, params, setCalendarDateRange } = useCalendarIssuesView();
const { calendarIssues, mutateIssues, params, displayFilters, setDisplayFilters } =
useCalendarIssuesView();
const totalDate = eachDayOfInterval({
start: calendarDates.startDate,
@@ -152,90 +154,103 @@ export const CalendarView: React.FC<Props> = ({
endDate,
});
setCalendarDateRange(
`${renderDateFormat(startDate)};after,${renderDateFormat(endDate)};before`
);
setDisplayFilters({
calendar_date_range: `${renderDateFormat(startDate)};after,${renderDateFormat(
endDate
)};before`,
});
};
useEffect(() => {
setCalendarDateRange(
`${renderDateFormat(startOfWeek(currentDate))};after,${renderDateFormat(
lastDayOfWeek(currentDate)
)};before`
);
}, [currentDate]);
if (!displayFilters || displayFilters.calendar_date_range === "")
setDisplayFilters({
calendar_date_range: `${renderDateFormat(
startOfWeek(currentDate)
)};after,${renderDateFormat(lastDayOfWeek(currentDate))};before`,
});
}, [currentDate, displayFilters, setDisplayFilters]);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
return calendarIssues ? (
<div className="h-full overflow-y-auto">
<DragDropContext onDragEnd={onDragEnd}>
<div className="h-full rounded-lg p-8 text-custom-text-200">
<CalendarHeader
isMonthlyView={isMonthlyView}
setIsMonthlyView={setIsMonthlyView}
showWeekEnds={showWeekEnds}
setShowWeekEnds={setShowWeekEnds}
currentDate={currentDate}
setCurrentDate={setCurrentDate}
changeDateRange={changeDateRange}
/>
return (
<>
<IssuePeekOverview
handleMutation={() => mutateIssues()}
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={disableUserActions}
/>
{calendarIssues ? (
<div className="h-full overflow-y-auto">
<DragDropContext onDragEnd={onDragEnd}>
<div className="h-full rounded-lg p-8 text-custom-text-200">
<CalendarHeader
isMonthlyView={isMonthlyView}
setIsMonthlyView={setIsMonthlyView}
showWeekEnds={showWeekEnds}
setShowWeekEnds={setShowWeekEnds}
currentDate={currentDate}
setCurrentDate={setCurrentDate}
changeDateRange={changeDateRange}
/>
<div
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
}`}
>
{weeks.map((date, index) => (
<div
key={index}
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 ${
!isMonthlyView
? showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
: ""
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
}`}
>
<span>
{isMonthlyView
? formatDate(date, "eee").substring(0, 3)
: formatDate(date, "eee")}
</span>
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
{weeks.map((date, index) => (
<div
key={index}
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 ${
!isMonthlyView
? showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
: ""
}`}
>
<span>
{isMonthlyView
? formatDate(date, "eee").substring(0, 3)
: formatDate(date, "eee")}
</span>
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
</div>
))}
</div>
))}
</div>
<div
className={`grid h-full ${isMonthlyView ? "auto-rows-min" : ""} ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
} `}
>
{currentViewDaysData.map((date, index) => (
<SingleCalendarDate
key={`${date}-${index}`}
index={index}
date={date}
handleIssueAction={handleIssueAction}
addIssueToDate={addIssueToDate}
isMonthlyView={isMonthlyView}
showWeekEnds={showWeekEnds}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
<div
className={`grid h-full ${isMonthlyView ? "auto-rows-min" : ""} ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
} `}
>
{currentViewDaysData.map((date, index) => (
<SingleCalendarDate
key={`${date}-${index}`}
index={index}
date={date}
handleIssueAction={handleIssueAction}
addIssueToDate={addIssueToDate}
isMonthlyView={isMonthlyView}
showWeekEnds={showWeekEnds}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
</div>
</DragDropContext>
</div>
</DragDropContext>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</>
);
};

View File

@@ -1,6 +1,5 @@
import React, { useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
@@ -158,6 +157,15 @@ export const SingleCalendarIssue: React.FC<Props> = ({
? Object.values(properties).some((value) => value === true)
: false;
const openPeekOverview = () => {
const { query } = router;
router.push({
pathname: router.pathname,
query: { ...query, peekIssue: issue.id },
});
};
return (
<div
key={index}
@@ -193,23 +201,27 @@ export const SingleCalendarIssue: React.FC<Props> = ({
</CustomMenu>
</div>
)}
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="flex w-full cursor-pointer flex-col items-start justify-center gap-1.5">
{properties.key && (
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-custom-text-200">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
)}
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-xs text-custom-text-100">{truncateText(issue.name, 25)}</span>
<button
type="button"
className="flex w-full cursor-pointer flex-col items-start justify-center gap-1.5"
onClick={openPeekOverview}
>
{properties.key && (
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-custom-text-200">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
</a>
</Link>
)}
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-xs text-custom-text-100">{truncateText(issue.name, 25)}</span>
</Tooltip>
</button>
{displayProperties && (
<div className="relative mt-1.5 w-full flex flex-wrap items-center gap-2 text-xs">
{properties.priority && (

View File

@@ -6,20 +6,24 @@ import { IssueGanttChartView } from "components/issues";
import { ModuleIssuesGanttChartView } from "components/modules";
import { ViewIssuesGanttChartView } from "components/views";
export const GanttChartView = () => {
type Props = {
disableUserActions: boolean;
};
export const GanttChartView: React.FC<Props> = ({ disableUserActions }) => {
const router = useRouter();
const { cycleId, moduleId, viewId } = router.query;
return (
<>
{cycleId ? (
<CycleIssuesGanttChartView />
<CycleIssuesGanttChartView disableUserActions={disableUserActions} />
) : moduleId ? (
<ModuleIssuesGanttChartView />
<ModuleIssuesGanttChartView disableUserActions={disableUserActions} />
) : viewId ? (
<ViewIssuesGanttChartView />
<ViewIssuesGanttChartView disableUserActions={disableUserActions} />
) : (
<IssueGanttChartView />
<IssueGanttChartView disableUserActions={disableUserActions} />
)}
</>
);

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