Merge branch 'develop' of github.com:makeplane/plane into dev/migration_priority_drafts_relation_props

This commit is contained in:
NarayanBavisetti 2023-09-14 15:39:18 +05:30
commit 67a5e7debc
81 changed files with 1527 additions and 1209 deletions

View File

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

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

@ -49,6 +49,7 @@ class IssueFlatSerializer(BaseSerializer):
"target_date", "target_date",
"sequence_id", "sequence_id",
"sort_order", "sort_order",
"is_draft",
] ]

View File

@ -1038,6 +1038,7 @@ urlpatterns = [
IssueDraftViewSet.as_view( IssueDraftViewSet.as_view(
{ {
"get": "list", "get": "list",
"post": "create",
} }
), ),
name="project-issue-draft", name="project-issue-draft",
@ -1047,6 +1048,7 @@ urlpatterns = [
IssueDraftViewSet.as_view( IssueDraftViewSet.as_view(
{ {
"get": "retrieve", "get": "retrieve",
"patch": "partial_update",
"delete": "destroy", "delete": "destroy",
} }
), ),

View File

@ -508,7 +508,7 @@ class IssueActivityEndpoint(BaseAPIView):
issue_activities = ( issue_activities = (
IssueActivity.objects.filter(issue_id=issue_id) IssueActivity.objects.filter(issue_id=issue_id)
.filter( .filter(
~Q(field__in=["comment", "vote", "reaction"]), ~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
) )
.select_related("actor", "workspace", "issue", "project") .select_related("actor", "workspace", "issue", "project")
@ -2358,6 +2358,47 @@ class IssueDraftViewSet(BaseViewSet):
serializer_class = IssueFlatSerializer serializer_class = IssueFlatSerializer
model = Issue model = Issue
def perform_update(self, serializer):
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
current_instance = (
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None:
issue_activity.delay(
type="issue_draft.activity.updated",
requested_data=requested_data,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
)
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): def get_queryset(self):
return ( return (
Issue.objects.annotate( Issue.objects.annotate(
@ -2383,6 +2424,7 @@ class IssueDraftViewSet(BaseViewSet):
) )
) )
@method_decorator(gzip_page) @method_decorator(gzip_page)
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
try: try:
@ -2492,6 +2534,40 @@ class IssueDraftViewSet(BaseViewSet):
) )
def create(self, request, slug, project_id):
try:
project = Project.objects.get(pk=project_id)
serializer = IssueCreateSerializer(
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
},
)
if serializer.is_valid():
serializer.save(is_draft=True)
# Track the issue
issue_activity.delay(
type="issue_draft.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
)
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): def retrieve(self, request, slug, project_id, pk=None):
try: try:
issue = Issue.objects.get( issue = Issue.objects.get(

View File

@ -396,16 +396,16 @@ def track_assignees(
def create_issue_activity( def create_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities
): ):
issue_activities.append( issue_activities.append(
IssueActivity( IssueActivity(
issue_id=issue_id, issue_id=issue_id,
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"created the issue", comment=f"created the issue",
verb="created", verb="created",
actor=actor, actor=actor,
)
) )
)
def track_estimate_points( def track_estimate_points(
@ -518,11 +518,6 @@ def update_issue_activity(
"closed_to": track_closed_to, "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: for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None) func = ISSUE_ACTIVITY_MAPPER.get(key, None)
if func is not None: if func is not None:
@ -1095,6 +1090,69 @@ def delete_issue_relation_activity(
) )
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 # Receive message from room group
@shared_task @shared_task
def issue_activity( def issue_activity(
@ -1166,6 +1224,9 @@ def issue_activity(
"comment_reaction.activity.deleted": delete_comment_reaction_activity, "comment_reaction.activity.deleted": delete_comment_reaction_activity,
"issue_vote.activity.created": create_issue_vote_activity, "issue_vote.activity.created": create_issue_vote_activity,
"issue_vote.activity.deleted": delete_issue_vote_activity, "issue_vote.activity.deleted": delete_issue_vote_activity,
"issue_draft.activity.created": create_draft_issue_activity,
"issue_draft.activity.updated": update_draft_issue_activity,
"issue_draft.activity.deleted": delete_draft_issue_activity,
} }
func = ACTIVITY_MAPPER.get(type) func = ACTIVITY_MAPPER.get(type)

View File

@ -39,14 +39,90 @@ def group_results(results_data, group_by, sub_group_by=False):
for value in results_data: for value in results_data:
main_group_attribute = resolve_keys(sub_group_by, value) main_group_attribute = resolve_keys(sub_group_by, value)
if str(main_group_attribute) not in main_responsive_dict:
main_responsive_dict[str(main_group_attribute)] = {}
group_attribute = resolve_keys(group_by, value) group_attribute = resolve_keys(group_by, value)
if str(group_attribute) in main_responsive_dict: if isinstance(main_group_attribute, list) and not isinstance(group_attribute, list):
main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value) if len(main_group_attribute):
for attrib in main_group_attribute:
if str(attrib) not in main_responsive_dict:
main_responsive_dict[str(attrib)] = {}
if str(group_attribute) in main_responsive_dict[str(attrib)]:
main_responsive_dict[str(attrib)][str(group_attribute)].append(value)
else:
main_responsive_dict[str(attrib)][str(group_attribute)] = []
main_responsive_dict[str(attrib)][str(group_attribute)].append(value)
else:
if str(None) not in main_responsive_dict:
main_responsive_dict[str(None)] = {}
if str(group_attribute) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][str(group_attribute)].append(value)
else:
main_responsive_dict[str(None)][str(group_attribute)] = []
main_responsive_dict[str(None)][str(group_attribute)].append(value)
elif isinstance(group_attribute, list) and not isinstance(main_group_attribute, list):
if str(main_group_attribute) not in main_responsive_dict:
main_responsive_dict[str(main_group_attribute)] = {}
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in main_responsive_dict[str(main_group_attribute)]:
main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value)
else:
main_responsive_dict[str(main_group_attribute)][str(attrib)] = []
main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value)
else:
if str(None) in main_responsive_dict[str(main_group_attribute)]:
main_responsive_dict[str(main_group_attribute)][str(None)].append(value)
else:
main_responsive_dict[str(main_group_attribute)][str(None)] = []
main_responsive_dict[str(main_group_attribute)][str(None)].append(value)
elif isinstance(group_attribute, list) and isinstance(main_group_attribute, list):
if len(main_group_attribute):
for main_attrib in main_group_attribute:
if str(main_attrib) not in main_responsive_dict:
main_responsive_dict[str(main_attrib)] = {}
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in main_responsive_dict[str(main_attrib)]:
main_responsive_dict[str(main_attrib)][str(attrib)].append(value)
else:
main_responsive_dict[str(main_attrib)][str(attrib)] = []
main_responsive_dict[str(main_attrib)][str(attrib)].append(value)
else:
if str(None) in main_responsive_dict[str(main_attrib)]:
main_responsive_dict[str(main_attrib)][str(None)].append(value)
else:
main_responsive_dict[str(main_attrib)][str(None)] = []
main_responsive_dict[str(main_attrib)][str(None)].append(value)
else:
if str(None) not in main_responsive_dict:
main_responsive_dict[str(None)] = {}
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][str(attrib)].append(value)
else:
main_responsive_dict[str(None)][str(attrib)] = []
main_responsive_dict[str(None)][str(attrib)].append(value)
else:
if str(None) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][str(None)].append(value)
else:
main_responsive_dict[str(None)][str(None)] = []
main_responsive_dict[str(None)][str(None)].append(value)
else: else:
main_responsive_dict[str(main_group_attribute)][str(group_attribute)] = [] main_group_attribute = resolve_keys(sub_group_by, value)
main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(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 return main_responsive_dict

View File

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

View File

@ -1,30 +1,29 @@
events { } events { }
http { http {
sendfile on; sendfile on;
server { server {
listen 80; listen 80;
root /www/data/; root /www/data/;
access_log /var/log/nginx/access.log; access_log /var/log/nginx/access.log;
client_max_body_size ${FILE_SIZE_LIMIT}; client_max_body_size ${FILE_SIZE_LIMIT};
location / { location / {
proxy_pass http://planefrontend:3000/; 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

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

View File

@ -5,15 +5,12 @@ cp ./.env.example ./.env
export LC_ALL=C export LC_ALL=C
export LC_CTYPE=C export LC_CTYPE=C
cp ./web/.env.example ./web/.env
# Generate the NEXT_PUBLIC_API_BASE_URL with given IP cp ./space/.env.example ./space/.env
echo -e "\nNEXT_PUBLIC_API_BASE_URL=$1" >> ./.env cp ./apiserver/.env.example ./apiserver/.env
# Generate the SECRET_KEY that will be used by django # Generate the SECRET_KEY that will be used by django
echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./.env echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env
# WEB_URL for email redirection and image saving
echo -e "WEB_URL=$1" >> ./.env
# Generate Prompt for taking tiptap auth key # Generate Prompt for taking tiptap auth key
echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token \e[0m \n" echo -e "\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 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" 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/ 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 # Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID="" NEXT_PUBLIC_GOOGLE_CLIENTID=""
# Flag to toggle OAuth # Flag to toggle OAuth
NEXT_PUBLIC_ENABLE_OAUTH=1 NEXT_PUBLIC_ENABLE_OAUTH=0

View File

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

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { useEffect } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -13,7 +13,7 @@ import useToast from "hooks/use-toast";
// components // components
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts"; import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
// images // images
const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces" : ""; const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
export const SignInView = observer(() => { export const SignInView = observer(() => {
const { user: userStore } = useMobxStore(); const { user: userStore } = useMobxStore();

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

@ -5,7 +5,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
// components // components
import { OnBoardingForm } from "components/accounts/onboarding-form"; import { OnBoardingForm } from "components/accounts/onboarding-form";
const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces" : ""; const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
const OnBoardingPage = () => { const OnBoardingPage = () => {
const { user: userStore } = useMobxStore(); const { user: userStore } = useMobxStore();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 # Extra image domains that need to be added for Next Image
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS= NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
# Google Client ID for Google OAuth # Google Client ID for Google OAuth
@ -23,4 +21,4 @@ NEXT_PUBLIC_SLACK_CLIENT_ID=""
# For Telemetry, set it to "app.plane.so" # For Telemetry, set it to "app.plane.so"
NEXT_PUBLIC_PLAUSIBLE_DOMAIN="" NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
# Public boards deploy URL # Public boards deploy URL
NEXT_PUBLIC_DEPLOY_URL="" NEXT_PUBLIC_DEPLOY_URL="http://localhost:3000/spaces"

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,10 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState, useRef } from "react";
// headless ui // headless ui
import { Tab, Transition, Popover } from "@headlessui/react"; import { Tab, Transition, Popover } from "@headlessui/react";
// react colors // react colors
import { TwitterPicker } from "react-color"; import { TwitterPicker } from "react-color";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// types // types
import { Props } from "./types"; import { Props } from "./types";
// emojis // emojis
@ -36,6 +38,8 @@ const EmojiIconPicker: React.FC<Props> = ({
const [recentEmojis, setRecentEmojis] = useState<string[]>([]); const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
const emojiPickerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
setRecentEmojis(getRecentEmojis()); setRecentEmojis(getRecentEmojis());
}, []); }, []);
@ -44,6 +48,8 @@ const EmojiIconPicker: React.FC<Props> = ({
if (!value || value?.length === 0) onChange(getRandomEmoji()); if (!value || value?.length === 0) onChange(getRandomEmoji());
}, [value, onChange]); }, [value, onChange]);
useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false));
return ( return (
<Popover className="relative z-[1]"> <Popover className="relative z-[1]">
<Popover.Button <Popover.Button
@ -63,7 +69,10 @@ const EmojiIconPicker: React.FC<Props> = ({
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Popover.Panel className="absolute z-10 mt-2 w-[250px] rounded-[4px] border border-custom-border-200 bg-custom-background-80 shadow-lg"> <Popover.Panel className="absolute z-10 mt-2 w-[250px] rounded-[4px] border border-custom-border-200 bg-custom-background-80 shadow-lg">
<div className="h-[230px] w-[250px] overflow-auto rounded-[4px] border border-custom-border-200 bg-custom-background-80 p-2 shadow-xl"> <div
ref={emojiPickerRef}
className="h-[230px] w-[250px] overflow-auto rounded-[4px] border border-custom-border-200 bg-custom-background-80 p-2 shadow-xl"
>
<Tab.Group as="div" className="flex h-full w-full flex-col"> <Tab.Group as="div" className="flex h-full w-full flex-col">
<Tab.List className="flex-0 -mx-2 flex justify-around gap-1 p-1"> <Tab.List className="flex-0 -mx-2 flex justify-around gap-1 p-1">
{tabOptions.map((tab) => ( {tabOptions.map((tab) => (

View File

@ -66,7 +66,7 @@ export const SingleEstimate: React.FC<Props> = ({
return ( return (
<> <>
<div className="gap-2 py-3"> <div className="gap-2 p-4 border-b border-custom-border-200">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h6 className="flex w-[40vw] items-center gap-2 truncate text-sm font-medium"> <h6 className="flex w-[40vw] items-center gap-2 truncate text-sm font-medium">

View File

@ -84,3 +84,4 @@ export * from "./clock-icon";
export * from "./bell-icon"; export * from "./bell-icon";
export * from "./single-comment-icon"; export * from "./single-comment-icon";
export * from "./related-icon"; export * from "./related-icon";
export * from "./module-icon";

View File

@ -0,0 +1,59 @@
import React from "react";
import type { Props } from "./types";
export const ModuleIcon: React.FC<Props> = ({
width = "24",
height = "24",
className,
color = "#F15B5B",
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2Z"
stroke="#F15B5B"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5.84925 4.66667H4.81221C4.73039 4.66667 4.66406 4.733 4.66406 4.81482V5.85185C4.66406 5.93367 4.73039 6 4.81221 6H5.84925C5.93107 6 5.9974 5.93367 5.9974 5.85185V4.81482C5.9974 4.733 5.93107 4.66667 5.84925 4.66667Z"
fill={color}
stroke="#F15B5B"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5.84925 10H4.81221C4.73039 10 4.66406 10.0663 4.66406 10.1481V11.1852C4.66406 11.267 4.73039 11.3333 4.81221 11.3333H5.84925C5.93107 11.3333 5.9974 11.267 5.9974 11.1852V10.1481C5.9974 10.0663 5.93107 10 5.84925 10Z"
fill={color}
stroke="#F15B5B"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M11.1852 4.66667H10.1481C10.0663 4.66667 10 4.733 10 4.81482V5.85185C10 5.93367 10.0663 6 10.1481 6H11.1852C11.267 6 11.3333 5.93367 11.3333 5.85185V4.81482C11.3333 4.733 11.267 4.66667 11.1852 4.66667Z"
fill={color}
stroke="#F15B5B"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M11.1852 10H10.1481C10.0663 10 10 10.0663 10 10.1481V11.1852C10 11.267 10.0663 11.3333 10.1481 11.3333H11.1852C11.267 11.3333 11.3333 11.267 11.3333 11.1852V10.1481C11.3333 10.0663 11.267 10 11.1852 10Z"
fill={color}
stroke="#F15B5B"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);

View File

@ -66,6 +66,8 @@ export const SelectRepository: React.FC<Props> = ({
content: <p>{truncateText(repo.full_name, characterLimit)}</p>, content: <p>{truncateText(repo.full_name, characterLimit)}</p>,
})) ?? []; })) ?? [];
if (userRepositories.length < 1) return null;
return ( return (
<CustomSearchSelect <CustomSearchSelect
value={value} value={value}

View File

@ -83,9 +83,7 @@ export const SelectChannel: React.FC<Props> = ({ integration }) => {
{projectIntegration ? ( {projectIntegration ? (
<button <button
type="button" type="button"
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${ className={`relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none bg-gray-700`}
slackChannelAvailabilityToggle ? "bg-green-500" : "bg-gray-200"
}`}
role="switch" role="switch"
aria-checked aria-checked
onClick={() => { onClick={() => {
@ -94,8 +92,8 @@ export const SelectChannel: React.FC<Props> = ({ integration }) => {
> >
<span <span
aria-hidden="true" aria-hidden="true"
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${ className={`self-center inline-block h-2 w-2 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
slackChannelAvailabilityToggle ? "translate-x-5" : "translate-x-0" slackChannelAvailabilityToggle ? "translate-x-3" : "translate-x-0"
}`} }`}
/> />
</button> </button>

View File

@ -17,7 +17,7 @@ import issuesService from "services/issues.service";
// ui // ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui"; import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// icons // icons
import { ChevronDownIcon } from "@heroicons/react/24/outline"; import { Component } from "lucide-react";
// types // types
import { IIssueLabels } from "types"; import { IIssueLabels } from "types";
// fetch-keys // fetch-keys
@ -132,7 +132,7 @@ export const CreateUpdateLabelInline = forwardRef<HTMLDivElement, Props>(
return ( return (
<div <div
className={`flex scroll-m-8 items-center gap-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5 ${ className={`flex scroll-m-8 items-center gap-2 rounded border border-custom-border-200 bg-custom-background-100 px-3.5 py-2 ${
labelForm ? "" : "hidden" labelForm ? "" : "hidden"
}`} }`}
ref={ref} ref={ref}
@ -146,18 +146,12 @@ export const CreateUpdateLabelInline = forwardRef<HTMLDivElement, Props>(
open ? "text-custom-text-100" : "text-custom-text-200" open ? "text-custom-text-100" : "text-custom-text-200"
}`} }`}
> >
<span <Component
className="h-5 w-5 rounded" className="h-4 w-4 text-custom-text-100 flex-shrink-0"
style={{ style={{
backgroundColor: watch("color"), color: watch("color"),
}} }}
/> />
<ChevronDownIcon
className={`ml-2 h-5 w-5 group-hover:text-custom-text-200 ${
open ? "text-gray-600" : "text-gray-400"
}`}
aria-hidden="true"
/>
</Popover.Button> </Popover.Button>
<Transition <Transition

View File

@ -13,12 +13,12 @@ import { CustomMenu } from "components/ui";
// icons // icons
import { import {
ChevronDownIcon, ChevronDownIcon,
RectangleGroupIcon,
XMarkIcon, XMarkIcon,
PlusIcon, PlusIcon,
PencilIcon, PencilIcon,
TrashIcon, TrashIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { Component, X } from "lucide-react";
// types // types
import { ICurrentUserResponse, IIssueLabels } from "types"; import { ICurrentUserResponse, IIssueLabels } from "types";
// fetch-keys // fetch-keys
@ -76,20 +76,18 @@ export const SingleLabelGroup: React.FC<Props> = ({
return ( return (
<Disclosure <Disclosure
as="div" as="div"
className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5 text-custom-text-100" className="rounded border border-custom-border-200 bg-custom-background-100 px-3.5 py-3 text-custom-text-100"
defaultOpen defaultOpen
> >
{({ open }) => ( {({ open }) => (
<> <>
<div className="flex cursor-pointer items-center justify-between gap-2"> <div className="flex cursor-pointer items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span> <Component className="h-4 w-4 text-custom-text-100 flex-shrink-0" />
<RectangleGroupIcon className="h-4 w-4" />
</span>
<h6>{label.name}</h6> <h6>{label.name}</h6>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CustomMenu ellipsis> <CustomMenu ellipsis buttonClassName="!text-custom-sidebar-text-400">
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}> <CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
@ -112,7 +110,9 @@ export const SingleLabelGroup: React.FC<Props> = ({
<Disclosure.Button> <Disclosure.Button>
<span> <span>
<ChevronDownIcon <ChevronDownIcon
className={`h-4 w-4 text-custom-text-100 ${!open ? "rotate-90 transform" : ""}`} className={`h-4 w-4 text-custom-sidebar-text-400 ${
!open ? "rotate-90 transform" : ""
}`}
/> />
</span> </span>
</Disclosure.Button> </Disclosure.Button>
@ -128,15 +128,15 @@ export const SingleLabelGroup: React.FC<Props> = ({
leaveTo="transform opacity-0" leaveTo="transform opacity-0"
> >
<Disclosure.Panel> <Disclosure.Panel>
<div className="mt-3 ml-6 space-y-3"> <div className="mt-2.5 ml-6">
{labelChildren.map((child) => ( {labelChildren.map((child) => (
<div <div
key={child.id} key={child.id}
className="group flex items-center justify-between rounded-md border border-custom-border-200 p-2 text-sm" className="group flex items-center justify-between border-b border-custom-border-200 px-4 py-2.5 text-sm last:border-0"
> >
<h5 className="flex items-center gap-3"> <h5 className="flex items-center gap-3">
<span <span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full" className="h-3.5 w-3.5 flex-shrink-0 rounded-full"
style={{ style={{
backgroundColor: backgroundColor:
child.color && child.color !== "" ? child.color : "#000000", child.color && child.color !== "" ? child.color : "#000000",
@ -144,27 +144,38 @@ export const SingleLabelGroup: React.FC<Props> = ({
/> />
{child.name} {child.name}
</h5> </h5>
<div className="pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"> <div className="flex items-center gap-3.5 pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100">
<CustomMenu ellipsis> <div className="h-4 w-4">
<CustomMenu.MenuItem onClick={() => removeFromGroup(child)}> <CustomMenu
<span className="flex items-center justify-start gap-2"> customButton={
<XMarkIcon className="h-4 w-4" /> <div className="h-4 w-4">
<span>Remove from group</span> <Component className="h-4 w-4 leading-4 text-custom-sidebar-text-400 flex-shrink-0" />
</span> </div>
</CustomMenu.MenuItem> }
<CustomMenu.MenuItem onClick={() => editLabel(child)}> >
<span className="flex items-center justify-start gap-2"> <CustomMenu.MenuItem onClick={() => removeFromGroup(child)}>
<PencilIcon className="h-4 w-4" /> <span className="flex items-center justify-start gap-2">
<span>Edit label</span> <XMarkIcon className="h-4 w-4" />
</span> <span>Remove from group</span>
</CustomMenu.MenuItem> </span>
<CustomMenu.MenuItem onClick={handleLabelDelete}> </CustomMenu.MenuItem>
<span className="flex items-center justify-start gap-2"> <CustomMenu.MenuItem onClick={() => editLabel(child)}>
<TrashIcon className="h-4 w-4" /> <span className="flex items-center justify-start gap-2">
<span>Delete label</span> <PencilIcon className="h-4 w-4" />
</span> <span>Edit label</span>
</CustomMenu.MenuItem> </span>
</CustomMenu> </CustomMenu.MenuItem>
</CustomMenu>
</div>
<div className="flex items-center">
<button
className="flex items-center justify-start gap-2"
onClick={handleLabelDelete}
>
<X className="h-[18px] w-[18px] text-custom-sidebar-text-400 flex-shrink-0" />
</button>
</div>
</div> </div>
</div> </div>
))} ))}

View File

@ -5,7 +5,8 @@ import { CustomMenu } from "components/ui";
// types // types
import { IIssueLabels } from "types"; import { IIssueLabels } from "types";
//icons //icons
import { RectangleGroupIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline"; import { RectangleGroupIcon, PencilIcon } from "@heroicons/react/24/outline";
import { Component, X } from "lucide-react";
type Props = { type Props = {
label: IIssueLabels; label: IIssueLabels;
@ -20,8 +21,8 @@ export const SingleLabel: React.FC<Props> = ({
editLabel, editLabel,
handleLabelDelete, handleLabelDelete,
}) => ( }) => (
<div className="gap-2 space-y-3 divide-y divide-custom-border-200 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5"> <div className="gap-2 space-y-3 divide-y divide-custom-border-200 rounded border border-custom-border-200 bg-custom-background-100 px-4 py-2.5">
<div className="flex items-center justify-between"> <div className="group flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
className="h-3.5 w-3.5 flex-shrink-0 rounded-full" className="h-3.5 w-3.5 flex-shrink-0 rounded-full"
@ -31,26 +32,36 @@ export const SingleLabel: React.FC<Props> = ({
/> />
<h6 className="text-sm">{label.name}</h6> <h6 className="text-sm">{label.name}</h6>
</div> </div>
<CustomMenu ellipsis> <div className="flex items-center gap-3.5 pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100">
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}> <div className="h-4 w-4">
<span className="flex items-center justify-start gap-2"> <CustomMenu
<RectangleGroupIcon className="h-4 w-4" /> customButton={
<span>Convert to group</span> <div className="h-4 w-4">
</span> <Component className="h-4 w-4 leading-4 text-custom-sidebar-text-400 flex-shrink-0" />
</CustomMenu.MenuItem> </div>
<CustomMenu.MenuItem onClick={() => editLabel(label)}> }
<span className="flex items-center justify-start gap-2"> >
<PencilIcon className="h-4 w-4" /> <CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
<span>Edit label</span> <span className="flex items-center justify-start gap-2">
</span> <RectangleGroupIcon className="h-4 w-4" />
</CustomMenu.MenuItem> <span>Convert to group</span>
<CustomMenu.MenuItem onClick={handleLabelDelete}> </span>
<span className="flex items-center justify-start gap-2"> </CustomMenu.MenuItem>
<TrashIcon className="h-4 w-4" /> <CustomMenu.MenuItem onClick={() => editLabel(label)}>
<span>Delete label</span> <span className="flex items-center justify-start gap-2">
</span> <PencilIcon className="h-4 w-4" />
</CustomMenu.MenuItem> <span>Edit label</span>
</CustomMenu> </span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<div className="flex items-center">
<button className="flex items-center justify-start gap-2" onClick={handleLabelDelete}>
<X className="h-[18px] w-[18px] text-custom-sidebar-text-400 flex-shrink-0" />
</button>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,8 +1,9 @@
export * from "./create-project-modal"; export * from "./create-project-modal";
export * from "./delete-project-modal"; export * from "./delete-project-modal";
export * from "./sidebar-list"; export * from "./sidebar-list";
export * from "./settings-header"; export * from "./settings-sidebar";
export * from "./single-integration-card"; export * from "./single-integration-card";
export * from "./single-project-card"; export * from "./single-project-card";
export * from "./single-sidebar-project"; export * from "./single-sidebar-project";
export * from "./confirm-project-leave-modal"; export * from "./confirm-project-leave-modal";
export * from "./member-select";

View File

@ -0,0 +1,74 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import projectService from "services/project.service";
// ui
import { Avatar, CustomSearchSelect } from "components/ui";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
value: any;
onChange: (val: string) => void;
};
export const MemberSelect: React.FC<Props> = ({ value, onChange }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const options = members?.map((member) => ({
value: member.member.id,
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
}));
const selectedOption = members?.find((m) => m.member.id === value)?.member;
return (
<CustomSearchSelect
value={value}
label={
<div className="flex items-center gap-2">
{selectedOption && <Avatar user={selectedOption} />}
{selectedOption ? (
selectedOption?.display_name
) : (
<span className="text-sm py-0.5 text-custom-text-200">Select</span>
)}
</div>
}
buttonClassName="!px-3 !py-2"
options={
options &&
options && [
...options,
{
value: "none",
query: "none",
content: <div className="flex items-center gap-2">None</div>,
},
]
}
maxHeight="md"
position="right"
width="w-full"
onChange={onChange}
/>
);
};

View File

@ -63,7 +63,11 @@ export const PublishProjectModal: React.FC<Props> = observer(() => {
const [isUnpublishing, setIsUnpublishing] = useState(false); const [isUnpublishing, setIsUnpublishing] = useState(false);
const [isUpdateRequired, setIsUpdateRequired] = useState(false); const [isUpdateRequired, setIsUpdateRequired] = useState(false);
const plane_deploy_url = process.env.NEXT_PUBLIC_DEPLOY_URL ?? "http://localhost:4000"; let plane_deploy_url = process.env.NEXT_PUBLIC_DEPLOY_URL;
if (typeof window !== 'undefined' && !plane_deploy_url) {
plane_deploy_url= window.location.protocol + "//" + window.location.host + "/spaces";
}
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;

View File

@ -1,13 +0,0 @@
import SettingsNavbar from "layouts/settings-navbar";
export const SettingsHeader = () => (
<div className="mb-8 space-y-6">
<div>
<h3 className="text-2xl font-semibold">Project Settings</h3>
<p className="mt-1 text-sm text-custom-text-200">
This information will be displayed to every member of the project.
</p>
</div>
<SettingsNavbar />
</div>
);

View File

@ -0,0 +1,72 @@
import React from "react";
import { useRouter } from "next/router";
import Link from "next/link";
export const SettingsSidebar = () => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const projectLinks: Array<{
label: string;
href: string;
}> = [
{
label: "General",
href: `/${workspaceSlug}/projects/${projectId}/settings`,
},
{
label: "Members",
href: `/${workspaceSlug}/projects/${projectId}/settings/members`,
},
{
label: "Features",
href: `/${workspaceSlug}/projects/${projectId}/settings/features`,
},
{
label: "States",
href: `/${workspaceSlug}/projects/${projectId}/settings/states`,
},
{
label: "Labels",
href: `/${workspaceSlug}/projects/${projectId}/settings/labels`,
},
{
label: "Integrations",
href: `/${workspaceSlug}/projects/${projectId}/settings/integrations`,
},
{
label: "Estimates",
href: `/${workspaceSlug}/projects/${projectId}/settings/estimates`,
},
{
label: "Automations",
href: `/${workspaceSlug}/projects/${projectId}/settings/automations`,
},
];
return (
<div className="flex flex-col gap-2 w-80 px-9">
<span className="text-xs text-custom-sidebar-text-400 font-semibold">SETTINGS</span>
<div className="flex flex-col gap-1 w-full">
{projectLinks.map((link) => (
<Link key={link.href} href={link.href}>
<a>
<div
className={`px-4 py-2 text-sm font-medium rounded-md ${
(
link.label === "Import"
? router.asPath.includes(link.href)
: router.asPath === link.href
)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
}`}
>
{link.label}
</div>
</a>
</Link>
))}
</div>
</div>
);
};

View File

@ -30,8 +30,7 @@ const integrationDetails: { [key: string]: any } = {
}, },
slack: { slack: {
logo: SlackLogo, logo: SlackLogo,
description: description: "Get regular updates and control which notification you want to receive.",
"Connect your slack channel to this project to get regular updates. Control which notification you want to receive.",
}, },
}; };
@ -93,19 +92,19 @@ export const SingleIntegration: React.FC<Props> = ({ integration }) => {
return ( return (
<> <>
{integration && ( {integration && (
<div className="flex items-center justify-between gap-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5"> <div className="flex items-center justify-between gap-2 border-b border-custom-border-200 bg-custom-background-100 px-4 py-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="h-12 w-12 flex-shrink-0"> <div className="h-10 w-10 flex-shrink-0">
<Image <Image
src={integrationDetails[integration.integration_detail.provider].logo} src={integrationDetails[integration.integration_detail.provider].logo}
alt={`${integration.integration_detail.title} Logo`} alt={`${integration.integration_detail.title} Logo`}
/> />
</div> </div>
<div> <div>
<h3 className="flex items-center gap-4 text-xl font-semibold"> <h3 className="flex items-center gap-4 text-sm font-medium">
{integration.integration_detail.title} {integration.integration_detail.title}
</h3> </h3>
<p className="text-sm text-custom-text-200"> <p className="text-sm text-custom-text-200 tracking-tight">
{integrationDetails[integration.integration_detail.provider].description} {integrationDetails[integration.integration_detail.provider].description}
</p> </p>
</div> </div>

View File

@ -9,13 +9,10 @@ import stateService from "services/state.service";
// ui // ui
import { Tooltip } from "components/ui"; import { Tooltip } from "components/ui";
// icons // icons
import { import { ArrowDownIcon, ArrowUpIcon } from "@heroicons/react/24/outline";
ArrowDownIcon,
ArrowUpIcon,
PencilSquareIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import { StateGroupIcon } from "components/icons"; import { StateGroupIcon } from "components/icons";
import { Pencil, X } from "lucide-react";
// helpers // helpers
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
import { groupBy, orderArrayBy } from "helpers/array.helper"; import { groupBy, orderArrayBy } from "helpers/array.helper";
@ -160,15 +157,15 @@ export const SingleState: React.FC<Props> = ({
}; };
return ( return (
<div className="group flex items-center justify-between gap-2 border-custom-border-200 bg-custom-background-100 p-5 first:rounded-t-[10px] last:rounded-b-[10px]"> <div className="group flex items-center justify-between gap-2 rounded border border-custom-border-200 bg-custom-background-100 px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<StateGroupIcon stateGroup={state.group} color={state.color} height="20px" width="20px" /> <StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
<div> <div>
<h6 className="text-sm">{addSpaceIfCamelCase(state.name)}</h6> <h6 className="text-sm font-medium">{addSpaceIfCamelCase(state.name)}</h6>
<p className="text-xs text-custom-text-200">{state.description}</p> <p className="text-xs text-custom-text-200">{state.description}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="group flex items-center gap-2.5">
{index !== 0 && ( {index !== 0 && (
<button <button
type="button" type="button"
@ -192,37 +189,43 @@ export const SingleState: React.FC<Props> = ({
) : ( ) : (
<button <button
type="button" type="button"
className="hidden text-xs text-custom-text-200 group-hover:inline-block" className="hidden text-xs text-custom-sidebar-text-400 group-hover:inline-block"
onClick={handleMakeDefault} onClick={handleMakeDefault}
disabled={isSubmitting} disabled={isSubmitting}
> >
Set as default Mark as default
</button> </button>
)} )}
<div className=" items-center gap-2.5 hidden group-hover:flex">
<button
type="button"
className="grid place-items-center group-hover:opacity-100 opacity-0"
onClick={handleEditState}
>
<Pencil className="h-3.5 w-3.5 text-custom-text-200" />
</button>
<button type="button" className="grid place-items-center" onClick={handleEditState}> <button
<PencilSquareIcon className="h-4 w-4 text-custom-text-200" /> type="button"
</button> className={`group-hover:opacity-100 opacity-0 ${
<button state.default || groupLength === 1 ? "cursor-not-allowed" : ""
type="button" } grid place-items-center`}
className={`${ onClick={handleDeleteState}
state.default || groupLength === 1 ? "cursor-not-allowed" : "" disabled={state.default || groupLength === 1}
} grid place-items-center`} >
onClick={handleDeleteState} {state.default ? (
disabled={state.default || groupLength === 1} <Tooltip tooltipContent="Cannot delete the default state.">
> <X className="h-3.5 w-3.5 text-red-500" />
{state.default ? ( </Tooltip>
<Tooltip tooltipContent="Cannot delete the default state."> ) : groupLength === 1 ? (
<TrashIcon className="h-4 w-4 text-red-500" /> <Tooltip tooltipContent="Cannot have an empty group.">
</Tooltip> <X className="h-3.5 w-3.5 text-red-500" />
) : groupLength === 1 ? ( </Tooltip>
<Tooltip tooltipContent="Cannot have an empty group."> ) : (
<TrashIcon className="h-4 w-4 text-red-500" /> <X className="h-3.5 w-3.5 text-red-500" />
</Tooltip> )}
) : ( </button>
<TrashIcon className="h-4 w-4 text-red-500" /> </div>
)}
</button>
</div> </div>
</div> </div>
); );

View File

@ -6,8 +6,8 @@ type Props = {
}; };
export const IntegrationAndImportExportBanner: React.FC<Props> = ({ bannerName, description }) => ( export const IntegrationAndImportExportBanner: React.FC<Props> = ({ bannerName, description }) => (
<div className="flex flex-col items-start gap-3"> <div className="flex flex-col items-start gap-3 py-3.5 border-b border-custom-border-200">
<h3 className="text-2xl font-semibold">{bannerName}</h3> <h3 className="text-xl font-medium">{bannerName}</h3>
{description && ( {description && (
<div className="flex items-center gap-3 rounded-[10px] border border-custom-primary/75 bg-custom-primary/5 p-4 text-sm text-custom-text-100"> <div className="flex items-center gap-3 rounded-[10px] border border-custom-primary/75 bg-custom-primary/5 p-4 text-sm text-custom-text-100">
<ExclamationIcon height={24} width={24} className="fill-current text-custom-text-100" /> <ExclamationIcon height={24} width={24} className="fill-current text-custom-text-100" />

View File

@ -18,24 +18,24 @@ export const ToggleSwitch: React.FC<Props> = (props) => {
disabled={disabled} disabled={disabled}
onChange={onChange} onChange={onChange}
className={`relative flex-shrink-0 inline-flex ${ className={`relative flex-shrink-0 inline-flex ${
size === "sm" ? "h-3.5 w-6" : size === "md" ? "h-4 w-7" : "h-6 w-11" size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10"
} flex-shrink-0 cursor-pointer rounded-full border-2 border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${ } flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${
value ? "bg-green-500" : "bg-custom-background-80" value ? "bg-custom-primary-100" : "bg-gray-700"
} ${className || ""}`} } ${className || ""}`}
> >
<span className="sr-only">{label}</span> <span className="sr-only">{label}</span>
<span <span
aria-hidden="true" aria-hidden="true"
className={`inline-block ${ className={`self-center inline-block ${
size === "sm" ? "h-2.5 w-2.5" : size === "md" ? "h-3 w-3" : "h-5 w-5" size === "sm" ? "h-2 w-2" : size === "md" ? "h-3 w-3" : "h-4 w-4"
} transform rounded-full shadow ring-0 transition duration-200 ease-in-out ${ } transform rounded-full shadow ring-0 transition duration-200 ease-in-out ${
value value
? (size === "sm" ? (size === "sm"
? "translate-x-2.5"
: size === "md"
? "translate-x-3" ? "translate-x-3"
: size === "md"
? "translate-x-4"
: "translate-x-5") + " bg-white" : "translate-x-5") + " bg-white"
: "translate-x-0 bg-custom-background-90" : "translate-x-1 bg-custom-background-90"
}`} }`}
/> />
</Switch> </Switch>

View File

@ -16,3 +16,7 @@ export const debounce = (func: any, wait: number, immediate: boolean = false) =>
if (callNow) func(...args); if (callNow) func(...args);
}; };
}; };
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL
? process.env.NEXT_PUBLIC_API_BASE_URL
: "";

View File

@ -17,7 +17,11 @@ type Props = {
}; };
const { NEXT_PUBLIC_DEPLOY_URL } = process.env; const { NEXT_PUBLIC_DEPLOY_URL } = process.env;
const plane_deploy_url = NEXT_PUBLIC_DEPLOY_URL ? NEXT_PUBLIC_DEPLOY_URL : "http://localhost:3001"; let plane_deploy_url = NEXT_PUBLIC_DEPLOY_URL
if (typeof window !== 'undefined' && !plane_deploy_url) {
plane_deploy_url= window.location.protocol + "//" + window.location.host + "/spaces";
}
const Header: React.FC<Props> = ({ breadcrumbs, left, right, setToggleSidebar, noHeader }) => { const Header: React.FC<Props> = ({ breadcrumbs, left, right, setToggleSidebar, noHeader }) => {
const { projectDetails } = useProjectDetails(); const { projectDetails } = useProjectDetails();

View File

@ -2,6 +2,8 @@
import { convertCookieStringToObject } from "./cookie"; import { convertCookieStringToObject } from "./cookie";
// types // types
import type { IProjectMember, IUser, IWorkspace, IWorkspaceMember } from "types"; import type { IProjectMember, IUser, IWorkspace, IWorkspaceMember } from "types";
// helper
import { API_BASE_URL } from "helpers/common.helper";
export const requiredAuth = async (cookie?: string) => { export const requiredAuth = async (cookie?: string) => {
const cookies = convertCookieStringToObject(cookie); const cookies = convertCookieStringToObject(cookie);
@ -9,12 +11,10 @@ export const requiredAuth = async (cookie?: string) => {
if (!token) return null; if (!token) return null;
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
let user: IUser | null = null; let user: IUser | null = null;
try { try {
const data = await fetch(`${baseUrl}/api/users/me/`, { const data = await fetch(`${API_BASE_URL}/api/users/me/`, {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -41,13 +41,11 @@ export const requiredAdmin = async (workspaceSlug: string, projectId: string, co
const cookies = convertCookieStringToObject(cookie); const cookies = convertCookieStringToObject(cookie);
const token = cookies?.accessToken; const token = cookies?.accessToken;
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
let memberDetail: IProjectMember | null = null; let memberDetail: IProjectMember | null = null;
try { try {
const data = await fetch( const data = await fetch(
`${baseUrl}/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`, `${API_BASE_URL}/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`,
{ {
method: "GET", method: "GET",
headers: { headers: {
@ -75,17 +73,18 @@ export const requiredWorkspaceAdmin = async (workspaceSlug: string, cookie?: str
const cookies = convertCookieStringToObject(cookie); const cookies = convertCookieStringToObject(cookie);
const token = cookies?.accessToken; const token = cookies?.accessToken;
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
let memberDetail: IWorkspaceMember | null = null; let memberDetail: IWorkspaceMember | null = null;
try { try {
const data = await fetch(`${baseUrl}/api/workspaces/${workspaceSlug}/workspace-members/me/`, { const data = await fetch(
method: "GET", `${API_BASE_URL}/api/workspaces/${workspaceSlug}/workspace-members/me/`,
headers: { {
Authorization: `Bearer ${token}`, method: "GET",
}, headers: {
}) Authorization: `Bearer ${token}`,
},
}
)
.then((res) => res.json()) .then((res) => res.json())
.then((data) => data); .then((data) => data);
@ -119,13 +118,11 @@ export const homePageRedirect = async (cookie?: string) => {
let workspaces: IWorkspace[] = []; let workspaces: IWorkspace[] = [];
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
const cookies = convertCookieStringToObject(cookie); const cookies = convertCookieStringToObject(cookie);
const token = cookies?.accessToken; const token = cookies?.accessToken;
try { try {
const data = await fetch(`${baseUrl}/api/users/me/workspaces/`, { const data = await fetch(`${API_BASE_URL}/api/users/me/workspaces/`, {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -166,7 +163,7 @@ export const homePageRedirect = async (cookie?: string) => {
}; };
} }
const invitations = await fetch(`${baseUrl}/api/users/me/invitations/workspaces/`, { const invitations = await fetch(`${API_BASE_URL}/api/users/me/invitations/workspaces/`, {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@ -57,7 +57,7 @@
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lowlight": "^2.9.0", "lowlight": "^2.9.0",
"lucide-react": "^0.263.1", "lucide-react": "^0.269.0",
"mobx": "^6.10.0", "mobx": "^6.10.0",
"mobx-react-lite": "^4.0.3", "mobx-react-lite": "^4.0.3",
"next": "12.3.2", "next": "12.3.2",

View File

@ -13,8 +13,8 @@ import useUserAuth from "hooks/use-user-auth";
import useProjectDetails from "hooks/use-project-details"; import useProjectDetails from "hooks/use-project-details";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { SettingsHeader } from "components/project";
import { AutoArchiveAutomation, AutoCloseAutomation } from "components/automation"; import { AutoArchiveAutomation, AutoCloseAutomation } from "components/automation";
import { SettingsSidebar } from "components/project";
// ui // ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types // types
@ -75,11 +75,16 @@ const AutomationsSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="p-8"> <div className="flex flex-row gap-2">
<SettingsHeader /> <div className="w-80 py-8">
<section className="space-y-5"> <SettingsSidebar />
<AutoCloseAutomation projectDetails={projectDetails} handleChange={handleChange} /> </div>
<section className="pr-9 py-8 w-full">
<div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Automations</h3>
</div>
<AutoArchiveAutomation projectDetails={projectDetails} handleChange={handleChange} /> <AutoArchiveAutomation projectDetails={projectDetails} handleChange={handleChange} />
<AutoCloseAutomation projectDetails={projectDetails} handleChange={handleChange} />
</section> </section>
</div> </div>
</ProjectAuthorizationWrapper> </ProjectAuthorizationWrapper>

View File

@ -1,241 +0,0 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// services
import projectService from "services/project.service";
// hooks
import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth";
// components
import { SettingsHeader } from "components/project";
// ui
import { CustomSelect, Loader, SecondaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types
import { IProject, IUserLite, IWorkspace } from "types";
import type { NextPage } from "next";
// fetch-keys
import { PROJECTS_LIST, PROJECT_DETAILS, PROJECT_MEMBERS } from "constants/fetch-keys";
// helper
import { truncateText } from "helpers/string.helper";
const defaultValues: Partial<IProject> = {
project_lead: null,
default_assignee: null,
};
const ControlSettings: NextPage = () => {
const { setToastAlert } = useToast();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { user } = useUserAuth();
const { data: projectDetails } = useSWR<IProject>(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const { data: people } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const {
handleSubmit,
reset,
control,
formState: { isSubmitting },
} = useForm<IProject>({ defaultValues });
const onSubmit = async (formData: IProject) => {
if (!workspaceSlug || !projectId || !projectDetails) return;
const payload: Partial<IProject> = {
default_assignee: formData.default_assignee,
project_lead: formData.project_lead,
};
await projectService
.updateProject(workspaceSlug as string, projectId as string, payload, user)
.then((res) => {
mutate(PROJECT_DETAILS(projectId as string));
mutate(
PROJECTS_LIST(workspaceSlug as string, {
is_favorite: "all",
})
);
setToastAlert({
title: "Success",
type: "success",
message: "Project updated successfully",
});
})
.catch((err) => {
console.log(err);
});
};
useEffect(() => {
if (projectDetails)
reset({
...projectDetails,
default_assignee: projectDetails.default_assignee?.id ?? projectDetails.default_assignee,
project_lead: (projectDetails.project_lead as IUserLite)?.id ?? projectDetails.project_lead,
workspace: (projectDetails.workspace as IWorkspace).id,
});
}, [projectDetails, reset]);
return (
<ProjectAuthorizationWrapper
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${truncateText(projectDetails?.name ?? "Project", 32)}`}
link={`/${workspaceSlug}/projects/${projectId}/issues`}
linkTruncate
/>
<BreadcrumbItem title="Control Settings" unshrinkTitle />
</Breadcrumbs>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="p-8">
<SettingsHeader />
<div className="space-y-8 sm:space-y-12">
<div className="grid grid-cols-12 items-start gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Project Lead</h4>
<p className="text-sm text-custom-text-200">Select the project leader.</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<Controller
name="project_lead"
control={control}
render={({ field }) => (
<CustomSelect
{...field}
label={
people?.find((person) => person.member.id === field.value)?.member
.display_name ?? <span className="text-custom-text-200">Select lead</span>
}
width="w-full"
input
>
{people?.map((person) => (
<CustomSelect.Option
key={person.member.id}
value={person.member.id}
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
{person.member.avatar && person.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<img
src={person.member.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded-full"
alt="User Avatar"
/>
</div>
) : (
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 capitalize text-white">
{person.member.display_name?.charAt(0)}
</div>
)}
{person.member.display_name}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 items-start gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Default Assignee</h4>
<p className="text-sm text-custom-text-200">
Select the default assignee for the project.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<Controller
name="default_assignee"
control={control}
render={({ field }) => (
<CustomSelect
{...field}
label={
people?.find((p) => p.member.id === field.value)?.member.display_name ?? (
<span className="text-custom-text-200">Select default assignee</span>
)
}
width="w-full"
input
>
{people?.map((person) => (
<CustomSelect.Option
key={person.member.id}
value={person.member.id}
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
{person.member.avatar && person.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<img
src={person.member.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded-full"
alt="User Avatar"
/>
</div>
) : (
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 capitalize text-white">
{person.member.display_name?.charAt(0)}
</div>
)}
{person.member.display_name}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
</div>
<div className="sm:text-right">
<SecondaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</SecondaryButton>
</div>
</div>
</form>
</ProjectAuthorizationWrapper>
);
};
export default ControlSettings;

View File

@ -13,12 +13,12 @@ import useProjectDetails from "hooks/use-project-details";
import { ProjectAuthorizationWrapper } from "layouts/auth-layout"; import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// components // components
import { CreateUpdateEstimateModal, SingleEstimate } from "components/estimates"; import { CreateUpdateEstimateModal, SingleEstimate } from "components/estimates";
import { SettingsHeader } from "components/project"; import { SettingsSidebar } from "components/project";
//hooks //hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
// ui // ui
import { EmptyState, Loader, SecondaryButton } from "components/ui"; import { EmptyState, Loader, PrimaryButton, SecondaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
import { PlusIcon } from "@heroicons/react/24/outline"; import { PlusIcon } from "@heroicons/react/24/outline";
@ -125,66 +125,68 @@ const EstimatesSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="h-full flex flex-col p-8 overflow-hidden"> <div className="flex flex-row gap-2 h-full overflow-hidden">
<SettingsHeader /> <div className="w-80 py-8">
<section className="flex items-center justify-between"> <SettingsSidebar />
<h3 className="text-2xl font-semibold">Estimates</h3> </div>
<div className="col-span-12 space-y-5 sm:col-span-7"> <div className="pr-9 py-8 flex flex-col w-full">
<div className="flex items-center gap-2"> <section className="flex items-center justify-between pt-2 pb-3.5 border-b border-custom-border-200">
<div <h3 className="text-xl font-medium">Estimates</h3>
className="flex cursor-pointer items-center gap-2 text-custom-primary-100 hover:text-custom-primary-200" <div className="col-span-12 space-y-5 sm:col-span-7">
onClick={() => { <div className="flex items-center gap-2">
setEstimateToUpdate(undefined); <PrimaryButton
setEstimateFormOpen(true); onClick={() => {
}}
>
<PlusIcon className="h-4 w-4" />
Create New Estimate
</div>
{projectDetails?.estimate && (
<SecondaryButton onClick={disableEstimates}>Disable Estimates</SecondaryButton>
)}
</div>
</div>
</section>
{estimatesList ? (
estimatesList.length > 0 ? (
<section className="h-full mt-5 divide-y divide-custom-border-200 rounded-xl border border-custom-border-200 bg-custom-background-100 px-6 overflow-y-auto">
{estimatesList.map((estimate) => (
<SingleEstimate
key={estimate.id}
estimate={estimate}
editEstimate={(estimate) => editEstimate(estimate)}
handleEstimateDelete={(estimateId) => removeEstimate(estimateId)}
user={user}
/>
))}
</section>
) : (
<div className="h-full w-full overflow-y-auto">
<EmptyState
title="No estimates yet"
description="Estimates help you communicate the complexity of an issue."
image={emptyEstimate}
primaryButton={{
icon: <PlusIcon className="h-4 w-4" />,
text: "Add Estimate",
onClick: () => {
setEstimateToUpdate(undefined); setEstimateToUpdate(undefined);
setEstimateFormOpen(true); setEstimateFormOpen(true);
}, }}
}} >
/> Add Estimate
</PrimaryButton>
{projectDetails?.estimate && (
<SecondaryButton onClick={disableEstimates}>Disable Estimates</SecondaryButton>
)}
</div>
</div> </div>
) </section>
) : ( {estimatesList ? (
<Loader className="mt-5 space-y-5"> estimatesList.length > 0 ? (
<Loader.Item height="40px" /> <section className="h-full bg-custom-background-100 overflow-y-auto">
<Loader.Item height="40px" /> {estimatesList.map((estimate) => (
<Loader.Item height="40px" /> <SingleEstimate
<Loader.Item height="40px" /> key={estimate.id}
</Loader> estimate={estimate}
)} editEstimate={(estimate) => editEstimate(estimate)}
handleEstimateDelete={(estimateId) => removeEstimate(estimateId)}
user={user}
/>
))}
</section>
) : (
<div className="h-full w-full overflow-y-auto">
<EmptyState
title="No estimates yet"
description="Estimates help you communicate the complexity of an issue."
image={emptyEstimate}
primaryButton={{
icon: <PlusIcon className="h-4 w-4" />,
text: "Add Estimate",
onClick: () => {
setEstimateToUpdate(undefined);
setEstimateFormOpen(true);
},
}}
/>
</div>
)
) : (
<Loader className="mt-5 space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</div>
</div> </div>
</ProjectAuthorizationWrapper> </ProjectAuthorizationWrapper>
</> </>

View File

@ -13,13 +13,13 @@ import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
// components // components
import { SettingsHeader } from "components/project"; import { SettingsSidebar } from "components/project";
// ui // ui
import { SecondaryButton, ToggleSwitch } from "components/ui"; import { ToggleSwitch } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
import { ContrastIcon, PeopleGroupIcon, ViewListIcon, InboxIcon } from "components/icons"; import { ModuleIcon } from "components/icons";
import { DocumentTextIcon } from "@heroicons/react/24/outline"; import { Contrast, FileText, Inbox, Layers } from "lucide-react";
// types // types
import { IProject } from "types"; import { IProject } from "types";
import type { NextPage } from "next"; import type { NextPage } from "next";
@ -33,35 +33,35 @@ const featuresList = [
title: "Cycles", title: "Cycles",
description: description:
"Cycles are enabled for all the projects in this workspace. Access them from the sidebar.", "Cycles are enabled for all the projects in this workspace. Access them from the sidebar.",
icon: <ContrastIcon color="#3f76ff" width={28} height={28} className="flex-shrink-0" />, icon: <Contrast className="h-4 w-4 text-custom-primary-100 flex-shrink-0" />,
property: "cycle_view", property: "cycle_view",
}, },
{ {
title: "Modules", title: "Modules",
description: description:
"Modules are enabled for all the projects in this workspace. Access it from the sidebar.", "Modules are enabled for all the projects in this workspace. Access it from the sidebar.",
icon: <PeopleGroupIcon color="#ff6b00" width={28} height={28} className="flex-shrink-0" />, icon: <ModuleIcon width={16} height={16} className="flex-shrink-0" />,
property: "module_view", property: "module_view",
}, },
{ {
title: "Views", title: "Views",
description: description:
"Views are enabled for all the projects in this workspace. Access it from the sidebar.", "Views are enabled for all the projects in this workspace. Access it from the sidebar.",
icon: <ViewListIcon color="#05c3ff" width={28} height={28} className="flex-shrink-0" />, icon: <Layers className="h-4 w-4 text-cyan-500 flex-shrink-0" />,
property: "issue_views_view", property: "issue_views_view",
}, },
{ {
title: "Pages", title: "Pages",
description: description:
"Pages are enabled for all the projects in this workspace. Access it from the sidebar.", "Pages are enabled for all the projects in this workspace. Access it from the sidebar.",
icon: <DocumentTextIcon color="#fcbe1d" width={28} height={28} className="flex-shrink-0" />, icon: <FileText className="h-4 w-4 text-red-400 flex-shrink-0" />,
property: "page_view", property: "page_view",
}, },
{ {
title: "Inbox", title: "Inbox",
description: description:
"Inbox are enabled for all the projects in this workspace. Access it from the issues views page.", "Inbox are enabled for all the projects in this workspace. Access it from the issues views page.",
icon: <InboxIcon color="#fcbe1d" width={24} height={24} className="flex-shrink-0" />, icon: <Inbox className="h-4 w-4 text-cyan-500 flex-shrink-0" />,
property: "inbox_view", property: "inbox_view",
}, },
]; ];
@ -149,21 +149,29 @@ const FeaturesSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="p-8"> <div className="flex flex-row gap-2">
<SettingsHeader /> <div className="w-80 py-8">
<section className="space-y-5"> <SettingsSidebar />
<h3 className="text-2xl font-semibold">Features</h3> </div>
<div className="space-y-5"> <section className="pr-9 py-8 w-full">
<div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Features</h3>
</div>
<div>
{featuresList.map((feature) => ( {featuresList.map((feature) => (
<div <div
key={feature.property} key={feature.property}
className="flex items-center justify-between gap-x-8 gap-y-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5" className="flex items-center justify-between gap-x-8 gap-y-2 border-b border-custom-border-200 bg-custom-background-100 p-4"
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{feature.icon} <div className="flex items-center justify-center p-3 rounded bg-custom-background-90">
{feature.icon}
</div>
<div className=""> <div className="">
<h4 className="text-lg font-semibold">{feature.title}</h4> <h4 className="text-sm font-medium">{feature.title}</h4>
<p className="text-sm text-custom-text-200">{feature.description}</p> <p className="text-sm text-custom-text-200 tracking-tight">
{feature.description}
</p>
</div> </div>
</div> </div>
<ToggleSwitch <ToggleSwitch
@ -187,29 +195,11 @@ const FeaturesSettings: NextPage = () => {
[feature.property]: !projectDetails?.[feature.property as keyof IProject], [feature.property]: !projectDetails?.[feature.property as keyof IProject],
}); });
}} }}
size="lg" size="sm"
/> />
</div> </div>
))} ))}
</div> </div>
<div className="flex items-center gap-2 text-custom-text-200">
<a
href="https://plane.so/"
target="_blank"
rel="noreferrer"
className="hover:text-custom-text-100"
>
<SecondaryButton outline>Plane is open-source, view Roadmap</SecondaryButton>
</a>
<a
href="https://github.com/makeplane/plane"
target="_blank"
rel="noreferrer"
className="hover:text-custom-text-100"
>
<SecondaryButton outline>Star us on GitHub</SecondaryButton>
</a>
</div>
</section> </section>
</div> </div>
</ProjectAuthorizationWrapper> </ProjectAuthorizationWrapper>

View File

@ -4,6 +4,8 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// react-hook-form // react-hook-form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// layouts // layouts
@ -11,7 +13,7 @@ import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// services // services
import projectService from "services/project.service"; import projectService from "services/project.service";
// components // components
import { DeleteProjectModal, SettingsHeader } from "components/project"; import { DeleteProjectModal, SettingsSidebar } from "components/project";
import { ImagePickerPopover } from "components/core"; import { ImagePickerPopover } from "components/core";
import EmojiIconPicker from "components/emoji-icon-picker"; import EmojiIconPicker from "components/emoji-icon-picker";
// hooks // hooks
@ -25,11 +27,14 @@ import {
CustomSelect, CustomSelect,
SecondaryButton, SecondaryButton,
DangerButton, DangerButton,
Icon,
PrimaryButton,
} from "components/ui"; } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// helpers // helpers
import { renderEmoji } from "helpers/emoji.helper"; import { renderEmoji } from "helpers/emoji.helper";
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types // types
import { IProject, IWorkspace } from "types"; import { IProject, IWorkspace } from "types";
import type { NextPage } from "next"; import type { NextPage } from "next";
@ -185,229 +190,258 @@ const GeneralSettings: NextPage = () => {
onClose={() => setSelectedProject(null)} onClose={() => setSelectedProject(null)}
user={user} user={user}
/> />
<form onSubmit={handleSubmit(onSubmit)} className="p-8"> <form onSubmit={handleSubmit(onSubmit)}>
<SettingsHeader /> <div className="flex flex-row gap-2">
<div className={`space-y-8 sm:space-y-12 ${isAdmin ? "" : "opacity-60"}`}> <div className="w-80 py-8">
<div className="grid grid-cols-12 items-start gap-4 sm:gap-16"> <SettingsSidebar />
<div className="col-span-12 sm:col-span-6"> </div>
<h4 className="text-lg font-semibold">Icon & Name</h4> <div className={`pr-9 py-8 w-full ${isAdmin ? "" : "opacity-60"}`}>
<p className="text-sm text-custom-text-200"> <div className="relative h-44 w-full mt-6">
Select an icon and a name for your project. <img
</p> src={watch("cover_image")!}
</div> alt={watch("cover_image")!}
<div className="col-span-12 flex gap-2 sm:col-span-6"> className="h-44 w-full rounded-md object-cover"
{projectDetails ? ( />
<div className="h-7 w-7 grid place-items-center"> <div className="flex items-end justify-between absolute bottom-4 w-full px-4">
<Controller <div className="flex gap-3">
control={control} <div className="flex items-center justify-center bg-custom-background-90 h-[52px] w-[52px] rounded-lg">
name="emoji_and_icon" {projectDetails ? (
render={({ field: { value, onChange } }) => ( <div className="h-7 w-7 grid place-items-center">
<EmojiIconPicker <Controller
label={value ? renderEmoji(value) : "Icon"} control={control}
value={value} name="emoji_and_icon"
onChange={onChange} render={({ field: { value, onChange } }) => (
disabled={!isAdmin} <EmojiIconPicker
/> label={value ? renderEmoji(value) : "Icon"}
value={value}
onChange={onChange}
disabled={!isAdmin}
/>
)}
/>
</div>
) : (
<Loader>
<Loader.Item height="46px" width="46px" />
</Loader>
)} )}
/> </div>
</div> <div className="flex flex-col gap-1 text-white">
) : ( <span className="text-lg font-semibold">{watch("name")}</span>
<Loader> <span className="flex items-center gap-2 text-sm">
<Loader.Item height="46px" width="46px" /> <span>
</Loader> {watch("identifier")} . {currentNetwork?.label}
)} </span>
{projectDetails ? ( </span>
<Input
id="name"
name="name"
error={errors.name}
register={register}
placeholder="Project Name"
validations={{
required: "Name is required",
}}
disabled={!isAdmin}
/>
) : (
<Loader>
<Loader.Item height="46px" width="225px" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Description</h4>
<p className="text-sm text-custom-text-200">Give a description to your project.</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
placeholder="Enter project description"
validations={{}}
className="min-h-[46px] text-sm"
disabled={!isAdmin}
/>
) : (
<Loader className="w-full">
<Loader.Item height="46px" width="full" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Cover Photo</h4>
<p className="text-sm text-custom-text-200">
Select your cover photo from the given library.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
{watch("cover_image") ? (
<div className="h-32 w-full rounded border border-custom-border-200 p-1">
<div className="relative h-full w-full rounded">
<img
src={watch("cover_image")!}
className="absolute top-0 left-0 h-full w-full object-cover rounded"
alt={projectDetails?.name ?? "Cover image"}
/>
<div className="absolute bottom-0 flex w-full justify-end">
<ImagePickerPopover
label={"Change cover"}
onChange={(imageUrl) => {
setValue("cover_image", imageUrl);
}}
value={watch("cover_image")}
disabled={!isAdmin}
/>
</div>
</div> </div>
</div> </div>
) : (
<Loader className="w-full">
<Loader.Item height="46px" width="full" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Identifier</h4>
<p className="text-sm text-custom-text-200">
Create a 1-6 characters{"'"} identifier for the project.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<Input
id="identifier"
name="identifier"
error={errors.identifier}
register={register}
placeholder="Enter identifier"
onChange={handleIdentifierChange}
validations={{
required: "Identifier is required",
validate: (value) =>
/^[A-Z0-9]+$/.test(value.toUpperCase()) || "Identifier must be in uppercase.",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 5,
message: "Identifier must at most be of 5 characters",
},
}}
disabled={!isAdmin}
/>
) : (
<Loader>
<Loader.Item height="46px" width="160px" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Network</h4>
<p className="text-sm text-custom-text-200">Select privacy type for the project.</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<Controller
name="network"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={currentNetwork?.label ?? "Select network"}
input
disabled={!isAdmin}
>
{NETWORK_CHOICES.map((network) => (
<CustomSelect.Option key={network.key} value={network.key}>
{network.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<Loader className="w-full">
<Loader.Item height="46px" width="160px" />
</Loader>
)}
</div>
</div>
{isAdmin && ( <div className="flex justify-center">
<> {projectDetails ? (
<div className="sm:text-right"> <div>
<Controller
control={control}
name="cover_image"
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label={"Change cover"}
onChange={(imageUrl) => {
setValue("cover_image", imageUrl);
}}
value={watch("cover_image")}
disabled={!isAdmin}
/>
)}
/>
</div>
) : (
<Loader>
<Loader.Item height="32px" width="108px" />
</Loader>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-8 my-8">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Project Name</h4>
{projectDetails ? ( {projectDetails ? (
<SecondaryButton type="submit" loading={isSubmitting} disabled={!isAdmin}> <Input
{isSubmitting ? "Updating Project..." : "Update Project"} id="name"
</SecondaryButton> name="name"
error={errors.name}
register={register}
className="!p-3 rounded-md font-medium"
placeholder="Project Name"
validations={{
required: "Name is required",
}}
disabled={!isAdmin}
/>
) : (
<Loader>
<Loader.Item height="46px" width="100%" />
</Loader>
)}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Description</h4>
{projectDetails ? (
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
placeholder="Enter project description"
validations={{}}
className="min-h-[102px] text-sm"
disabled={!isAdmin}
/>
) : (
<Loader className="w-full">
<Loader.Item height="102px" width="full" />
</Loader>
)}
</div>
<div className="flex items-center justify-between gap-10 w-full">
<div className="flex flex-col gap-1 w-1/2">
<h4 className="text-sm">Identifier</h4>
{projectDetails ? (
<Input
id="identifier"
name="identifier"
error={errors.identifier}
register={register}
placeholder="Enter identifier"
onChange={handleIdentifierChange}
validations={{
required: "Identifier is required",
validate: (value) =>
/^[A-Z0-9]+$/.test(value.toUpperCase()) ||
"Identifier must be in uppercase.",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 5,
message: "Identifier must at most be of 5 characters",
},
}}
disabled={!isAdmin}
/>
) : (
<Loader>
<Loader.Item height="36px" width="100%" />
</Loader>
)}
</div>
<div className="flex flex-col gap-1 w-1/2">
<h4 className="text-sm">Network</h4>
{projectDetails ? (
<Controller
name="network"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={currentNetwork?.label ?? "Select network"}
className="!border-custom-border-200 !shadow-none"
input
disabled={!isAdmin}
>
{NETWORK_CHOICES.map((network) => (
<CustomSelect.Option key={network.key} value={network.key}>
{network.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<Loader className="w-full">
<Loader.Item height="46px" width="100%" />
</Loader>
)}
</div>
</div>
<div className="flex items-center justify-between py-2">
{projectDetails ? (
<>
<PrimaryButton type="submit" loading={isSubmitting} disabled={!isAdmin}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</PrimaryButton>
<span className="text-sm text-custom-sidebar-text-400 italic">
Created on {renderShortDateWithYearFormat(projectDetails?.created_at)}
</span>
</>
) : ( ) : (
<Loader className="mt-2 w-full"> <Loader className="mt-2 w-full">
<Loader.Item height="34px" width="100px" /> <Loader.Item height="34px" width="100px" />
</Loader> </Loader>
)} )}
</div> </div>
<div className="grid grid-cols-12 gap-4 sm:gap-16"> </div>
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Danger Zone</h4> <Disclosure as="div" className="border-t border-custom-border-400">
<p className="text-sm text-custom-text-200"> {({ open }) => (
The danger zone of the project delete page is a critical area that requires <div className="w-full">
careful consideration and attention. When deleting a project, all of the data <Disclosure.Button
and resources within that project will be permanently removed and cannot be as="button"
recovered. type="button"
</p> className="flex items-center justify-between w-full py-4"
>
<span className="text-xl tracking-tight">Danger Zone</span>
<Icon iconName={open ? "expand_more" : "expand_less"} className="!text-2xl" />
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="flex flex-col gap-8">
<span className="text-sm tracking-tight">
The danger zone of the project delete page is a critical area that
requires careful consideration and attention. When deleting a project, all
of the data and resources within that project will be permanently removed
and cannot be recovered.
</span>
<div>
{projectDetails ? (
<div>
<DangerButton
onClick={() => setSelectedProject(projectDetails.id ?? null)}
className="!text-sm"
outline
>
Delete my project
</DangerButton>
</div>
) : (
<Loader className="mt-2 w-full">
<Loader.Item height="38px" width="144px" />
</Loader>
)}
</div>
</div>
</Disclosure.Panel>
</Transition>
</div> </div>
<div className="col-span-12 sm:col-span-6"> )}
{projectDetails ? ( </Disclosure>
<div> </div>
<DangerButton
onClick={() => setSelectedProject(projectDetails.id ?? null)}
outline
>
Delete Project
</DangerButton>
</div>
) : (
<Loader className="mt-2 w-full">
<Loader.Item height="46px" width="100px" />
</Loader>
)}
</div>
</div>
</>
)}
</div> </div>
</form> </form>
</ProjectAuthorizationWrapper> </ProjectAuthorizationWrapper>

View File

@ -10,7 +10,7 @@ import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
import IntegrationService from "services/integration"; import IntegrationService from "services/integration";
import projectService from "services/project.service"; import projectService from "services/project.service";
// components // components
import { SettingsHeader, SingleIntegration } from "components/project"; import { SettingsSidebar, SingleIntegration } from "components/project";
// ui // ui
import { EmptyState, IntegrationAndImportExportBanner, Loader } from "components/ui"; import { EmptyState, IntegrationAndImportExportBanner, Loader } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
@ -58,13 +58,15 @@ const ProjectIntegrations: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="h-full flex flex-col p-8 overflow-hidden"> <div className="flex flex-row gap-2 h-full overflow-hidden">
<SettingsHeader /> <div className="w-80 py-8">
<SettingsSidebar />
</div>
{workspaceIntegrations ? ( {workspaceIntegrations ? (
workspaceIntegrations.length > 0 ? ( workspaceIntegrations.length > 0 ? (
<section className="space-y-8 overflow-y-auto"> <section className="pr-9 py-8 overflow-y-auto w-full">
<IntegrationAndImportExportBanner bannerName="Integrations" /> <IntegrationAndImportExportBanner bannerName="Integrations" />
<div className="space-y-5"> <div>
{workspaceIntegrations.map((integration) => ( {workspaceIntegrations.map((integration) => (
<SingleIntegration <SingleIntegration
key={integration.integration_detail.id} key={integration.integration_detail.id}

View File

@ -19,7 +19,7 @@ import {
SingleLabel, SingleLabel,
SingleLabelGroup, SingleLabelGroup,
} from "components/labels"; } from "components/labels";
import { SettingsHeader } from "components/project"; import { SettingsSidebar } from "components/project";
// ui // ui
import { EmptyState, Loader, PrimaryButton } from "components/ui"; import { EmptyState, Loader, PrimaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
@ -113,20 +113,23 @@ const LabelsSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="p-8"> <div className="flex flex-row gap-2">
<SettingsHeader /> <div className="w-80 py-8">
<section className="grid grid-cols-12 gap-10"> <SettingsSidebar />
<div className="col-span-12 sm:col-span-5"> </div>
<h3 className="text-2xl font-semibold">Labels</h3> <section className="pr-9 py-8 gap-10 w-full">
<p className="text-custom-text-200">Manage the labels of this project.</p> <div className="flex items-center justify-between pt-2 pb-3.5 border-b border-custom-border-200">
<PrimaryButton onClick={newLabel} size="sm" className="mt-4"> <h3 className="text-xl font-medium">Labels</h3>
<span className="flex items-center gap-2">
<PlusIcon className="h-4 w-4" /> <PrimaryButton
New label onClick={newLabel}
</span> size="sm"
className="flex items-center justify-center"
>
Add label
</PrimaryButton> </PrimaryButton>
</div> </div>
<div className="col-span-12 space-y-5 sm:col-span-7"> <div className="space-y-3 py-6">
{labelForm && ( {labelForm && (
<CreateUpdateLabelInline <CreateUpdateLabelInline
labelForm={labelForm} labelForm={labelForm}

View File

@ -1,9 +1,9 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
import useSWR from "swr"; import useSWR, { mutate } from "swr";
// services // services
import projectService from "services/project.service"; import projectService from "services/project.service";
@ -13,22 +13,35 @@ import useToast from "hooks/use-toast";
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
import useProjectMembers from "hooks/use-project-members"; import useProjectMembers from "hooks/use-project-members";
import useProjectDetails from "hooks/use-project-details"; import useProjectDetails from "hooks/use-project-details";
import { Controller, useForm } from "react-hook-form";
// layouts // layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout"; import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// components // components
import ConfirmProjectMemberRemove from "components/project/confirm-project-member-remove"; import ConfirmProjectMemberRemove from "components/project/confirm-project-member-remove";
import SendProjectInvitationModal from "components/project/send-project-invitation-modal"; import SendProjectInvitationModal from "components/project/send-project-invitation-modal";
import { SettingsHeader } from "components/project"; import { MemberSelect, SettingsSidebar } from "components/project";
// ui // ui
import { CustomMenu, CustomSelect, Loader } from "components/ui"; import {
CustomMenu,
CustomSearchSelect,
CustomSelect,
Icon,
Loader,
PrimaryButton,
SecondaryButton,
} from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline";
// types // types
import type { NextPage } from "next"; import type { NextPage } from "next";
import { IProject, IUserLite, IWorkspace } from "types";
// fetch-keys // fetch-keys
import { import {
PROJECTS_LIST,
PROJECT_DETAILS,
PROJECT_INVITATIONS_WITH_EMAIL, PROJECT_INVITATIONS_WITH_EMAIL,
PROJECT_MEMBERS,
PROJECT_MEMBERS_WITH_EMAIL, PROJECT_MEMBERS_WITH_EMAIL,
WORKSPACE_DETAILS, WORKSPACE_DETAILS,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
@ -37,6 +50,11 @@ import { ROLE } from "constants/workspace";
// helper // helper
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
const defaultValues: Partial<IProject> = {
project_lead: null,
default_assignee: null,
};
const MembersSettings: NextPage = () => { const MembersSettings: NextPage = () => {
const [inviteModal, setInviteModal] = useState(false); const [inviteModal, setInviteModal] = useState(false);
const [selectedRemoveMember, setSelectedRemoveMember] = useState<string | null>(null); const [selectedRemoveMember, setSelectedRemoveMember] = useState<string | null>(null);
@ -55,11 +73,25 @@ const MembersSettings: NextPage = () => {
Boolean(workspaceSlug && projectId) Boolean(workspaceSlug && projectId)
); );
const {
handleSubmit,
reset,
control,
formState: { isSubmitting },
} = useForm<IProject>({ defaultValues });
const { data: activeWorkspace } = useSWR( const { data: activeWorkspace } = useSWR(
workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null, workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null,
() => (workspaceSlug ? workspaceService.getWorkspace(workspaceSlug as string) : null) () => (workspaceSlug ? workspaceService.getWorkspace(workspaceSlug as string) : null)
); );
const { data: people } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const { data: projectMembers, mutate: mutateMembers } = useSWR( const { data: projectMembers, mutate: mutateMembers } = useSWR(
workspaceSlug && projectId workspaceSlug && projectId
? PROJECT_MEMBERS_WITH_EMAIL(workspaceSlug.toString(), projectId.toString()) ? PROJECT_MEMBERS_WITH_EMAIL(workspaceSlug.toString(), projectId.toString())
@ -110,6 +142,76 @@ const MembersSettings: NextPage = () => {
const handleProjectInvitationSuccess = () => {}; const handleProjectInvitationSuccess = () => {};
const onSubmit = async (formData: IProject) => {
if (!workspaceSlug || !projectId || !projectDetails) return;
const payload: Partial<IProject> = {
default_assignee: formData.default_assignee,
project_lead: formData.project_lead === "none" ? null : formData.project_lead,
};
await projectService
.updateProject(workspaceSlug as string, projectId as string, payload, user)
.then((res) => {
mutate(PROJECT_DETAILS(projectId as string));
mutate(
PROJECTS_LIST(workspaceSlug as string, {
is_favorite: "all",
})
);
setToastAlert({
title: "Success",
type: "success",
message: "Project updated successfully",
});
})
.catch((err) => {
console.log(err);
});
};
useEffect(() => {
if (projectDetails)
reset({
...projectDetails,
default_assignee: projectDetails.default_assignee?.id ?? projectDetails.default_assignee,
project_lead: (projectDetails.project_lead as IUserLite)?.id ?? projectDetails.project_lead,
workspace: (projectDetails.workspace as IWorkspace).id,
});
}, [projectDetails, reset]);
const submitChanges = async (formData: Partial<IProject>) => {
if (!workspaceSlug || !projectId) return;
const payload: Partial<IProject> = {
default_assignee: formData.default_assignee === "none" ? null : formData.default_assignee,
project_lead: formData.project_lead === "none" ? null : formData.project_lead,
};
await projectService
.updateProject(workspaceSlug as string, projectId as string, payload, user)
.then((res) => {
mutate(PROJECT_DETAILS(projectId as string));
mutate(
PROJECTS_LIST(workspaceSlug as string, {
is_favorite: "all",
})
);
setToastAlert({
title: "Success",
type: "success",
message: "Project updated successfully",
});
})
.catch((err) => {
console.log(err);
});
};
return ( return (
<ProjectAuthorizationWrapper <ProjectAuthorizationWrapper
breadcrumbs={ breadcrumbs={
@ -171,19 +273,69 @@ const MembersSettings: NextPage = () => {
user={user} user={user}
onSuccess={() => mutateMembers()} onSuccess={() => mutateMembers()}
/> />
<div className="p-8"> <div className="flex flex-row gap-2">
<SettingsHeader /> <div className="w-80 py-8">
<section className="space-y-5"> <SettingsSidebar />
<div className="flex items-end justify-between gap-4"> </div>
<h3 className="text-2xl font-semibold">Members</h3> <section className="pr-9 py-8 w-full">
<button <div className="flex items-center py-3.5 border-b border-custom-border-200">
type="button" <h3 className="text-xl font-medium">Defaults</h3>
className="flex items-center gap-2 text-custom-primary outline-none" </div>
onClick={() => setInviteModal(true)} <div className="flex flex-col gap-2 pb-4 w-full">
> <div className="flex items-center py-8 gap-4 w-full">
<PlusIcon className="h-4 w-4" /> <div className="flex flex-col gap-2 w-1/2">
Add Member <h4 className="text-sm">Project Lead</h4>
</button> <div className="">
{projectDetails ? (
<Controller
control={control}
name="project_lead"
render={({ field: { value } }) => (
<MemberSelect
value={value}
onChange={(val: string) => {
submitChanges({ project_lead: val });
}}
/>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
</div>
<div className="flex flex-col gap-2 w-1/2">
<h4 className="text-sm">Default Assignee</h4>
<div className="">
{projectDetails ? (
<Controller
control={control}
name="default_assignee"
render={({ field: { value } }) => (
<MemberSelect
value={value}
onChange={(val: string) => {
submitChanges({ default_assignee: val });
}}
/>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-4 py-3.5 border-b border-custom-border-200">
<h4 className="text-xl font-medium border-b border-custom-border-100">Members</h4>
<PrimaryButton onClick={() => setInviteModal(true)}>Add Member</PrimaryButton>
</div> </div>
{!projectMembers || !projectInvitations ? ( {!projectMembers || !projectInvitations ? (
<Loader className="space-y-5"> <Loader className="space-y-5">
@ -193,10 +345,13 @@ const MembersSettings: NextPage = () => {
<Loader.Item height="40px" /> <Loader.Item height="40px" />
</Loader> </Loader>
) : ( ) : (
<div className="divide-y divide-custom-border-200 rounded-[10px] border border-custom-border-200 bg-custom-background-100 px-6"> <div className="divide-y divide-custom-border-200">
{members.length > 0 {members.length > 0
? members.map((member) => ( ? members.map((member) => (
<div key={member.id} className="flex items-center justify-between py-6"> <div
key={member.id}
className="flex items-center justify-between px-3.5 py-[18px]"
>
<div className="flex items-center gap-x-6 gap-y-2"> <div className="flex items-center gap-x-6 gap-y-2">
{member.avatar && member.avatar !== "" ? ( {member.avatar && member.avatar !== "" ? (
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize text-white"> <div className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize text-white">
@ -242,7 +397,20 @@ const MembersSettings: NextPage = () => {
</div> </div>
)} )}
<CustomSelect <CustomSelect
label={ROLE[member.role as keyof typeof ROLE]} customButton={
<button className="flex item-center gap-1">
<span
className={`flex items-center text-sm font-medium ${
member.memberId !== user?.id ? "" : "text-custom-sidebar-text-400"
}`}
>
{ROLE[member.role as keyof typeof ROLE]}
</span>
{member.memberId !== user?.id && (
<Icon iconName="expand_more" className="text-lg font-medium" />
)}
</button>
}
value={member.role} value={member.role}
onChange={(value: 5 | 10 | 15 | 20 | undefined) => { onChange={(value: 5 | 10 | 15 | 20 | undefined) => {
if (!activeWorkspace || !projectDetails) return; if (!activeWorkspace || !projectDetails) return;
@ -306,7 +474,11 @@ const MembersSettings: NextPage = () => {
> >
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" /> <XMarkIcon className="h-4 w-4" />
<span>Remove member</span>
<span>
{" "}
{member.memberId !== user?.id ? "Remove member" : "Leave project"}
</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
</CustomMenu> </CustomMenu>

View File

@ -18,7 +18,7 @@ import {
SingleState, SingleState,
StateGroup, StateGroup,
} from "components/states"; } from "components/states";
import { SettingsHeader } from "components/project"; import { SettingsSidebar } from "components/project";
// ui // ui
import { Loader } from "components/ui"; import { Loader } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
@ -73,31 +73,33 @@ const StatesSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="p-8"> <div className="flex flex-row gap-2">
<SettingsHeader /> <div className="w-80 py-8">
<div className="grid grid-cols-12 gap-10"> <SettingsSidebar />
<div className="col-span-12 sm:col-span-5"> </div>
<h3 className="text-2xl font-semibold text-custom-text-100">States</h3> <div className="pr-9 py-8 gap-10 w-full">
<p className="text-custom-text-200">Manage the states of this project.</p> <div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">States</h3>
</div> </div>
<div className="col-span-12 space-y-8 sm:col-span-7"> <div className="space-y-8 py-6">
{states && projectDetails && orderedStateGroups ? ( {states && projectDetails && orderedStateGroups ? (
Object.keys(orderedStateGroups).map((key) => { Object.keys(orderedStateGroups).map((key) => {
if (orderedStateGroups[key].length !== 0) if (orderedStateGroups[key].length !== 0)
return ( return (
<div key={key}> <div key={key} className="flex flex-col gap-2">
<div className="mb-2 flex w-full justify-between"> <div className="flex w-full justify-between">
<h4 className="text-custom-text-200 capitalize">{key}</h4> <h4 className="text-base font-medium text-custom-text-200 capitalize">
{key}
</h4>
<button <button
type="button" type="button"
className="flex items-center gap-2 text-custom-primary-100 hover:text-custom-primary-200 outline-none" className="flex items-center gap-2 text-custom-primary-100 px-2 hover:text-custom-primary-200 outline-none"
onClick={() => setActiveGroup(key as keyof StateGroup)} onClick={() => setActiveGroup(key as keyof StateGroup)}
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Add
</button> </button>
</div> </div>
<div className="divide-y divide-custom-border-200 rounded-[10px] border border-custom-border-200"> <div className="flex flex-col gap-2 rounded">
{key === activeGroup && ( {key === activeGroup && (
<CreateUpdateStateInline <CreateUpdateStateInline
groupLength={orderedStateGroups[key].length} groupLength={orderedStateGroups[key].length}

View File

@ -1,18 +1,16 @@
// services
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
// types // types
import { ICurrentUserResponse, IGptResponse } from "types"; import { ICurrentUserResponse, IGptResponse } from "types";
// helpers
const { NEXT_PUBLIC_API_BASE_URL } = process.env; import { API_BASE_URL } from "helpers/common.helper";
const trackEvent = const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class AiServices extends APIService { class AiServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async createGptTask( async createGptTask(

View File

@ -8,12 +8,11 @@ import {
IExportAnalyticsFormData, IExportAnalyticsFormData,
ISaveAnalyticsFormData, ISaveAnalyticsFormData,
} from "types"; } from "types";
import { API_BASE_URL } from "helpers/common.helper";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
class AnalyticsServices extends APIService { class AnalyticsServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async getAnalytics(workspaceSlug: string, params: IAnalyticsParams): Promise<IAnalyticsResponse> { async getAnalytics(workspaceSlug: string, params: IAnalyticsParams): Promise<IAnalyticsResponse> {

View File

@ -1,12 +1,11 @@
// services // services
import axios from "axios"; import axios from "axios";
import APIService from "services/api.service"; import APIService from "services/api.service";
import { API_BASE_URL } from "helpers/common.helper";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
class AppInstallationsService extends APIService { class AppInstallationsService extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async addInstallationApp(workspaceSlug: string, provider: string, data: any): Promise<any> { async addInstallationApp(workspaceSlug: string, provider: string, data: any): Promise<any> {

View File

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

View File

@ -1,18 +1,16 @@
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
// types // types
import type { CycleDateCheckData, ICurrentUserResponse, ICycle, IIssue } from "types"; import type { CycleDateCheckData, ICurrentUserResponse, ICycle, IIssue } from "types";
import { API_BASE_URL } from "helpers/common.helper";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent = const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class ProjectCycleServices extends APIService { class ProjectCycleServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async createCycle( async createCycle(

View File

@ -3,15 +3,14 @@ import APIService from "services/api.service";
// types // types
import type { ICurrentUserResponse, IEstimate, IEstimateFormData } from "types"; import type { ICurrentUserResponse, IEstimate, IEstimateFormData } from "types";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
import { API_BASE_URL } from "helpers/common.helper";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent = const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class ProjectEstimateServices extends APIService { class ProjectEstimateServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async createEstimate( async createEstimate(

View File

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

View File

@ -1,7 +1,6 @@
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
import { API_BASE_URL } from "helpers/common.helper";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent = const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
@ -19,7 +18,7 @@ import type {
class InboxServices extends APIService { class InboxServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async getInboxes(workspaceSlug: string, projectId: string): Promise<IInbox[]> { async getInboxes(workspaceSlug: string, projectId: string): Promise<IInbox[]> {

View File

@ -1,16 +1,14 @@
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
import { ICurrentUserResponse } from "types"; import { ICurrentUserResponse } from "types";
import { API_BASE_URL } from "helpers/common.helper";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent = const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class CSVIntegrationService extends APIService { class CSVIntegrationService extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async exportCSVService( async exportCSVService(

View File

@ -1,5 +1,6 @@
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
import { API_BASE_URL } from "helpers/common.helper";
import { ICurrentUserResponse, IGithubRepoInfo, IGithubServiceImportFormData } from "types"; import { ICurrentUserResponse, IGithubRepoInfo, IGithubServiceImportFormData } from "types";
@ -11,7 +12,7 @@ const trackEvent =
const integrationServiceType: string = "github"; const integrationServiceType: string = "github";
class GithubIntegrationService extends APIService { class GithubIntegrationService extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async listAllRepositories(workspaceSlug: string, integrationSlug: string): Promise<any> { async listAllRepositories(workspaceSlug: string, integrationSlug: string): Promise<any> {

View File

@ -9,15 +9,14 @@ import {
IWorkspaceIntegration, IWorkspaceIntegration,
IExportServiceResponse, IExportServiceResponse,
} from "types"; } from "types";
import { API_BASE_URL } from "helpers/common.helper";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent = const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class IntegrationService extends APIService { class IntegrationService extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async getAppIntegrationsList(): Promise<IAppIntegration[]> { async getAppIntegrationsList(): Promise<IAppIntegration[]> {

View File

@ -1,6 +1,6 @@
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
import { API_BASE_URL } from "helpers/common.helper";
// types // types
import { IJiraMetadata, IJiraResponse, IJiraImporterForm, ICurrentUserResponse } from "types"; import { IJiraMetadata, IJiraResponse, IJiraImporterForm, ICurrentUserResponse } from "types";
@ -11,7 +11,7 @@ const trackEvent =
class JiraImportedService extends APIService { class JiraImportedService extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async getJiraProjectInfo(workspaceSlug: string, params: IJiraMetadata): Promise<IJiraResponse> { async getJiraProjectInfo(workspaceSlug: string, params: IJiraMetadata): Promise<IJiraResponse> {

View File

@ -10,15 +10,14 @@ import type {
IIssueLabels, IIssueLabels,
ISubIssueResponse, ISubIssueResponse,
} from "types"; } from "types";
import { API_BASE_URL } from "helpers/common.helper";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent = const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class ProjectIssuesServices extends APIService { class ProjectIssuesServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async createIssues( async createIssues(

View File

@ -1,9 +1,9 @@
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "./track-event.service"; import trackEventServices from "./track-event.service";
// types // types
import type { IModule, IIssue, ICurrentUserResponse } from "types"; import type { IModule, IIssue, ICurrentUserResponse } from "types";
import { API_BASE_URL } from "helpers/common.helper";
const { NEXT_PUBLIC_API_BASE_URL } = process.env; const { NEXT_PUBLIC_API_BASE_URL } = process.env;
@ -12,7 +12,7 @@ const trackEvent =
class ProjectIssuesServices extends APIService { class ProjectIssuesServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async getModules(workspaceSlug: string, projectId: string): Promise<IModule[]> { async getModules(workspaceSlug: string, projectId: string): Promise<IModule[]> {

View File

@ -1,8 +1,5 @@
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
// types // types
import type { import type {
IUserNotification, IUserNotification,
@ -11,10 +8,12 @@ import type {
PaginatedUserNotification, PaginatedUserNotification,
IMarkAllAsReadPayload, IMarkAllAsReadPayload,
} from "types"; } from "types";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
class UserNotificationsServices extends APIService { class UserNotificationsServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async getUserNotifications( async getUserNotifications(

View File

@ -1,18 +1,16 @@
import { API_BASE_URL } from "helpers/common.helper";
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
// types // types
import { IPage, IPageBlock, RecentPagesResponse, IIssue, ICurrentUserResponse } from "types"; import { IPage, IPageBlock, RecentPagesResponse, IIssue, ICurrentUserResponse } from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent = const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class PageServices extends APIService { class PageServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async createPage( async createPage(

View File

@ -1,3 +1,4 @@
import { API_BASE_URL } from "helpers/common.helper";
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
@ -5,14 +6,12 @@ import trackEventServices from "services/track-event.service";
import { ICurrentUserResponse } from "types"; import { ICurrentUserResponse } from "types";
import { IProjectPublishSettings } from "store/project-publish"; import { IProjectPublishSettings } from "store/project-publish";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent = const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class ProjectServices extends APIService { class ProjectServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async getProjectSettingsAsync( async getProjectSettingsAsync(

View File

@ -1,7 +1,7 @@
import { API_BASE_URL } from "helpers/common.helper";
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
// types // types
import type { import type {
GithubRepositoriesResponse, GithubRepositoriesResponse,
@ -16,14 +16,12 @@ import type {
TProjectIssuesSearchParams, TProjectIssuesSearchParams,
} from "types"; } from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent = const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
export class ProjectServices extends APIService { export class ProjectServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async createProject( async createProject(

View File

@ -1,7 +1,7 @@
import { API_BASE_URL } from "helpers/common.helper";
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
// types // types
import type { import type {
ICurrentUserResponse, ICurrentUserResponse,
@ -11,14 +11,12 @@ import type {
IssueCommentReactionForm, IssueCommentReactionForm,
} from "types"; } from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent = const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class ReactionService extends APIService { class ReactionService extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async createIssueReaction( async createIssueReaction(

View File

@ -1,8 +1,8 @@
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
// helpers
const { NEXT_PUBLIC_API_BASE_URL } = process.env; import { API_BASE_URL } from "helpers/common.helper";
const trackEvent = const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
@ -12,7 +12,7 @@ import type { ICurrentUserResponse, IState, IStateResponse } from "types";
class ProjectStateServices extends APIService { class ProjectStateServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async createState( async createState(

View File

@ -12,14 +12,14 @@ import type {
IUserWorkspaceDashboard, IUserWorkspaceDashboard,
} from "types"; } from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env; import { API_BASE_URL } from "helpers/common.helper";
const trackEvent = const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class UserService extends APIService { class UserService extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
currentUserConfig() { currentUserConfig() {

View File

@ -6,6 +6,8 @@ import { ICurrentUserResponse } from "types";
// types // types
import { IView } from "types/views"; import { IView } from "types/views";
import { API_BASE_URL } from "helpers/common.helper";
const { NEXT_PUBLIC_API_BASE_URL } = process.env; const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent = const trackEvent =
@ -13,7 +15,7 @@ const trackEvent =
class ViewServices extends APIService { class ViewServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async createView( async createView(

View File

@ -1,9 +1,8 @@
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
// helpers
const { NEXT_PUBLIC_API_BASE_URL } = process.env; import { API_BASE_URL } from "helpers/common.helper";
// types // types
import { import {
IWorkspace, IWorkspace,
@ -22,7 +21,7 @@ const trackEvent =
class WorkspaceService extends APIService { class WorkspaceService extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(API_BASE_URL);
} }
async userWorkspaces(): Promise<IWorkspace[]> { async userWorkspaces(): Promise<IWorkspace[]> {