Merge pull request #2385 from makeplane/stage-release

release: stage release to master
This commit is contained in:
sriram veeraghanta 2023-10-05 14:52:59 +05:30 committed by GitHub
commit 5ac2c270f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 1453 additions and 782 deletions

77
.github/workflows/create-sync-pr.yml vendored Normal file
View File

@ -0,0 +1,77 @@
name: Create PR in Plane EE Repository to sync the changes
on:
pull_request:
types:
- closed
jobs:
create_pr:
# Only run the job when a PR is merged
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- name: Check SOURCE_REPO
id: check_repo
env:
SOURCE_REPO: ${{ secrets.SOURCE_REPO_NAME }}
run: |
echo "::set-output name=is_correct_repo::$(if [[ "$SOURCE_REPO" == "makeplane/plane" ]]; then echo 'true'; else echo 'false'; fi)"
- name: Checkout Code
if: steps.check_repo.outputs.is_correct_repo == 'true'
uses: actions/checkout@v2
with:
persist-credentials: false
fetch-depth: 0
- name: Set up Branch Name
if: steps.check_repo.outputs.is_correct_repo == 'true'
run: |
echo "SOURCE_BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
- name: Setup GH CLI
if: steps.check_repo.outputs.is_correct_repo == 'true'
run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh -y
- name: Create Pull Request
if: steps.check_repo.outputs.is_correct_repo == 'true'
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ secrets.TARGET_REPO_NAME }}"
TARGET_BRANCH="${{ secrets.TARGET_REPO_BRANCH }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
git remote add target "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target $SOURCE_BRANCH:$SOURCE_BRANCH
PR_TITLE="${{ github.event.pull_request.title }}"
PR_BODY="${{ github.event.pull_request.body }}"
# Remove double quotes
PR_TITLE_CLEANED="${PR_TITLE//\"/}"
PR_BODY_CLEANED="${PR_BODY//\"/}"
# Construct PR_BODY_CONTENT using a here-document
PR_BODY_CONTENT=$(cat <<EOF
$PR_BODY_CLEANED
EOF
)
gh pr create \
--base $TARGET_BRANCH \
--head $SOURCE_BRANCH \
--title "[SYNC] $PR_TITLE_CLEANED" \
--body "$PR_BODY_CONTENT" \
--repo $TARGET_REPO

View File

@ -54,7 +54,7 @@ chmod +x setup.sh
- Run setup.sh - Run setup.sh
```bash ```bash
./setup.sh http://localhost ./setup.sh
``` ```
> If running in a cloud env replace localhost with public facing IP address of the VM > If running in a cloud env replace localhost with public facing IP address of the VM

View File

@ -1,7 +1,7 @@
# Backend # Backend
# Debug value for api server use it as 0 for production use # Debug value for api server use it as 0 for production use
DEBUG=0 DEBUG=0
DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" DJANGO_SETTINGS_MODULE="plane.settings.production"
# Error logs # Error logs
SENTRY_DSN="" SENTRY_DSN=""
@ -59,3 +59,14 @@ DEFAULT_PASSWORD="password123"
# SignUps # SignUps
ENABLE_SIGNUP="1" ENABLE_SIGNUP="1"
# Enable Email/Password Signup
ENABLE_EMAIL_PASSWORD="1"
# Enable Magic link Login
ENABLE_MAGIC_LINK_LOGIN="0"
# Email redirections and minio domain settings
WEB_URL="http://localhost"

View File

@ -70,6 +70,7 @@ from plane.api.views import (
ProjectIdentifierEndpoint, ProjectIdentifierEndpoint,
ProjectFavoritesViewSet, ProjectFavoritesViewSet,
LeaveProjectEndpoint, LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint,
## End Projects ## End Projects
# Issues # Issues
IssueViewSet, IssueViewSet,
@ -150,12 +151,11 @@ from plane.api.views import (
GlobalSearchEndpoint, GlobalSearchEndpoint,
IssueSearchEndpoint, IssueSearchEndpoint,
## End Search ## End Search
# Gpt # External
GPTIntegrationEndpoint, GPTIntegrationEndpoint,
## End Gpt
# Release Notes
ReleaseNotesEndpoint, ReleaseNotesEndpoint,
## End Release Notes UnsplashEndpoint,
## End External
# Inbox # Inbox
InboxViewSet, InboxViewSet,
InboxIssueViewSet, InboxIssueViewSet,
@ -186,6 +186,9 @@ from plane.api.views import (
## Exporter ## Exporter
ExportIssuesEndpoint, ExportIssuesEndpoint,
## End Exporter ## End Exporter
# Configuration
ConfigurationEndpoint,
## End Configuration
) )
@ -573,6 +576,11 @@ urlpatterns = [
LeaveProjectEndpoint.as_view(), LeaveProjectEndpoint.as_view(),
name="project", name="project",
), ),
path(
"project-covers/",
ProjectPublicCoverImagesEndpoint.as_view(),
name="project-covers",
),
# End Projects # End Projects
# States # States
path( path(
@ -1446,20 +1454,23 @@ urlpatterns = [
name="project-issue-search", name="project-issue-search",
), ),
## End Search ## End Search
# Gpt # External
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/", "workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
GPTIntegrationEndpoint.as_view(), GPTIntegrationEndpoint.as_view(),
name="importer", name="importer",
), ),
## End Gpt
# Release Notes
path( path(
"release-notes/", "release-notes/",
ReleaseNotesEndpoint.as_view(), ReleaseNotesEndpoint.as_view(),
name="release-notes", name="release-notes",
), ),
## End Release Notes path(
"unsplash/",
UnsplashEndpoint.as_view(),
name="release-notes",
),
## End External
# Inbox # Inbox
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/", "workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/",
@ -1728,4 +1739,11 @@ urlpatterns = [
name="workspace-project-boards", name="workspace-project-boards",
), ),
## End Public Boards ## End Public Boards
# Configuration
path(
"configs/",
ConfigurationEndpoint.as_view(),
name="configuration",
),
## End Configuration
] ]

View File

@ -17,6 +17,7 @@ from .project import (
ProjectMemberEndpoint, ProjectMemberEndpoint,
WorkspaceProjectDeployBoardEndpoint, WorkspaceProjectDeployBoardEndpoint,
LeaveProjectEndpoint, LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint,
) )
from .user import ( from .user import (
UserEndpoint, UserEndpoint,
@ -147,16 +148,13 @@ from .page import (
from .search import GlobalSearchEndpoint, IssueSearchEndpoint from .search import GlobalSearchEndpoint, IssueSearchEndpoint
from .gpt import GPTIntegrationEndpoint from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint
from .estimate import ( from .estimate import (
ProjectEstimatePointEndpoint, ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint, BulkEstimatePointEndpoint,
) )
from .release import ReleaseNotesEndpoint
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
from .analytic import ( from .analytic import (
@ -169,4 +167,6 @@ from .analytic import (
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
from .exporter import ExportIssuesEndpoint from .exporter import ExportIssuesEndpoint
from .config import ConfigurationEndpoint

View File

@ -0,0 +1,40 @@
# Python imports
import os
# Django imports
from django.conf import settings
# Third party imports
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Module imports
from .base import BaseAPIView
class ConfigurationEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request):
try:
data = {}
data["google"] = os.environ.get("GOOGLE_CLIENT_ID", None)
data["github"] = os.environ.get("GITHUB_CLIENT_ID", None)
data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None)
data["magic_login"] = (
bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD)
) and os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0") == "1"
data["email_password_login"] = (
os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1"
)
return Response(data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -2,9 +2,10 @@
import requests import requests
# Third party imports # Third party imports
import openai
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
import openai from rest_framework.permissions import AllowAny
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
# Django imports # Django imports
@ -15,6 +16,7 @@ from .base import BaseAPIView
from plane.api.permissions import ProjectEntityPermission from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Workspace, Project from plane.db.models import Workspace, Project
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
from plane.utils.integrations.github import get_release_notes
class GPTIntegrationEndpoint(BaseAPIView): class GPTIntegrationEndpoint(BaseAPIView):
@ -73,3 +75,44 @@ class GPTIntegrationEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
class ReleaseNotesEndpoint(BaseAPIView):
def get(self, request):
try:
release_notes = get_release_notes()
return Response(release_notes, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class UnsplashEndpoint(BaseAPIView):
def get(self, request):
try:
query = request.GET.get("query", False)
page = request.GET.get("page", 1)
per_page = request.GET.get("per_page", 20)
url = (
f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}"
if query
else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}"
)
headers = {
"Content-Type": "application/json",
}
resp = requests.get(url=url, headers=headers)
return Response(resp.json(), status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -1,5 +1,6 @@
# Python imports # Python imports
import jwt import jwt
import boto3
from datetime import datetime from datetime import datetime
# Django imports # Django imports
@ -495,7 +496,7 @@ class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberAdminSerializer serializer_class = ProjectMemberAdminSerializer
model = ProjectMember model = ProjectMember
permission_classes = [ permission_classes = [
ProjectBasePermission, ProjectMemberPermission,
] ]
search_fields = [ search_fields = [
@ -617,7 +618,8 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
except ProjectMember.DoesNotExist: except ProjectMember.DoesNotExist:
return Response( return Response(
{"error": "Project Member does not exist"}, status=status.HTTP_400_BAD_REQUEST {"error": "Project Member does not exist"},
status=status.HTTP_400_BAD_REQUEST,
) )
except Exception as e: except Exception as e:
capture_exception(e) capture_exception(e)
@ -1209,3 +1211,38 @@ class LeaveProjectEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
class ProjectPublicCoverImagesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request):
try:
files = []
s3 = boto3.client(
"s3",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
params = {
"Bucket": settings.AWS_S3_BUCKET_NAME,
"Prefix": "static/project-cover/",
}
response = s3.list_objects_v2(**params)
# Extracting file keys from the response
if "Contents" in response:
for content in response["Contents"]:
if not content["Key"].endswith(
"/"
): # This line ensures we're only getting files, not "sub-folders"
files.append(
f"https://{settings.AWS_S3_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
)
return Response(files, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response([], status=status.HTTP_200_OK)

View File

@ -1,21 +0,0 @@
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from .base import BaseAPIView
from plane.utils.integrations.github import get_release_notes
class ReleaseNotesEndpoint(BaseAPIView):
def get(self, request):
try:
release_notes = get_release_notes()
return Response(release_notes, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -1197,7 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
projects = request.query_params.getlist("project", []) projects = request.query_params.getlist("project", [])
queryset = IssueActivity.objects.filter( queryset = IssueActivity.objects.filter(
~Q(field__in=["comment", "vote", "reaction"]), ~Q(field__in=["comment", "vote", "reaction", "draft"]),
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
actor=user_id, actor=user_id,

View File

@ -114,3 +114,6 @@ CELERY_BROKER_URL = os.environ.get("REDIS_URL")
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")

View File

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

View File

@ -126,3 +126,4 @@ ANALYTICS_BASE_API = False
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")

View File

@ -218,3 +218,7 @@ CELERY_BROKER_URL = broker_url
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")

View File

@ -1,5 +1,6 @@
{ {
"repository": "https://github.com/makeplane/plane.git", "repository": "https://github.com/makeplane/plane.git",
"version": "0.13.2",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"workspaces": [ "workspaces": [

View File

@ -1,6 +1,6 @@
{ {
"name": "eslint-config-custom", "name": "eslint-config-custom",
"version": "0.0.0", "version": "0.13.2",
"main": "index.js", "main": "index.js",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "tailwind-config-custom", "name": "tailwind-config-custom",
"version": "0.0.1", "version": "0.13.2",
"description": "common tailwind configuration across monorepo", "description": "common tailwind configuration across monorepo",
"main": "index.js", "main": "index.js",
"devDependencies": { "devDependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "tsconfig", "name": "tsconfig",
"version": "0.0.0", "version": "0.13.2",
"private": true, "private": true,
"files": [ "files": [
"base.json", "base.json",

1
packages/ui/README.md Normal file
View File

@ -0,0 +1 @@
# UI Package

View File

@ -1,6 +1,6 @@
{ {
"name": "ui", "name": "ui",
"version": "0.0.0", "version": "0.13.2",
"main": "./index.tsx", "main": "./index.tsx",
"types": "./index.tsx", "types": "./index.tsx",
"license": "MIT", "license": "MIT",

View File

@ -10,9 +10,12 @@ import githubWhiteImage from "public/logos/github-white.svg";
export interface GithubLoginButtonProps { export interface GithubLoginButtonProps {
handleSignIn: React.Dispatch<string>; handleSignIn: React.Dispatch<string>;
clientId: string;
} }
export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn }) => { export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
const { handleSignIn, clientId } = props;
// states
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
const [gitCode, setGitCode] = useState<null | string>(null); const [gitCode, setGitCode] = useState<null | string>(null);
@ -38,7 +41,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn })
<div className="w-full flex justify-center items-center"> <div className="w-full flex justify-center items-center">
<Link <Link
className="w-full" className="w-full"
href={`https://github.com/login/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`} href={`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
> >
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]"> <button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
<Image <Image

View File

@ -1,22 +1,23 @@
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react"; import { FC, useEffect, useRef, useCallback, useState } from "react";
import Script from "next/script"; import Script from "next/script";
export interface IGoogleLoginButton { export interface IGoogleLoginButton {
text?: string; clientId: string;
handleSignIn: React.Dispatch<any>; handleSignIn: React.Dispatch<any>;
styles?: CSSProperties;
} }
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => { export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
const { handleSignIn, clientId } = props;
// refs
const googleSignInButton = useRef<HTMLDivElement>(null); const googleSignInButton = useRef<HTMLDivElement>(null);
// states
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false); const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
const loadScript = useCallback(() => { const loadScript = useCallback(() => {
if (!googleSignInButton.current || gsiScriptLoaded) return; if (!googleSignInButton.current || gsiScriptLoaded) return;
(window as any)?.google?.accounts.id.initialize({ (window as any)?.google?.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "", client_id: clientId,
callback: handleSignIn, callback: handleSignIn,
}); });

View File

@ -1,26 +1,30 @@
import React, { useEffect } from "react"; import React from "react";
import useSWR from "swr";
import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// mobx // mobx
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// services // services
import authenticationService from "services/authentication.service"; import authenticationService from "services/authentication.service";
import { AppConfigService } from "services/app-config.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts"; import { EmailPasswordForm, GoogleLoginButton, EmailCodeForm } from "components/accounts";
// images // images
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : ""; const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
const appConfig = new AppConfigService();
export const SignInView = observer(() => { export const SignInView = observer(() => {
const { user: userStore } = useMobxStore(); const { user: userStore } = useMobxStore();
// router
const router = useRouter(); const router = useRouter();
const { next_path } = router.query as { next_path: string };
// toast
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// fetch app config
const { data } = useSWR("APP_CONFIG", () => appConfig.envConfig());
const onSignInError = (error: any) => { const onSignInError = (error: any) => {
setToastAlert({ setToastAlert({
@ -31,17 +35,17 @@ export const SignInView = observer(() => {
}; };
const onSignInSuccess = (response: any) => { const onSignInSuccess = (response: any) => {
const isOnboarded = response?.user?.onboarding_step?.profile_complete || false;
const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/login";
userStore.setCurrentUser(response?.user); userStore.setCurrentUser(response?.user);
if (!isOnboarded) { const isOnboard = response?.user?.onboarding_step?.profile_complete || false;
router.push(`/onboarding?next_path=${nextPath}`);
return; if (isOnboard) {
if (next_path) router.push(next_path);
else router.push("/login");
} else {
if (next_path) router.push(`/onboarding?next_path=${next_path}`);
else router.push("/onboarding");
} }
router.push((nextPath ?? "/login").toString());
}; };
const handleGoogleSignIn = async ({ clientId, credential }: any) => { const handleGoogleSignIn = async ({ clientId, credential }: any) => {
@ -63,24 +67,6 @@ export const SignInView = observer(() => {
} }
}; };
const handleGitHubSignIn = async (credential: string) => {
try {
if (process.env.NEXT_PUBLIC_GITHUB_ID && credential) {
const socialAuthPayload = {
medium: "github",
credential,
clientId: process.env.NEXT_PUBLIC_GITHUB_ID,
};
const response = await authenticationService.socialAuth(socialAuthPayload);
onSignInSuccess(response);
} else {
throw Error("Cant find credentials");
}
} catch (err: any) {
onSignInError(err);
}
};
const handlePasswordSignIn = async (formData: any) => { const handlePasswordSignIn = async (formData: any) => {
await authenticationService await authenticationService
.emailLogin(formData) .emailLogin(formData)
@ -118,38 +104,32 @@ export const SignInView = observer(() => {
</div> </div>
<div className="grid place-items-center h-full overflow-y-auto py-5 px-7"> <div className="grid place-items-center h-full overflow-y-auto py-5 px-7">
<div> <div>
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( <h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">Sign in to Plane</h1>
<> {data?.email_password_login && <EmailPasswordForm onSubmit={handlePasswordSignIn} />}
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
Sign in to Plane {data?.magic_login && (
</h1> <div className="flex flex-col divide-y divide-custom-border-200">
<div className="flex flex-col divide-y divide-custom-border-200"> <div className="pb-7">
<div className="pb-7"> <EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
</div>
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
<GoogleLoginButton handleSignIn={handleGoogleSignIn} />
{/* <GithubLoginButton handleSignIn={handleGitHubSignIn} /> */}
</div>
</div> </div>
</> </div>
) : (
<EmailPasswordForm onSubmit={handlePasswordSignIn} />
)} )}
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( <div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
<p className="pt-16 text-custom-text-200 text-sm text-center"> {data?.google && <GoogleLoginButton clientId={data.google} handleSignIn={handleGoogleSignIn} />}
By signing up, you agree to the{" "} </div>
<a
href="https://plane.so/terms-and-conditions" <p className="pt-16 text-custom-text-200 text-sm text-center">
target="_blank" By signing up, you agree to the{" "}
rel="noopener noreferrer" <a
className="font-medium underline" href="https://plane.so/terms-and-conditions"
> target="_blank"
Terms & Conditions rel="noopener noreferrer"
</a> className="font-medium underline"
</p> >
) : null} Terms & Conditions
</a>
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -44,19 +44,43 @@ const IssueNavbar = observer(() => {
}, [projectStore, workspace_slug, project_slug]); }, [projectStore, workspace_slug, project_slug]);
useEffect(() => { useEffect(() => {
if (workspace_slug && project_slug) { if (workspace_slug && project_slug && projectStore?.deploySettings) {
if (!board) { const viewsAcceptable: string[] = [];
router.push({ let currentBoard: string | null = null;
pathname: `/${workspace_slug}/${project_slug}`,
query: { if (projectStore?.deploySettings?.views?.list) viewsAcceptable.push("list");
board: "list", if (projectStore?.deploySettings?.views?.kanban) viewsAcceptable.push("kanban");
}, if (projectStore?.deploySettings?.views?.calendar) viewsAcceptable.push("calendar");
}); if (projectStore?.deploySettings?.views?.gantt) viewsAcceptable.push("gantt");
return projectStore.setActiveBoard("list"); if (projectStore?.deploySettings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet");
if (board) {
if (viewsAcceptable.includes(board.toString())) {
currentBoard = board.toString();
} else {
if (viewsAcceptable && viewsAcceptable.length > 0) {
currentBoard = viewsAcceptable[0];
}
}
} else {
if (viewsAcceptable && viewsAcceptable.length > 0) {
currentBoard = viewsAcceptable[0];
}
}
if (currentBoard) {
if (projectStore?.activeBoard === null || projectStore?.activeBoard !== currentBoard) {
projectStore.setActiveBoard(currentBoard);
router.push({
pathname: `/${workspace_slug}/${project_slug}`,
query: {
board: currentBoard,
},
});
}
} }
projectStore.setActiveBoard(board.toString());
} }
}, [board, workspace_slug, project_slug]); }, [board, workspace_slug, project_slug, router, projectStore, projectStore?.deploySettings]);
return ( return (
<div className="px-5 relative w-full flex items-center gap-4"> <div className="px-5 relative w-full flex items-center gap-4">
@ -105,7 +129,7 @@ const IssueNavbar = observer(() => {
</div> </div>
) : ( ) : (
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Link href={`/?next_path=${router.asPath}`}> <Link href={`/login/?next_path=${router.asPath}`}>
<a> <a>
<PrimaryButton className="flex-shrink-0" outline> <PrimaryButton className="flex-shrink-0" outline>
Sign in Sign in

View File

@ -7,7 +7,13 @@ import { SignInView, UserLoggedIn } from "components/accounts";
export const LoginView = observer(() => { export const LoginView = observer(() => {
const { user: userStore } = useMobxStore(); const { user: userStore } = useMobxStore();
if (!userStore.currentUser) return <SignInView />; return (
<>
return <UserLoggedIn />; {userStore?.loader ? (
<div className="relative w-screen h-screen flex justify-center items-center">Loading</div>
) : (
<>{userStore.currentUser ? <UserLoggedIn /> : <SignInView />}</>
)}
</>
);
}); });

View File

@ -3,12 +3,14 @@
import { useEffect } from "react"; import { useEffect } from "react";
// next imports // next imports
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// js cookie
import Cookie from "js-cookie";
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root"; import { RootStore } from "store/root";
const MobxStoreInit = () => { const MobxStoreInit = () => {
const store: RootStore = useMobxStore(); const { user: userStore }: RootStore = useMobxStore();
const router = useRouter(); const router = useRouter();
const { states, labels, priorities } = router.query as { states: string[]; labels: string[]; priorities: string[] }; const { states, labels, priorities } = router.query as { states: string[]; labels: string[]; priorities: string[] };
@ -19,6 +21,11 @@ const MobxStoreInit = () => {
// store.issue.userSelectedStates = states || []; // store.issue.userSelectedStates = states || [];
// }, [store.issue]); // }, [store.issue]);
useEffect(() => {
const authToken = Cookie.get("accessToken") || null;
if (authToken) userStore.fetchCurrentUser();
}, [userStore]);
return <></>; return <></>;
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "space", "name": "space",
"version": "0.0.1", "version": "0.13.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 4000", "dev": "next dev -p 4000",

19
space/pages/index.tsx Normal file
View File

@ -0,0 +1,19 @@
import { useEffect } from "react";
// next
import { NextPage } from "next";
import { useRouter } from "next/router";
const Index: NextPage = () => {
const router = useRouter();
const { next_path } = router.query as { next_path: string };
useEffect(() => {
if (next_path) router.push(`/login?next_path=${next_path}`);
else router.push(`/login`);
}, [router, next_path]);
return null;
};
export default Index;

View File

@ -5,4 +5,4 @@ import { LoginView } from "components/views";
const LoginPage = () => <LoginView />; const LoginPage = () => <LoginView />;
export default LoginPage; export default LoginPage;

View File

@ -0,0 +1,30 @@
// services
import APIService from "services/api.service";
// helper
import { API_BASE_URL } from "helpers/common.helper";
export interface IEnvConfig {
github: string;
google: string;
github_app_name: string | null;
email_password_login: boolean;
magic_login: boolean;
}
export class AppConfigService extends APIService {
constructor() {
super(API_BASE_URL);
}
async envConfig(): Promise<IEnvConfig> {
return this.get("/api/configs/", {
headers: {
"Content-Type": "application/json",
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@ -74,24 +74,6 @@ class FileServices extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async getUnsplashImages(page: number = 1, query?: string): Promise<UnSplashImage[]> {
const url = "/api/unsplash";
return this.request({
method: "get",
url,
params: {
page,
per_page: 20,
query,
},
})
.then((response) => response?.data?.results ?? response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
} }
const fileServices = new FileServices(); const fileServices = new FileServices();

View File

@ -7,12 +7,17 @@ import { ActorDetail } from "types/issue";
import { IUser } from "types/user"; import { IUser } from "types/user";
export interface IUserStore { export interface IUserStore {
loader: boolean;
error: any | null;
currentUser: any | null; currentUser: any | null;
fetchCurrentUser: () => void; fetchCurrentUser: () => void;
currentActor: () => any; currentActor: () => any;
} }
class UserStore implements IUserStore { class UserStore implements IUserStore {
loader: boolean = false;
error: any | null = null;
currentUser: IUser | null = null; currentUser: IUser | null = null;
// root store // root store
rootStore; rootStore;
@ -22,6 +27,9 @@ class UserStore implements IUserStore {
constructor(_rootStore: any) { constructor(_rootStore: any) {
makeObservable(this, { makeObservable(this, {
// observable // observable
loader: observable.ref,
error: observable.ref,
currentUser: observable.ref, currentUser: observable.ref,
// actions // actions
setCurrentUser: action, setCurrentUser: action,
@ -73,14 +81,19 @@ class UserStore implements IUserStore {
fetchCurrentUser = async () => { fetchCurrentUser = async () => {
try { try {
this.loader = true;
this.error = null;
const response = await this.userService.currentUser(); const response = await this.userService.currentUser();
if (response) { if (response) {
runInAction(() => { runInAction(() => {
this.loader = false;
this.currentUser = response; this.currentUser = response;
}); });
} }
} catch (error) { } catch (error) {
console.error("Failed to fetch current user", error); console.error("Failed to fetch current user", error);
this.loader = false;
this.error = error;
} }
}; };
} }

View File

@ -1,8 +1,6 @@
{ {
"$schema": "https://turbo.build/schema.json", "$schema": "https://turbo.build/schema.json",
"globalEnv": [ "globalEnv": [
"NEXT_PUBLIC_GITHUB_ID",
"NEXT_PUBLIC_GOOGLE_CLIENTID",
"NEXT_PUBLIC_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL",
"NEXT_PUBLIC_DEPLOY_URL", "NEXT_PUBLIC_DEPLOY_URL",
"API_BASE_URL", "API_BASE_URL",
@ -12,8 +10,6 @@
"NEXT_PUBLIC_GITHUB_APP_NAME", "NEXT_PUBLIC_GITHUB_APP_NAME",
"NEXT_PUBLIC_ENABLE_SENTRY", "NEXT_PUBLIC_ENABLE_SENTRY",
"NEXT_PUBLIC_ENABLE_OAUTH", "NEXT_PUBLIC_ENABLE_OAUTH",
"NEXT_PUBLIC_UNSPLASH_ACCESS",
"NEXT_PUBLIC_UNSPLASH_ENABLED",
"NEXT_PUBLIC_TRACK_EVENTS", "NEXT_PUBLIC_TRACK_EVENTS",
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN", "NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
"NEXT_PUBLIC_CRISP_ID", "NEXT_PUBLIC_CRISP_ID",

View File

@ -1,12 +1,5 @@
import React, { useState } from "react"; import React from "react";
import { useRouter } from "next/router";
import Link from "next/link";
// react hook form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// components
import { EmailResetPasswordForm } from "components/account";
// ui // ui
import { Input, PrimaryButton } from "components/ui"; import { Input, PrimaryButton } from "components/ui";
// types // types
@ -18,14 +11,12 @@ type EmailPasswordFormValues = {
type Props = { type Props = {
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>; onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
setIsResettingPassword: (value: boolean) => void;
}; };
export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => { export const EmailPasswordForm: React.FC<Props> = (props) => {
const [isResettingPassword, setIsResettingPassword] = useState(false); const { onSubmit, setIsResettingPassword } = props;
// form info
const router = useRouter();
const isSignUpPage = router.pathname === "/sign-up";
const { const {
register, register,
handleSubmit, handleSubmit,
@ -42,94 +33,62 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
return ( return (
<> <>
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100"> <form
{isResettingPassword className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
? "Reset your password" onSubmit={handleSubmit(onSubmit)}
: isSignUpPage >
? "Sign up on Plane" <div className="space-y-1">
: "Sign in to Plane"} <Input
</h1> id="email"
{isResettingPassword ? ( type="email"
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} /> name="email"
) : ( register={register}
<form validations={{
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto" required: "Email address is required",
onSubmit={handleSubmit(onSubmit)} validate: (value) =>
> /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
<div className="space-y-1"> value
<Input ) || "Email address is not valid",
id="email" }}
type="email" error={errors.email}
name="email" placeholder="Enter your email address..."
register={register} className="border-custom-border-300 h-[46px]"
validations={{ />
required: "Email address is required", </div>
validate: (value) => <div className="space-y-1">
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( <Input
value id="password"
) || "Email address is not valid", type="password"
}} name="password"
error={errors.email} register={register}
placeholder="Enter your email address..." validations={{
className="border-custom-border-300 h-[46px]" required: "Password is required",
/> }}
</div> error={errors.password}
<div className="space-y-1"> placeholder="Enter your password..."
<Input className="border-custom-border-300 h-[46px]"
id="password" />
type="password" </div>
name="password" <div className="text-right text-xs">
register={register} <button
validations={{ type="button"
required: "Password is required", onClick={() => setIsResettingPassword(true)}
}} className="text-custom-text-200 hover:text-custom-primary-100"
error={errors.password} >
placeholder="Enter your password..." Forgot your password?
className="border-custom-border-300 h-[46px]" </button>
/> </div>
</div> <div>
<div className="text-right text-xs"> <PrimaryButton
{isSignUpPage ? ( type="submit"
<Link href="/"> className="w-full text-center h-[46px]"
<a className="text-custom-text-200 hover:text-custom-primary-100"> disabled={!isValid && isDirty}
Already have an account? Sign in. loading={isSubmitting}
</a> >
</Link> {isSubmitting ? "Signing in..." : "Sign in"}
) : ( </PrimaryButton>
<button </div>
type="button" </form>
onClick={() => setIsResettingPassword(true)}
className="text-custom-text-200 hover:text-custom-primary-100"
>
Forgot your password?
</button>
)}
</div>
<div>
<PrimaryButton
type="submit"
className="w-full text-center h-[46px]"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSignUpPage
? isSubmitting
? "Signing up..."
: "Sign up"
: isSubmitting
? "Signing in..."
: "Sign in"}
</PrimaryButton>
{!isSignUpPage && (
<Link href="/sign-up">
<a className="block text-custom-text-200 hover:text-custom-primary-100 text-xs mt-4">
Don{"'"}t have an account? Sign up.
</a>
</Link>
)}
</div>
</form>
)}
</> </>
); );
}; };

View File

@ -0,0 +1,114 @@
import React from "react";
import Link from "next/link";
import { useForm } from "react-hook-form";
// ui
import { Input, PrimaryButton } from "components/ui";
// types
type EmailPasswordFormValues = {
email: string;
password?: string;
confirm_password: string;
medium?: string;
};
type Props = {
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
};
export const EmailSignUpForm: React.FC<Props> = (props) => {
const { onSubmit } = props;
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailPasswordFormValues>({
defaultValues: {
email: "",
password: "",
confirm_password: "",
medium: "email",
},
mode: "onChange",
reValidateMode: "onChange",
});
return (
<>
<form
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
onSubmit={handleSubmit(onSubmit)}
>
<div className="space-y-1">
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
}}
error={errors.email}
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]"
/>
</div>
<div className="space-y-1">
<Input
id="password"
type="password"
name="password"
register={register}
validations={{
required: "Password is required",
}}
error={errors.password}
placeholder="Enter your password..."
className="border-custom-border-300 h-[46px]"
/>
</div>
<div className="space-y-1">
<Input
id="confirm_password"
type="password"
name="confirm_password"
register={register}
validations={{
required: "Password is required",
validate: (val: string) => {
if (watch("password") != val) {
return "Your passwords do no match";
}
},
}}
error={errors.confirm_password}
placeholder="Confirm your password..."
className="border-custom-border-300 h-[46px]"
/>
</div>
<div className="text-right text-xs">
<Link href="/">
<a className="text-custom-text-200 hover:text-custom-primary-100">
Already have an account? Sign in.
</a>
</Link>
</div>
<div>
<PrimaryButton
type="submit"
className="w-full text-center h-[46px]"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Signing up..." : "Sign up"}
</PrimaryButton>
</div>
</form>
</>
);
};

View File

@ -1,29 +1,27 @@
import { useEffect, useState, FC } from "react"; import { useEffect, useState, FC } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// next-themes
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
// images // images
import githubBlackImage from "/public/logos/github-black.png"; import githubBlackImage from "/public/logos/github-black.png";
import githubWhiteImage from "/public/logos/github-white.png"; import githubWhiteImage from "/public/logos/github-white.png";
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
export interface GithubLoginButtonProps { export interface GithubLoginButtonProps {
handleSignIn: React.Dispatch<string>; handleSignIn: React.Dispatch<string>;
clientId: string;
} }
export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn }) => { export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
const { handleSignIn, clientId } = props;
// states
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
const [gitCode, setGitCode] = useState<null | string>(null); const [gitCode, setGitCode] = useState<null | string>(null);
// router
const { const {
query: { code }, query: { code },
} = useRouter(); } = useRouter();
// theme
const { theme } = useTheme(); const { theme } = useTheme();
useEffect(() => { useEffect(() => {
@ -42,7 +40,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn })
return ( return (
<div className="w-full flex justify-center items-center"> <div className="w-full flex justify-center items-center">
<Link <Link
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`} href={`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
> >
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]"> <button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
<Image <Image

View File

@ -1,22 +1,23 @@
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react"; import { FC, useEffect, useRef, useCallback, useState } from "react";
import Script from "next/script"; import Script from "next/script";
export interface IGoogleLoginButton { export interface IGoogleLoginButton {
text?: string;
handleSignIn: React.Dispatch<any>; handleSignIn: React.Dispatch<any>;
styles?: CSSProperties; clientId: string;
} }
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => { export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
const { handleSignIn, clientId } = props;
// refs
const googleSignInButton = useRef<HTMLDivElement>(null); const googleSignInButton = useRef<HTMLDivElement>(null);
// states
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false); const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
const loadScript = useCallback(() => { const loadScript = useCallback(() => {
if (!googleSignInButton.current || gsiScriptLoaded) return; if (!googleSignInButton.current || gsiScriptLoaded) return;
window?.google?.accounts.id.initialize({ window?.google?.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "", client_id: clientId,
callback: handleSignIn, callback: handleSignIn,
}); });
@ -39,7 +40,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
window?.google?.accounts.id.prompt(); // also display the One Tap dialog window?.google?.accounts.id.prompt(); // also display the One Tap dialog
setGsiScriptLoaded(true); setGsiScriptLoaded(true);
}, [handleSignIn, gsiScriptLoaded]); }, [handleSignIn, gsiScriptLoaded, clientId]);
useEffect(() => { useEffect(() => {
if (window?.google?.accounts?.id) { if (window?.google?.accounts?.id) {

View File

@ -3,3 +3,4 @@ export * from "./email-password-form";
export * from "./email-reset-password-form"; export * from "./email-reset-password-form";
export * from "./github-login-button"; export * from "./github-login-button";
export * from "./google-login"; export * from "./google-login";
export * from "./email-signup-form";

View File

@ -13,9 +13,14 @@ import { IProject } from "types";
type Props = { type Props = {
projectDetails: IProject | undefined; projectDetails: IProject | undefined;
handleChange: (formData: Partial<IProject>) => Promise<void>; handleChange: (formData: Partial<IProject>) => Promise<void>;
disabled?: boolean;
}; };
export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleChange }) => { export const AutoArchiveAutomation: React.FC<Props> = ({
projectDetails,
handleChange,
disabled = false,
}) => {
const [monthModal, setmonthModal] = useState(false); const [monthModal, setmonthModal] = useState(false);
const initialValues: Partial<IProject> = { archive_in: 1 }; const initialValues: Partial<IProject> = { archive_in: 1 };
@ -49,6 +54,7 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
: handleChange({ archive_in: 0 }) : handleChange({ archive_in: 0 })
} }
size="sm" size="sm"
disabled={disabled}
/> />
</div> </div>
@ -70,6 +76,7 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
input input
verticalPosition="bottom" verticalPosition="bottom"
width="w-full" width="w-full"
disabled={disabled}
> >
<> <>
{PROJECT_AUTOMATION_MONTHS.map((month) => ( {PROJECT_AUTOMATION_MONTHS.map((month) => (

View File

@ -24,9 +24,14 @@ import { getStatesList } from "helpers/state.helper";
type Props = { type Props = {
projectDetails: IProject | undefined; projectDetails: IProject | undefined;
handleChange: (formData: Partial<IProject>) => Promise<void>; handleChange: (formData: Partial<IProject>) => Promise<void>;
disabled?: boolean;
}; };
export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleChange }) => { export const AutoCloseAutomation: React.FC<Props> = ({
projectDetails,
handleChange,
disabled = false,
}) => {
const [monthModal, setmonthModal] = useState(false); const [monthModal, setmonthModal] = useState(false);
const router = useRouter(); const router = useRouter();
@ -98,6 +103,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
: handleChange({ close_in: 0, default_state: null }) : handleChange({ close_in: 0, default_state: null })
} }
size="sm" size="sm"
disabled={disabled}
/> />
</div> </div>
@ -119,6 +125,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
}} }}
input input
width="w-full" width="w-full"
disabled={disabled}
> >
<> <>
{PROJECT_AUTOMATION_MONTHS.map((month) => ( {PROJECT_AUTOMATION_MONTHS.map((month) => (

View File

@ -1,32 +1,23 @@
import React, { useEffect, useState, useRef, useCallback } from "react"; import React, { useEffect, useState, useRef, useCallback } from "react";
// next
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// swr
import useSWR from "swr"; import useSWR from "swr";
// react-dropdown
import { useDropzone } from "react-dropzone"; import { useDropzone } from "react-dropzone";
// headless ui
import { Tab, Transition, Popover } from "@headlessui/react"; import { Tab, Transition, Popover } from "@headlessui/react";
// services // services
import fileService from "services/file.service"; import fileService from "services/file.service";
// components
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"; import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
const unsplashEnabled = import { Input, PrimaryButton, SecondaryButton, Loader } from "components/ui";
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "1";
const tabOptions = [ const tabOptions = [
{
key: "unsplash",
title: "Unsplash",
},
{ {
key: "images", key: "images",
title: "Images", title: "Images",
@ -64,8 +55,22 @@ export const ImagePickerPopover: React.FC<Props> = ({
search: "", search: "",
}); });
const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchParams}`, () => const { data: unsplashImages, error: unsplashError } = useSWR(
fileService.getUnsplashImages(1, searchParams) `UNSPLASH_IMAGES_${searchParams}`,
() => fileService.getUnsplashImages(searchParams),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
const { data: projectCoverImages } = useSWR(
`PROJECT_COVER_IMAGES`,
() => fileService.getProjectCoverImages(),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
); );
const imagePickerRef = useRef<HTMLDivElement>(null); const imagePickerRef = useRef<HTMLDivElement>(null);
@ -115,18 +120,17 @@ export const ImagePickerPopover: React.FC<Props> = ({
}; };
useEffect(() => { useEffect(() => {
if (!images || value !== null) return; if (!unsplashImages || value !== null) return;
onChange(images[0].urls.regular);
}, [value, onChange, images]); onChange(unsplashImages[0].urls.regular);
}, [value, onChange, unsplashImages]);
useOutsideClickDetector(imagePickerRef, () => setIsOpen(false)); useOutsideClickDetector(imagePickerRef, () => setIsOpen(false));
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-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" className="rounded border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
onClick={() => setIsOpen((prev) => !prev)} onClick={() => setIsOpen((prev) => !prev)}
disabled={disabled} disabled={disabled}
> >
@ -141,15 +145,19 @@ export const ImagePickerPopover: React.FC<Props> = ({
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
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-100 shadow-custom-shadow-sm">
<div <div
ref={imagePickerRef} 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" 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> <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"> {tabOptions.map((tab) => {
{tabOptions.map((tab) => ( if (!unsplashImages && unsplashError && tab.key === "unsplash") return null;
if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images")
return null;
return (
<Tab <Tab
key={tab.key} key={tab.key}
className={({ selected }) => className={({ selected }) =>
@ -160,50 +168,106 @@ export const ImagePickerPopover: React.FC<Props> = ({
> >
{tab.title} {tab.title}
</Tab> </Tab>
))} );
</Tab.List> })}
</div> </Tab.List>
<Tab.Panels className="h-full w-full flex-1 overflow-y-auto overflow-x-hidden"> <Tab.Panels className="h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
<Tab.Panel className="h-full w-full space-y-4"> {(unsplashImages || !unsplashError) && (
<div className="flex gap-x-2 pt-7"> <Tab.Panel className="h-full w-full space-y-4 mt-4">
<Input <div className="flex gap-x-2">
name="search" <Input
className="text-sm" name="search"
id="search" className="text-sm"
value={formData.search} id="search"
onChange={(e) => setFormData({ ...formData, search: e.target.value })} value={formData.search}
placeholder="Search for images" onChange={(e) => setFormData({ ...formData, search: e.target.value })}
/> placeholder="Search for images"
<PrimaryButton onClick={() => setSearchParams(formData.search)} size="sm"> />
Search <PrimaryButton onClick={() => setSearchParams(formData.search)} size="sm">
</PrimaryButton> Search
</div> </PrimaryButton>
{images ? ( </div>
<div className="grid grid-cols-4 gap-4"> {unsplashImages ? (
{images.map((image) => ( unsplashImages.length > 0 ? (
<div <div className="grid grid-cols-4 gap-4">
key={image.id} {unsplashImages.map((image) => (
className="relative col-span-2 aspect-video md:col-span-1" <div
> key={image.id}
<img className="relative col-span-2 aspect-video md:col-span-1"
src={image.urls.small} onClick={() => {
alt={image.alt_description} setIsOpen(false);
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover" onChange(image.urls.regular);
onClick={() => { }}
setIsOpen(false); >
onChange(image.urls.regular); <img
}} src={image.urls.small}
/> alt={image.alt_description}
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
/>
</div>
))}
</div> </div>
))} ) : (
</div> <p className="text-center text-custom-text-300 text-xs pt-7">
) : ( No images found.
<div className="flex justify-center pt-20"> </p>
<Spinner /> )
</div> ) : (
)} <Loader className="grid grid-cols-4 gap-4">
</Tab.Panel> <Loader.Item height="80px" width="100%" />
<Tab.Panel className="h-full w-full pt-5"> <Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
</Loader>
)}
</Tab.Panel>
)}
{(!projectCoverImages || projectCoverImages.length !== 0) && (
<Tab.Panel className="h-full w-full space-y-4 mt-4">
{projectCoverImages ? (
projectCoverImages.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{projectCoverImages.map((image, index) => (
<div
key={image}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => {
setIsOpen(false);
onChange(image);
}}
>
<img
src={image}
alt={`Default project cover image- ${index}`}
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
/>
</div>
))}
</div>
) : (
<p className="text-center text-custom-text-300 text-xs pt-7">
No images found.
</p>
)
) : (
<Loader className="grid grid-cols-4 gap-4 pt-4">
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
</Loader>
)}
</Tab.Panel>
)}
<Tab.Panel className="h-full w-full mt-4">
<div className="w-full h-full flex flex-col gap-y-2"> <div className="w-full h-full flex flex-col gap-y-2">
<div className="flex items-center gap-3 w-full flex-1"> <div className="flex items-center gap-3 w-full flex-1">
<div <div

View File

@ -6,6 +6,7 @@ import { useRouter } from "next/router";
import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd"; import { Draggable } from "react-beautiful-dnd";
// components // components
import { CreateUpdateDraftIssueModal } from "components/issues";
import { BoardHeader, SingleBoardIssue, BoardInlineCreateIssueForm } from "components/core"; import { BoardHeader, SingleBoardIssue, BoardInlineCreateIssueForm } from "components/core";
// ui // ui
import { CustomMenu } from "components/ui"; import { CustomMenu } from "components/ui";
@ -57,6 +58,7 @@ export const SingleBoard: React.FC<Props> = (props) => {
const [isCollapsed, setIsCollapsed] = useState(true); const [isCollapsed, setIsCollapsed] = useState(true);
const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false); const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false);
const [isCreateDraftIssueModalOpen, setIsCreateDraftIssueModalOpen] = useState(false);
const { displayFilters, groupedIssues } = viewProps; const { displayFilters, groupedIssues } = viewProps;
@ -96,10 +98,27 @@ export const SingleBoard: React.FC<Props> = (props) => {
scrollToBottom(); scrollToBottom();
}; };
const handleAddIssueToGroup = () => {
if (isDraftIssuesPage) setIsCreateDraftIssueModalOpen(true);
else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
else onCreateClick();
};
return ( return (
<div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}> <div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}>
<CreateUpdateDraftIssueModal
isOpen={isCreateDraftIssueModalOpen}
handleClose={() => setIsCreateDraftIssueModalOpen(false)}
prePopulateData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
[displayFilters?.group_by! === "labels" ? "labels_list" : displayFilters?.group_by!]:
displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle,
}}
/>
<BoardHeader <BoardHeader
addIssueToGroup={addIssueToGroup} addIssueToGroup={handleAddIssueToGroup}
currentState={currentState} currentState={currentState}
groupTitle={groupTitle} groupTitle={groupTitle}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
@ -218,21 +237,22 @@ export const SingleBoard: React.FC<Props> = (props) => {
{displayFilters?.group_by !== "created_by" && ( {displayFilters?.group_by !== "created_by" && (
<div> <div>
{type === "issue" {type === "issue"
? !disableAddIssueOption && ( ? !disableAddIssueOption &&
!isDraftIssuesPage && (
<button <button
type="button" type="button"
className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1" className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1"
onClick={() => { onClick={() => {
if (isDraftIssuesPage || isMyIssuesPage || isProfileIssuesPage) { if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
addIssueToGroup(); else onCreateClick();
} else onCreateClick();
}} }}
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Add Issue Add Issue
</button> </button>
) )
: !disableUserActions && ( : !disableUserActions &&
!isDraftIssuesPage && (
<CustomMenu <CustomMenu
customButton={ customButton={
<button <button
@ -246,7 +266,13 @@ export const SingleBoard: React.FC<Props> = (props) => {
position="left" position="left"
noBorder noBorder
> >
<CustomMenu.MenuItem onClick={() => onCreateClick()}> <CustomMenu.MenuItem
onClick={() => {
if (isDraftIssuesPage) setIsCreateDraftIssueModalOpen(true);
else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
else onCreateClick();
}}
>
Create new Create new
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{openIssuesListModal && ( {openIssuesListModal && (

View File

@ -13,6 +13,7 @@ import projectService from "services/project.service";
// hooks // hooks
import useProjects from "hooks/use-projects"; import useProjects from "hooks/use-projects";
// components // components
import { CreateUpdateDraftIssueModal } from "components/issues";
import { SingleListIssue, ListInlineCreateIssueForm } from "components/core"; import { SingleListIssue, ListInlineCreateIssueForm } from "components/core";
// ui // ui
import { Avatar, CustomMenu } from "components/ui"; import { Avatar, CustomMenu } from "components/ui";
@ -75,6 +76,7 @@ export const SingleList: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false); const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false);
const [isDraftIssuesModalOpen, setIsDraftIssuesModalOpen] = useState(false);
const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues"; const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues";
const isProfileIssuesPage = router.pathname.split("/")[2] === "profile"; const isProfileIssuesPage = router.pathname.split("/")[2] === "profile";
@ -208,154 +210,169 @@ export const SingleList: React.FC<Props> = (props) => {
if (!groupedIssues) return null; if (!groupedIssues) return null;
return ( return (
<Disclosure as="div" defaultOpen> <>
{({ open }) => ( <CreateUpdateDraftIssueModal
<div> isOpen={isDraftIssuesModalOpen}
<div className="flex items-center justify-between px-4 py-2.5 bg-custom-background-90"> handleClose={() => setIsDraftIssuesModalOpen(false)}
<Disclosure.Button> prePopulateData={{
<div className="flex items-center gap-x-3"> ...(cycleId && { cycle: cycleId.toString() }),
{displayFilters?.group_by !== null && ( ...(moduleId && { module: moduleId.toString() }),
<div className="flex items-center">{getGroupIcon()}</div> [displayFilters?.group_by! === "labels" ? "labels_list" : displayFilters?.group_by!]:
)} displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle,
{displayFilters?.group_by !== null ? ( }}
<h2 />
className={`text-sm font-semibold leading-6 text-custom-text-100 ${
displayFilters?.group_by === "created_by" ? "" : "capitalize"
}`}
>
{getGroupTitle()}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<span className="text-custom-text-200 min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs">
{groupedIssues[groupTitle as keyof IIssue].length}
</span>
</div>
</Disclosure.Button>
{isArchivedIssues ? (
""
) : type === "issue" ? (
!disableAddIssueOption && (
<button
type="button"
className="p-1 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => {
if (isDraftIssuesPage || isMyIssuesPage || isProfileIssuesPage) {
addIssueToGroup();
} else setIsCreateIssueFormOpen(true);
}}
>
<PlusIcon className="h-4 w-4" />
</button>
)
) : disableUserActions ? (
""
) : (
<CustomMenu
customButton={
<div className="flex cursor-pointer items-center">
<PlusIcon className="h-4 w-4" />
</div>
}
position="right"
noBorder
>
<CustomMenu.MenuItem onClick={() => setIsCreateIssueFormOpen(true)}>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
<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>
{groupedIssues[groupTitle] ? (
groupedIssues[groupTitle].length > 0 ? (
groupedIssues[groupTitle].map((issue, index) => (
<SingleListIssue
key={issue.id}
type={type}
issue={issue}
projectId={issue.project_detail.id}
groupTitle={groupTitle}
index={index}
editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
handleDraftIssueSelect={
handleDraftIssueAction
? () => handleDraftIssueAction(issue, "edit")
: undefined
}
handleDraftIssueDelete={
handleDraftIssueAction
? () => handleDraftIssueAction(issue, "delete")
: undefined
}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={() => {
if (removeIssue !== null && issue.bridge_id)
removeIssue(issue.bridge_id, issue.id);
}}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
viewProps={viewProps}
/>
))
) : (
<p className="bg-custom-background-100 px-4 py-2.5 text-sm text-custom-text-200">
No issues.
</p>
)
) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div>
)}
<ListInlineCreateIssueForm <Disclosure as="div" defaultOpen>
isOpen={isCreateIssueFormOpen && !disableAddIssueOption} {({ open }) => (
handleClose={() => setIsCreateIssueFormOpen(false)} <div>
prePopulatedData={{ <div className="flex items-center justify-between px-4 py-2.5 bg-custom-background-90">
...(cycleId && { cycle: cycleId.toString() }), <Disclosure.Button>
...(moduleId && { module: moduleId.toString() }), <div className="flex items-center gap-x-3">
[displayFilters?.group_by!]: groupTitle, {displayFilters?.group_by !== null && (
}} <div className="flex items-center">{getGroupIcon()}</div>
/> )}
{displayFilters?.group_by !== null ? (
{!disableAddIssueOption && !isCreateIssueFormOpen && ( <h2
// TODO: add border here className={`text-sm font-semibold leading-6 text-custom-text-100 ${
<div className="w-full bg-custom-background-100 px-6 py-3 border-b border-custom-border-100"> displayFilters?.group_by === "created_by" ? "" : "capitalize"
}`}
>
{getGroupTitle()}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<span className="text-custom-text-200 min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs">
{groupedIssues[groupTitle as keyof IIssue].length}
</span>
</div>
</Disclosure.Button>
{isArchivedIssues ? (
""
) : type === "issue" ? (
!disableAddIssueOption && (
<button <button
type="button" type="button"
className="p-1 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => { onClick={() => {
if (isDraftIssuesPage || isMyIssuesPage || isProfileIssuesPage) { if (isDraftIssuesPage) setIsDraftIssuesModalOpen(true);
addIssueToGroup(); else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
} else setIsCreateIssueFormOpen(true); else setIsCreateIssueFormOpen(true);
}} }}
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button> </button>
</div> )
) : disableUserActions ? (
""
) : (
<CustomMenu
customButton={
<div className="flex cursor-pointer items-center">
<PlusIcon className="h-4 w-4" />
</div>
}
position="right"
noBorder
>
<CustomMenu.MenuItem onClick={() => setIsCreateIssueFormOpen(true)}>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)} )}
</Disclosure.Panel> </div>
</Transition> <Transition
</div> show={open}
)} enter="transition duration-100 ease-out"
</Disclosure> enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
{groupedIssues[groupTitle] ? (
groupedIssues[groupTitle].length > 0 ? (
groupedIssues[groupTitle].map((issue, index) => (
<SingleListIssue
key={issue.id}
type={type}
issue={issue}
projectId={issue.project_detail.id}
groupTitle={groupTitle}
index={index}
editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
handleDraftIssueSelect={
handleDraftIssueAction
? () => handleDraftIssueAction(issue, "edit")
: undefined
}
handleDraftIssueDelete={
handleDraftIssueAction
? () => handleDraftIssueAction(issue, "delete")
: undefined
}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={() => {
if (removeIssue !== null && issue.bridge_id)
removeIssue(issue.bridge_id, issue.id);
}}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
viewProps={viewProps}
/>
))
) : (
<p className="bg-custom-background-100 px-4 py-2.5 text-sm text-custom-text-200">
No issues.
</p>
)
) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div>
)}
<ListInlineCreateIssueForm
isOpen={isCreateIssueFormOpen && !disableAddIssueOption}
handleClose={() => setIsCreateIssueFormOpen(false)}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
[displayFilters?.group_by! === "labels"
? "labels_list"
: displayFilters?.group_by!]:
displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle,
}}
/>
{!disableAddIssueOption && !isCreateIssueFormOpen && !isDraftIssuesPage && (
<div className="w-full bg-custom-background-100 px-6 py-3 border-b border-custom-border-100">
<button
type="button"
onClick={() => {
if (isDraftIssuesPage) setIsDraftIssuesModalOpen(true);
else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
else setIsCreateIssueFormOpen(true);
}}
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
</div>
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</>
); );
}; };

View File

@ -4,6 +4,7 @@ import { Tab, Transition, Popover } from "@headlessui/react";
// react colors // react colors
import { TwitterPicker } from "react-color"; import { TwitterPicker } from "react-color";
// hooks // hooks
import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown";
import useOutsideClickDetector from "hooks/use-outside-click-detector"; import useOutsideClickDetector from "hooks/use-outside-click-detector";
// types // types
import { Props } from "./types"; import { Props } from "./types";
@ -38,6 +39,7 @@ const EmojiIconPicker: React.FC<Props> = ({
const [recentEmojis, setRecentEmojis] = useState<string[]>([]); const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
const buttonRef = useRef<HTMLButtonElement>(null);
const emojiPickerRef = useRef<HTMLDivElement>(null); const emojiPickerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@ -49,10 +51,12 @@ const EmojiIconPicker: React.FC<Props> = ({
}, [value, onChange]); }, [value, onChange]);
useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false)); useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false));
useDynamicDropdownPosition(isOpen, () => setIsOpen(false), buttonRef, emojiPickerRef);
return ( return (
<Popover className="relative z-[1]"> <Popover className="relative z-[1]">
<Popover.Button <Popover.Button
ref={buttonRef}
onClick={() => setIsOpen((prev) => !prev)} onClick={() => setIsOpen((prev) => !prev)}
className="outline-none" className="outline-none"
disabled={disabled} disabled={disabled}
@ -61,6 +65,8 @@ const EmojiIconPicker: React.FC<Props> = ({
</Popover.Button> </Popover.Button>
<Transition <Transition
show={isOpen} show={isOpen}
static
as={React.Fragment}
enter="transition ease-out duration-100" enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95" enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100" enterTo="transform opacity-100 scale-100"
@ -68,11 +74,11 @@ const EmojiIconPicker: React.FC<Props> = ({
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
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
<div ref={emojiPickerRef}
ref={emojiPickerRef} className="fixed z-10 mt-2 w-[250px] rounded-[4px] border border-custom-border-200 bg-custom-background-80 shadow-lg"
className="h-[230px] w-[250px] overflow-auto rounded-[4px] border border-custom-border-200 bg-custom-background-80 p-2 shadow-xl" >
> <div 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

@ -80,6 +80,9 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
const router = useRouter(); const router = useRouter();
const { cycleId, moduleId } = router.query; const { cycleId, moduleId } = router.query;
const isCyclePage = router.pathname.split("/")[4] === "cycles" && !cycleId;
const isModulePage = router.pathname.split("/")[4] === "modules" && !moduleId;
const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) => const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) =>
blocks && blocks.length > 0 blocks && blocks.length > 0
? blocks.map((block: any) => ({ ? blocks.map((block: any) => ({
@ -317,7 +320,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
SidebarBlockRender={SidebarBlockRender} SidebarBlockRender={SidebarBlockRender}
enableReorder={enableReorder} enableReorder={enableReorder}
/> />
{chartBlocks && ( {chartBlocks && !(isCyclePage || isModulePage) && (
<div className="pl-2.5 py-3"> <div className="pl-2.5 py-3">
<GanttInlineCreateIssueForm <GanttInlineCreateIssueForm
isOpen={isCreateIssueFormOpen} isOpen={isCreateIssueFormOpen}

View File

@ -15,10 +15,28 @@ export const StateGroupBacklogIcon: React.FC<Props> = ({
height={height} height={height}
width={width} width={width}
className={className} className={className}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 323.15 323.03"
> >
<circle cx="6" cy="6" r="5.6" stroke={color} strokeWidth="0.8" strokeDasharray="4 4" /> <g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path
fill={color}
d="M163.42,322.92A172.12,172.12,0,0,1,104.8,312.7c-3.92-1.4-5.22-3.05-3.07-7.1,2.4-4.52,3-11.38,6.64-13.48s9.34,2.47,14.23,3.81c29.55,8.11,58.78,7.25,87.57-3.31,4.08-1.5,5.86-1.05,7.09,3.21a82.63,82.63,0,0,0,4.6,11c1.19,2.57,1,4.06-2,5.2a163.84,163.84,0,0,1-40.05,9.76C173.84,322.45,167.89,323.34,163.42,322.92Z"
/>
<path
fill={color}
d="M.07,163a174.76,174.76,0,0,1,10.07-58c1.59-4.57,3.53-5.59,7.8-3.2a61,61,0,0,0,10.11,4.19c3.11,1.06,4.07,2.46,2.71,5.79-6.43,15.73-9.17,32.33-9.23,49.14a132.65,132.65,0,0,0,8.17,47.35c2.44,6.5,2.33,6.57-4.06,9.35-3.35,1.45-6.83,2.63-10.11,4.23-2.44,1.19-3.54.49-4.43-1.86a162.3,162.3,0,0,1-10-41C.51,173.12-.24,167.17.07,163Z"
/>
<path
fill={color}
d="M323,160.16a169.68,169.68,0,0,1-10.2,58.09c-1.45,4.08-3.21,5.07-7.14,3a105.3,105.3,0,0,0-11.48-4.81c-2.23-.85-3.2-1.85-2.16-4.41a133.86,133.86,0,0,0,9.57-48.59,132,132,0,0,0-8.9-50.69c-1.67-4.24-.8-5.79,3.29-7a84,84,0,0,0,11-4.62c2.65-1.24,4.05-.82,5.16,2.12a159.68,159.68,0,0,1,9.68,39C322.56,148.71,323.52,155.17,323,160.16Z"
/>
<path
fill={color}
d="M161.59,0a164.28,164.28,0,0,1,58,10.72c2.81,1,3.75,2,2.41,4.93-2,4.38-3.86,8.84-5.5,13.37-.93,2.56-2.28,2.77-4.53,1.87a137.94,137.94,0,0,0-99.35-.52c-3.43,1.32-5.3,1.35-6.45-2.69a50.33,50.33,0,0,0-4.55-11c-2.25-3.93-.36-5.11,2.9-6.29A165.32,165.32,0,0,1,161.59,0Z"
/>
</g>
</g>
</svg> </svg>
); );

View File

@ -9,17 +9,38 @@ export const StateGroupStartedIcon: React.FC<Props> = ({
width = "20", width = "20",
height = "20", height = "20",
className, className,
color = "#f59e0b", color = "#f39e1f",
}) => ( }) => (
<svg <svg
height={height} height={height}
width={width} width={width}
className={className} className={className}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12" viewBox="0 0 152.93 152.95"
fill="none"
> >
<circle cx="6" cy="6" r="5.6" stroke={color} strokeWidth="0.8" /> <g id="Layer_2" data-name="Layer 2">
<circle cx="6" cy="6" r="3.35" stroke={color} strokeWidth="0.8" strokeDasharray="2.4 2.4" /> <g id="Layer_1-2" data-name="Layer 1">
<path
fill={color}
d="M77.74,0C35.63-.62.78,32.9,0,74.94c-.77,42.74,33,77.34,76.23,78A76.48,76.48,0,0,0,77.74,0ZM75.46,142.68a66.24,66.24,0,1,1,3-132.45c35.71,1,66.31,31.26,64.16,70.08A66.23,66.23,0,0,1,75.46,142.68Z"
/>
<path
fill={color}
d="M124.29,76.58a49.52,49.52,0,0,1-3.11,16.9c-.38,1-.77,1.27-1.81.78-2.15-1-4.34-1.92-6.56-2.72-1.3-.46-1.51-1-1-2.3a36.61,36.61,0,0,0,.64-23.77c-1-3.48-1.06-3.47,2.38-4.88,1.57-.65,3.15-1.27,4.68-2,.94-.44,1.34-.22,1.69.75A49.74,49.74,0,0,1,124.29,76.58Z"
/>
<path
fill={color}
d="M94.65,32.63c-.1.22-.19.42-.27.63-1,2.5-2.08,5-3.09,7.51-.28.69-.55.89-1.37.59a37.3,37.3,0,0,0-26.82,0c-.91.34-1.15.08-1.46-.7-1-2.46-2-4.92-3.06-7.34-.42-.92-.07-1.18.69-1.46a47.66,47.66,0,0,1,34.43,0C94.06,32,94.68,32,94.65,32.63Z"
/>
<path
fill={color}
d="M28.72,76.67a48.27,48.27,0,0,1,3-17.13c.45-1.25.92-1.34,2-.83,2.25,1,4.56,2,6.87,2.87.86.34,1.05.67.71,1.58a36.85,36.85,0,0,0-.07,26.36c.36,1,.3,1.46-.75,1.86-2.38.9-4.72,1.88-7,2.92-1,.43-1.33.2-1.68-.76A46.76,46.76,0,0,1,28.72,76.67Z"
/>
<path
fill={color}
d="M76.37,124.22a48.11,48.11,0,0,1-16.91-3.08c-1.05-.38-1.26-.8-.79-1.82,1-2.31,2-4.66,2.93-7,.34-.87.69-1.06,1.61-.72a37.06,37.06,0,0,0,26.67,0c.75-.28,1.09-.23,1.39.55,1,2.56,2,5.13,3.18,7.65.49,1.08-.3,1.13-.86,1.34A46.53,46.53,0,0,1,76.37,124.22Z"
/>
</g>
</g>
</svg> </svg>
); );

View File

@ -8,6 +8,7 @@ import { Controller, useForm } from "react-hook-form";
import aiService from "services/ai.service"; import aiService from "services/ai.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useLocalStorage from "hooks/use-local-storage";
// components // components
import { GptAssistantModal } from "components/core"; import { GptAssistantModal } from "components/core";
import { ParentIssuesListModal } from "components/issues"; import { ParentIssuesListModal } from "components/issues";
@ -60,6 +61,7 @@ interface IssueFormProps {
action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue"
) => Promise<void>; ) => Promise<void>;
data?: Partial<IIssue> | null; data?: Partial<IIssue> | null;
isOpen: boolean;
prePopulatedData?: Partial<IIssue> | null; prePopulatedData?: Partial<IIssue> | null;
projectId: string; projectId: string;
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>; setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
@ -89,6 +91,7 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
const { const {
handleFormSubmit, handleFormSubmit,
data, data,
isOpen,
prePopulatedData, prePopulatedData,
projectId, projectId,
setActiveProject, setActiveProject,
@ -109,6 +112,8 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
const [gptAssistantModal, setGptAssistantModal] = useState(false); const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {});
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
const router = useRouter(); const router = useRouter();
@ -133,6 +138,33 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
const issueName = watch("name"); const issueName = watch("name");
const payload: Partial<IIssue> = {
name: watch("name"),
description: watch("description"),
description_html: watch("description_html"),
state: watch("state"),
priority: watch("priority"),
assignees: watch("assignees"),
labels: watch("labels"),
start_date: watch("start_date"),
target_date: watch("target_date"),
project: watch("project"),
parent: watch("parent"),
cycle: watch("cycle"),
module: watch("module"),
};
useEffect(() => {
if (!isOpen || data) return;
setLocalStorageValue(
JSON.stringify({
...payload,
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(payload), isOpen, data]);
const onClose = () => { const onClose = () => {
handleClose(); handleClose();
}; };
@ -273,7 +305,7 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
)} )}
<form <form
onSubmit={handleSubmit((formData) => onSubmit={handleSubmit((formData) =>
handleCreateUpdateIssue(formData, "convertToNewIssue") handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createDraft")
)} )}
> >
<div className="space-y-5"> <div className="space-y-5">

View File

@ -385,6 +385,7 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = (props) =
> >
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl"> <Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
<DraftIssueForm <DraftIssueForm
isOpen={isOpen}
handleFormSubmit={handleFormSubmit} handleFormSubmit={handleFormSubmit}
prePopulatedData={prePopulateData} prePopulatedData={prePopulateData}
data={data} data={data}

View File

@ -129,18 +129,19 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
const issueName = watch("name"); const issueName = watch("name");
const payload: Partial<IIssue> = { const payload: Partial<IIssue> = {
name: getValues("name"), name: watch("name"),
description: getValues("description"), description: watch("description"),
state: getValues("state"), description_html: watch("description_html"),
priority: getValues("priority"), state: watch("state"),
assignees: getValues("assignees"), priority: watch("priority"),
labels: getValues("labels"), assignees: watch("assignees"),
start_date: getValues("start_date"), labels: watch("labels"),
target_date: getValues("target_date"), start_date: watch("start_date"),
project: getValues("project"), target_date: watch("target_date"),
parent: getValues("parent"), project: watch("project"),
cycle: getValues("cycle"), parent: watch("parent"),
module: getValues("module"), cycle: watch("cycle"),
module: watch("module"),
}; };
useEffect(() => { useEffect(() => {

View File

@ -14,7 +14,7 @@ import { ExistingIssuesListModal } from "components/core";
import { XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
import { BlockedIcon } from "components/icons"; import { BlockedIcon } from "components/icons";
// types // types
import { BlockeIssueDetail, IIssue, ISearchIssueResponse, UserAuth } from "types"; import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
type Props = { type Props = {
issueId?: string; issueId?: string;
@ -41,6 +41,9 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
setIsBlockedModalOpen(false); setIsBlockedModalOpen(false);
}; };
const blockedByIssue =
watch("related_issues")?.filter((i) => i.relation_type === "blocked_by") || [];
const onSubmit = async (data: ISearchIssueResponse[]) => { const onSubmit = async (data: ISearchIssueResponse[]) => {
if (data.length === 0) { if (data.length === 0) {
setToastAlert({ setToastAlert({
@ -80,18 +83,13 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
}) })
.then((response) => { .then((response) => {
submitChanges({ submitChanges({
related_issues: [ related_issues: [...watch("related_issues"), ...response],
...watch("related_issues")?.filter((i) => i.relation_type !== "blocked_by"),
...response,
],
}); });
}); });
handleClose(); handleClose();
}; };
const blockedByIssue = watch("related_issues")?.filter((i) => i.relation_type === "blocked_by");
return ( return (
<> <>
<ExistingIssuesListModal <ExistingIssuesListModal

View File

@ -15,7 +15,7 @@ import issuesService from "services/issues.service";
import { XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
import { BlockerIcon } from "components/icons"; import { BlockerIcon } from "components/icons";
// types // types
import { BlockeIssueDetail, IIssue, ISearchIssueResponse, UserAuth } from "types"; import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
type Props = { type Props = {
issueId?: string; issueId?: string;

View File

@ -75,10 +75,8 @@ export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
})), })),
], ],
}) })
.then((response) => { .then(() => {
submitChanges({ submitChanges();
related_issues: [...watch("related_issues"), ...(response ?? [])],
});
}); });
handleClose(); handleClose();

View File

@ -75,10 +75,8 @@ export const SidebarRelatesSelect: React.FC<Props> = (props) => {
})), })),
], ],
}) })
.then((response) => { .then(() => {
submitChanges({ submitChanges();
related_issues: [...watch("related_issues"), ...(response ?? [])],
});
}); });
handleClose(); handleClose();

View File

@ -53,7 +53,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types"; import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types";
// fetch-keys // fetch-keys
import { ISSUE_DETAILS } from "constants/fetch-keys"; import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import { ContrastIcon } from "components/icons"; import { ContrastIcon } from "components/icons";
type Props = { type Props = {
@ -480,6 +480,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
}, },
false false
); );
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
}} }}
watch={watchIssue} watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable} disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
@ -500,6 +501,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
}, },
false false
); );
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
}} }}
watch={watchIssue} watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable} disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
@ -517,6 +519,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
...data, ...data,
}; };
}); });
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
}} }}
watch={watchIssue} watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable} disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
@ -534,6 +537,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
...data, ...data,
}; };
}); });
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
}} }}
watch={watchIssue} watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable} disabled={memberRole.isGuest || memberRole.isViewer || uneditable}

View File

@ -1,7 +1,8 @@
import React from "react"; import React from "react";
// next imports // next imports
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// lucide icons // lucide icons
import { import {
ChevronDown, ChevronDown,
@ -13,6 +14,7 @@ import {
Loader, Loader,
} from "lucide-react"; } from "lucide-react";
// components // components
import { IssuePeekOverview } from "components/issues/peek-overview";
import { SubIssuesRootList } from "./issues-list"; import { SubIssuesRootList } from "./issues-list";
import { IssueProperty } from "./properties"; import { IssueProperty } from "./properties";
// ui // ui
@ -20,6 +22,8 @@ import { Tooltip, CustomMenu } from "components/ui";
// types // types
import { ICurrentUserResponse, IIssue } from "types"; import { ICurrentUserResponse, IIssue } from "types";
import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root"; import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
// fetch keys
import { SUB_ISSUES } from "constants/fetch-keys";
export interface ISubIssues { export interface ISubIssues {
workspaceSlug: string; workspaceSlug: string;
@ -38,7 +42,6 @@ export interface ISubIssues {
issueId: string, issueId: string,
issue?: IIssue | null issue?: IIssue | null
) => void; ) => void;
setPeekParentId: (id: string) => void;
} }
export const SubIssues: React.FC<ISubIssues> = ({ export const SubIssues: React.FC<ISubIssues> = ({
@ -54,14 +57,12 @@ export const SubIssues: React.FC<ISubIssues> = ({
handleIssuesLoader, handleIssuesLoader,
copyText, copyText,
handleIssueCrudOperation, handleIssueCrudOperation,
setPeekParentId,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { query } = router;
const { peekIssue } = query as { peekIssue: string };
const openPeekOverview = (issue_id: string) => { const openPeekOverview = (issue_id: string) => {
const { query } = router;
setPeekParentId(parentIssue?.id);
router.push({ router.push({
pathname: router.pathname, pathname: router.pathname,
query: { ...query, peekIssue: issue_id }, query: { ...query, peekIssue: issue_id },
@ -199,7 +200,17 @@ export const SubIssues: React.FC<ISubIssues> = ({
handleIssuesLoader={handleIssuesLoader} handleIssuesLoader={handleIssuesLoader}
copyText={copyText} copyText={copyText}
handleIssueCrudOperation={handleIssueCrudOperation} handleIssueCrudOperation={handleIssueCrudOperation}
setPeekParentId={setPeekParentId} />
)}
{peekIssue && peekIssue === issue?.id && (
<IssuePeekOverview
handleMutation={() =>
parentIssue && parentIssue?.id && mutate(SUB_ISSUES(parentIssue?.id))
}
projectId={issue?.project ?? ""}
workspaceSlug={workspaceSlug ?? ""}
readOnly={!editable}
/> />
)} )}
</div> </div>

View File

@ -27,7 +27,6 @@ export interface ISubIssuesRootList {
issueId: string, issueId: string,
issue?: IIssue | null issue?: IIssue | null
) => void; ) => void;
setPeekParentId: (id: string) => void;
} }
export const SubIssuesRootList: React.FC<ISubIssuesRootList> = ({ export const SubIssuesRootList: React.FC<ISubIssuesRootList> = ({
@ -42,7 +41,6 @@ export const SubIssuesRootList: React.FC<ISubIssuesRootList> = ({
handleIssuesLoader, handleIssuesLoader,
copyText, copyText,
handleIssueCrudOperation, handleIssueCrudOperation,
setPeekParentId,
}) => { }) => {
const { data: issues, isLoading } = useSWR( const { data: issues, isLoading } = useSWR(
workspaceSlug && projectId && parentIssue && parentIssue?.id workspaceSlug && projectId && parentIssue && parentIssue?.id
@ -83,7 +81,6 @@ export const SubIssuesRootList: React.FC<ISubIssuesRootList> = ({
handleIssuesLoader={handleIssuesLoader} handleIssuesLoader={handleIssuesLoader}
copyText={copyText} copyText={copyText}
handleIssueCrudOperation={handleIssueCrudOperation} handleIssueCrudOperation={handleIssueCrudOperation}
setPeekParentId={setPeekParentId}
/> />
))} ))}

View File

@ -10,7 +10,6 @@ import { ExistingIssuesListModal } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { SubIssuesRootList } from "./issues-list"; import { SubIssuesRootList } from "./issues-list";
import { ProgressBar } from "./progressbar"; import { ProgressBar } from "./progressbar";
import { IssuePeekOverview } from "components/issues/peek-overview";
// ui // ui
import { CustomMenu } from "components/ui"; import { CustomMenu } from "components/ui";
// hooks // hooks
@ -60,8 +59,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user }) =
: null : null
); );
const [peekParentId, setPeekParentId] = React.useState<string | null>("");
const [issuesLoader, setIssuesLoader] = React.useState<ISubIssuesRootLoaders>({ const [issuesLoader, setIssuesLoader] = React.useState<ISubIssuesRootLoaders>({
visibility: [parentIssue?.id], visibility: [parentIssue?.id],
delete: [], delete: [],
@ -237,7 +234,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user }) =
handleIssuesLoader={handleIssuesLoader} handleIssuesLoader={handleIssuesLoader}
copyText={copyText} copyText={copyText}
handleIssueCrudOperation={handleIssueCrudOperation} handleIssueCrudOperation={handleIssueCrudOperation}
setPeekParentId={setPeekParentId}
/> />
</div> </div>
)} )}
@ -363,13 +359,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user }) =
)} )}
</> </>
)} )}
<IssuePeekOverview
handleMutation={() => peekParentId && peekIssue && mutateSubIssues(peekParentId)}
projectId={projectId ?? ""}
workspaceSlug={workspaceSlug ?? ""}
readOnly={!isEditable}
/>
</div> </div>
); );
}; };

View File

@ -393,7 +393,7 @@ export const CreateProjectModal: React.FC<Props> = ({
value={value} value={value}
onChange={onChange} onChange={onChange}
options={options} options={options}
buttonClassName="!px-2 shadow-md" buttonClassName="border-[0.5px] !px-2 shadow-md"
label={ label={
<div className="flex items-center justify-center gap-2 py-[1px]"> <div className="flex items-center justify-center gap-2 py-[1px]">
{value ? ( {value ? (

View File

@ -16,9 +16,10 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = { type Props = {
value: any; value: any;
onChange: (val: string) => void; onChange: (val: string) => void;
isDisabled?: boolean;
}; };
export const MemberSelect: React.FC<Props> = ({ value, onChange }) => { export const MemberSelect: React.FC<Props> = ({ value, onChange, isDisabled = false }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -79,6 +80,7 @@ export const MemberSelect: React.FC<Props> = ({ value, onChange }) => {
position="right" position="right"
width="w-full" width="w-full"
onChange={onChange} onChange={onChange}
disabled={isDisabled}
/> />
); );
}; };

View File

@ -89,7 +89,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
onClick={() => { onClick={() => {
editor?.chain().focus().run(); editor?.chain().focus().run();
}} }}
className={`tiptap-editor-container cursor-text ${editorClassNames}`} className={`tiptap-editor-container relative cursor-text ${editorClassNames}`}
> >
{editor && <EditorBubbleMenu editor={editor} />} {editor && <EditorBubbleMenu editor={editor} />}
<div className={`${editorContentCustomClassNames}`}> <div className={`${editorContentCustomClassNames}`}>

View File

@ -80,8 +80,6 @@ export const TableMenu = ({ editor }: { editor: any }) => {
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
const tableNode = findTableAncestor(range.startContainer); const tableNode = findTableAncestor(range.startContainer);
let parent = tableNode?.parentElement;
if (tableNode) { if (tableNode) {
const tableRect = tableNode.getBoundingClientRect(); const tableRect = tableNode.getBoundingClientRect();
const tableCenter = tableRect.left + tableRect.width / 2; const tableCenter = tableRect.left + tableRect.width / 2;
@ -90,18 +88,6 @@ export const TableMenu = ({ editor }: { editor: any }) => {
const tableBottom = tableRect.bottom; const tableBottom = tableRect.bottom;
setTableLocation({ bottom: tableBottom, left: menuLeft }); setTableLocation({ bottom: tableBottom, left: menuLeft });
while (parent) {
if (!parent.classList.contains("disable-scroll"))
parent.classList.add("disable-scroll");
parent = parent.parentElement;
}
} else {
const scrollDisabledContainers = document.querySelectorAll(".disable-scroll");
scrollDisabledContainers.forEach((container) => {
container.classList.remove("disable-scroll");
});
} }
} }
}; };
@ -115,13 +101,9 @@ export const TableMenu = ({ editor }: { editor: any }) => {
return ( return (
<section <section
className={`fixed left-1/2 transform -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-sm p-1 ${ className={`absolute z-20 left-1/2 -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-sm p-1 ${
isOpen ? "block" : "hidden" isOpen ? "block" : "hidden"
}`} }`}
style={{
bottom: `calc(100vh - ${tableLocation.bottom + 45}px)`,
left: `${tableLocation.left}px`,
}}
> >
{items.map((item, index) => ( {items.map((item, index) => (
<Tooltip key={index} tooltipContent={item.name}> <Tooltip key={index} tooltipContent={item.name}>

View File

@ -46,6 +46,7 @@ const CustomMenu = ({
type="button" type="button"
onClick={menuButtonOnClick} onClick={menuButtonOnClick}
className={customButtonClassName} className={customButtonClassName}
disabled={disabled}
> >
{customButton} {customButton}
</Menu.Button> </Menu.Button>

View File

@ -16,6 +16,7 @@ type Props = {
}; };
secondaryButton?: React.ReactNode; secondaryButton?: React.ReactNode;
isFullScreen?: boolean; isFullScreen?: boolean;
disabled?: boolean;
}; };
export const EmptyState: React.FC<Props> = ({ export const EmptyState: React.FC<Props> = ({
@ -25,6 +26,7 @@ export const EmptyState: React.FC<Props> = ({
primaryButton, primaryButton,
secondaryButton, secondaryButton,
isFullScreen = true, isFullScreen = true,
disabled = false,
}) => ( }) => (
<div <div
className={`h-full w-full mx-auto grid place-items-center p-8 ${ className={`h-full w-full mx-auto grid place-items-center p-8 ${
@ -37,7 +39,11 @@ export const EmptyState: React.FC<Props> = ({
{description && <p className="text-custom-text-300 mb-7 sm:mb-8">{description}</p>} {description && <p className="text-custom-text-300 mb-7 sm:mb-8">{description}</p>}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{primaryButton && ( {primaryButton && (
<PrimaryButton className="flex items-center gap-1.5" onClick={primaryButton.onClick}> <PrimaryButton
className="flex items-center gap-1.5"
onClick={primaryButton.onClick}
disabled={disabled}
>
{primaryButton.icon} {primaryButton.icon}
{primaryButton.text} {primaryButton.text}
</PrimaryButton> </PrimaryButton>

View File

@ -21,7 +21,7 @@ export const ToggleSwitch: React.FC<Props> = (props) => {
size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10" size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10"
} flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${ } flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${
value ? "bg-custom-primary-100" : "bg-gray-700" value ? "bg-custom-primary-100" : "bg-gray-700"
} ${className || ""}`} } ${className || ""} ${disabled ? "cursor-not-allowed" : ""}`}
> >
<span className="sr-only">{label}</span> <span className="sr-only">{label}</span>
<span <span
@ -36,7 +36,7 @@ export const ToggleSwitch: React.FC<Props> = (props) => {
? "translate-x-4" ? "translate-x-4"
: "translate-x-5") + " bg-white" : "translate-x-5") + " bg-white"
: "translate-x-0.5 bg-custom-background-90" : "translate-x-0.5 bg-custom-background-90"
}`} } ${disabled ? "cursor-not-allowed" : ""}`}
/> />
</Switch> </Switch>
); );

View File

@ -1,24 +1,23 @@
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
// headless ui
import { Transition } from "@headlessui/react"; import { Transition } from "@headlessui/react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks // hooks
import useTheme from "hooks/use-theme";
import useOutsideClickDetector from "hooks/use-outside-click-detector"; import useOutsideClickDetector from "hooks/use-outside-click-detector";
// icons // icons
import { Bolt, HelpOutlineOutlined, WestOutlined } from "@mui/icons-material"; import { Bolt, HelpOutlineOutlined, WestOutlined } from "@mui/icons-material";
import { ChatBubbleOvalLeftEllipsisIcon } from "@heroicons/react/24/outline"; import { DiscordIcon } from "components/icons";
import { DocumentIcon, DiscordIcon, GithubIcon } from "components/icons"; import { FileText, Github, MessagesSquare } from "lucide-react";
// mobx store // assets
import { useMobxStore } from "lib/mobx/store-provider"; import packageJson from "package.json";
const helpOptions = [ const helpOptions = [
{ {
name: "Documentation", name: "Documentation",
href: "https://docs.plane.so/", href: "https://docs.plane.so/",
Icon: DocumentIcon, Icon: FileText,
}, },
{ {
name: "Join our Discord", name: "Join our Discord",
@ -28,13 +27,13 @@ const helpOptions = [
{ {
name: "Report a bug", name: "Report a bug",
href: "https://github.com/makeplane/plane/issues/new/choose", href: "https://github.com/makeplane/plane/issues/new/choose",
Icon: GithubIcon, Icon: Github,
}, },
{ {
name: "Chat with us", name: "Chat with us",
href: null, href: null,
onClick: () => (window as any).$crisp.push(["do", "chat:show"]), onClick: () => (window as any).$crisp.push(["do", "chat:show"]),
Icon: ChatBubbleOvalLeftEllipsisIcon, Icon: MessagesSquare,
}, },
]; ];
@ -123,37 +122,44 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = ({ setS
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<div <div
className={`absolute bottom-2 ${ className={`absolute bottom-2 min-w-[10rem] ${
store?.theme?.sidebarCollapsed ? "left-full" : "left-[-75px]" store?.theme?.sidebarCollapsed ? "left-full" : "-left-[75px]"
} space-y-2 rounded-sm bg-custom-background-80 p-1 shadow-md`} } rounded bg-custom-background-100 p-1 shadow-custom-shadow-xs whitespace-nowrap divide-y divide-custom-border-200`}
ref={helpOptionsRef} ref={helpOptionsRef}
> >
{helpOptions.map(({ name, Icon, href, onClick }) => { <div className="space-y-1 pb-2">
if (href) {helpOptions.map(({ name, Icon, href, onClick }) => {
return ( if (href)
<Link href={href} key={name}> return (
<a <Link href={href} key={name}>
target="_blank" <a
className="flex items-center gap-x-2 whitespace-nowrap rounded-md px-2 py-1 text-xs hover:bg-custom-background-90" target="_blank"
className="flex items-center gap-x-2 rounded px-2 py-1 text-xs hover:bg-custom-background-80"
>
<div className="grid place-items-center flex-shrink-0">
<Icon className="text-custom-text-200 h-3.5 w-3.5" size={14} />
</div>
<span className="text-xs">{name}</span>
</a>
</Link>
);
else
return (
<button
key={name}
type="button"
onClick={onClick ?? undefined}
className="flex w-full items-center gap-x-2 rounded px-2 py-1 text-xs hover:bg-custom-background-80"
> >
<Icon className="h-4 w-4 text-custom-text-200" /> <div className="grid place-items-center flex-shrink-0">
<span className="text-sm">{name}</span> <Icon className="text-custom-text-200 h-3.5 w-3.5" size={14} />
</a> </div>
</Link> <span className="text-xs">{name}</span>
); </button>
else );
return ( })}
<button </div>
key={name} <div className="px-2 pt-2 pb-1 text-[10px]">Version: v{packageJson.version}</div>
type="button"
onClick={onClick ? onClick : undefined}
className="flex w-full items-center gap-x-2 whitespace-nowrap rounded-md px-2 py-1 text-xs hover:bg-custom-background-90"
>
<Icon className="h-4 w-4 text-custom-sidebar-text-200" />
<span className="text-sm">{name}</span>
</button>
);
})}
</div> </div>
</Transition> </Transition>
</div> </div>

View File

@ -24,9 +24,12 @@ const MobxStoreInit = () => {
); );
// theme // theme
if (store.theme.theme === null && store?.user?.currentUserSettings) { if (
(store.theme.theme === null || store.theme.theme === "undefined") &&
store?.user?.currentUserSettings
) {
let currentTheme = localStorage.getItem("theme"); let currentTheme = localStorage.getItem("theme");
currentTheme = currentTheme ? currentTheme : "system"; currentTheme = currentTheme && currentTheme != "undefined" ? currentTheme : "system";
// validating the theme and applying for initial state // validating the theme and applying for initial state
if (currentTheme) { if (currentTheme) {

View File

@ -15,6 +15,7 @@ const nextConfig = {
"vinci-web.s3.amazonaws.com", "vinci-web.s3.amazonaws.com",
"planefs-staging.s3.ap-south-1.amazonaws.com", "planefs-staging.s3.ap-south-1.amazonaws.com",
"planefs.s3.amazonaws.com", "planefs.s3.amazonaws.com",
"planefs-staging.s3.amazonaws.com",
"images.unsplash.com", "images.unsplash.com",
"avatars.githubusercontent.com", "avatars.githubusercontent.com",
"localhost", "localhost",

View File

@ -1,6 +1,6 @@
{ {
"name": "web", "name": "web",
"version": "0.1.0", "version": "0.13.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --port 3000", "dev": "next dev --port 3000",

View File

@ -375,7 +375,7 @@ const Profile: NextPage = () => {
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between py-2">
<PrimaryButton type="submit" loading={isSubmitting}> <PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"} {isSubmitting ? "Updating Profile..." : "Update Profile"}
</PrimaryButton> </PrimaryButton>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@ import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import useSWR, { mutate } from "swr";
// services // services
import projectService from "services/project.service"; import projectService from "services/project.service";
@ -21,7 +21,7 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
import type { NextPage } from "next"; import type { NextPage } from "next";
import { IProject } from "types"; import { IProject } from "types";
// constant // constant
import { PROJECTS_LIST, PROJECT_DETAILS } from "constants/fetch-keys"; import { PROJECTS_LIST, PROJECT_DETAILS, USER_PROJECT_VIEW } from "constants/fetch-keys";
// helper // helper
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
@ -34,6 +34,13 @@ const AutomationsSettings: NextPage = () => {
const { projectDetails } = useProjectDetails(); const { projectDetails } = useProjectDetails();
const { data: memberDetails } = useSWR(
workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null,
workspaceSlug && projectId
? () => projectService.projectMemberMe(workspaceSlug.toString(), projectId.toString())
: null
);
const handleChange = async (formData: Partial<IProject>) => { const handleChange = async (formData: Partial<IProject>) => {
if (!workspaceSlug || !projectId || !projectDetails) return; if (!workspaceSlug || !projectId || !projectDetails) return;
@ -62,6 +69,8 @@ const AutomationsSettings: NextPage = () => {
}); });
}; };
const isAdmin = memberDetails?.role === 20;
return ( return (
<ProjectAuthorizationWrapper <ProjectAuthorizationWrapper
breadcrumbs={ breadcrumbs={
@ -79,12 +88,20 @@ const AutomationsSettings: NextPage = () => {
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar /> <SettingsSidebar />
</div> </div>
<section className="pr-9 py-8 w-full overflow-y-auto"> <section className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
<div className="flex items-center py-3.5 border-b border-custom-border-200"> <div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Automations</h3> <h3 className="text-xl font-medium">Automations</h3>
</div> </div>
<AutoArchiveAutomation projectDetails={projectDetails} handleChange={handleChange} /> <AutoArchiveAutomation
<AutoCloseAutomation projectDetails={projectDetails} handleChange={handleChange} /> projectDetails={projectDetails}
handleChange={handleChange}
disabled={!isAdmin}
/>
<AutoCloseAutomation
projectDetails={projectDetails}
handleChange={handleChange}
disabled={!isAdmin}
/>
</section> </section>
</div> </div>
</ProjectAuthorizationWrapper> </ProjectAuthorizationWrapper>

View File

@ -25,7 +25,7 @@ import { ContrastOutlined } from "@mui/icons-material";
import { IProject } from "types"; import { IProject } from "types";
import type { NextPage } from "next"; import type { NextPage } from "next";
// fetch-keys // fetch-keys
import { PROJECTS_LIST, PROJECT_DETAILS } from "constants/fetch-keys"; import { PROJECTS_LIST, PROJECT_DETAILS, USER_PROJECT_VIEW } from "constants/fetch-keys";
// helper // helper
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
@ -102,6 +102,13 @@ const FeaturesSettings: NextPage = () => {
: null : null
); );
const { data: memberDetails } = useSWR(
workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null,
workspaceSlug && projectId
? () => projectService.projectMemberMe(workspaceSlug.toString(), projectId.toString())
: null
);
const handleSubmit = async (formData: Partial<IProject>) => { const handleSubmit = async (formData: Partial<IProject>) => {
if (!workspaceSlug || !projectId || !projectDetails) return; if (!workspaceSlug || !projectId || !projectDetails) return;
@ -140,6 +147,8 @@ const FeaturesSettings: NextPage = () => {
); );
}; };
const isAdmin = memberDetails?.role === 20;
return ( return (
<ProjectAuthorizationWrapper <ProjectAuthorizationWrapper
breadcrumbs={ breadcrumbs={
@ -157,7 +166,7 @@ const FeaturesSettings: NextPage = () => {
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar /> <SettingsSidebar />
</div> </div>
<section className="pr-9 py-8 w-full overflow-y-auto"> <section className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
<div className="flex items-center py-3.5 border-b border-custom-border-200"> <div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Features</h3> <h3 className="text-xl font-medium">Features</h3>
</div> </div>
@ -199,6 +208,7 @@ const FeaturesSettings: NextPage = () => {
[feature.property]: !projectDetails?.[feature.property as keyof IProject], [feature.property]: !projectDetails?.[feature.property as keyof IProject],
}); });
}} }}
disabled={!isAdmin}
size="sm" size="sm"
/> />
</div> </div>

View File

@ -22,7 +22,7 @@ import emptyIntegration from "public/empty-state/integration.svg";
import { IProject } from "types"; import { IProject } from "types";
import type { NextPage } from "next"; import type { NextPage } from "next";
// fetch-keys // fetch-keys
import { PROJECT_DETAILS, WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; import { PROJECT_DETAILS, USER_PROJECT_VIEW, WORKSPACE_INTEGRATIONS } from "constants/fetch-keys";
// helper // helper
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
@ -45,6 +45,15 @@ const ProjectIntegrations: NextPage = () => {
: null : null
); );
const { data: memberDetails } = useSWR(
workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null,
workspaceSlug && projectId
? () => projectService.projectMemberMe(workspaceSlug.toString(), projectId.toString())
: null
);
const isAdmin = memberDetails?.role === 20;
return ( return (
<ProjectAuthorizationWrapper <ProjectAuthorizationWrapper
breadcrumbs={ breadcrumbs={
@ -62,7 +71,7 @@ const ProjectIntegrations: NextPage = () => {
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar /> <SettingsSidebar />
</div> </div>
<div className="pr-9 py-8 gap-10 w-full overflow-y-auto"> <div className={`pr-9 py-8 gap-10 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
<div className="flex items-center py-3.5 border-b border-custom-border-200"> <div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Integrations</h3> <h3 className="text-xl font-medium">Integrations</h3>
</div> </div>
@ -85,6 +94,7 @@ const ProjectIntegrations: NextPage = () => {
text: "Configure now", text: "Configure now",
onClick: () => router.push(`/${workspaceSlug}/settings/integrations`), onClick: () => router.push(`/${workspaceSlug}/settings/integrations`),
}} }}
disabled={!isAdmin}
/> />
) )
) : ( ) : (

View File

@ -43,6 +43,7 @@ import {
PROJECT_INVITATIONS_WITH_EMAIL, PROJECT_INVITATIONS_WITH_EMAIL,
PROJECT_MEMBERS, PROJECT_MEMBERS,
PROJECT_MEMBERS_WITH_EMAIL, PROJECT_MEMBERS_WITH_EMAIL,
USER_PROJECT_VIEW,
WORKSPACE_DETAILS, WORKSPACE_DETAILS,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
// constants // constants
@ -111,6 +112,13 @@ const MembersSettings: NextPage = () => {
: null : null
); );
const { data: memberDetails } = useSWR(
workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null,
workspaceSlug && projectId
? () => projectService.projectMemberMe(workspaceSlug.toString(), projectId.toString())
: null
);
const members = [ const members = [
...(projectMembers?.map((item) => ({ ...(projectMembers?.map((item) => ({
id: item.id, id: item.id,
@ -212,6 +220,8 @@ const MembersSettings: NextPage = () => {
}); });
}; };
const isAdmin = memberDetails?.role === 20;
return ( return (
<ProjectAuthorizationWrapper <ProjectAuthorizationWrapper
breadcrumbs={ breadcrumbs={
@ -277,7 +287,7 @@ const MembersSettings: NextPage = () => {
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar /> <SettingsSidebar />
</div> </div>
<section className="pr-9 py-8 w-full overflow-y-auto"> <section className={`pr-9 py-8 w-full overflow-y-auto`}>
<div className="flex items-center py-3.5 border-b border-custom-border-200"> <div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Defaults</h3> <h3 className="text-xl font-medium">Defaults</h3>
</div> </div>
@ -296,6 +306,7 @@ const MembersSettings: NextPage = () => {
onChange={(val: string) => { onChange={(val: string) => {
submitChanges({ project_lead: val }); submitChanges({ project_lead: val });
}} }}
isDisabled={!isAdmin}
/> />
)} )}
/> />
@ -320,6 +331,7 @@ const MembersSettings: NextPage = () => {
onChange={(val: string) => { onChange={(val: string) => {
submitChanges({ default_assignee: val }); submitChanges({ default_assignee: val });
}} }}
isDisabled={!isAdmin}
/> />
)} )}
/> />
@ -467,7 +479,7 @@ const MembersSettings: NextPage = () => {
); );
})} })}
</CustomSelect> </CustomSelect>
<CustomMenu ellipsis> <CustomMenu ellipsis disabled={!isAdmin}>
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => { onClick={() => {
if (member.member) setSelectedRemoveMember(member.id); if (member.member) setSelectedRemoveMember(member.id);

View File

@ -1,13 +1,14 @@
import React, { useEffect } from "react"; import React, { useEffect, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import type { NextPage } from "next"; import type { NextPage } from "next";
import { useTheme } from "next-themes";
import useSWR from "swr";
import { observer } from "mobx-react-lite";
// layouts // layouts
import DefaultLayout from "layouts/default-layout"; import DefaultLayout from "layouts/default-layout";
// services // services
import authenticationService from "services/authentication.service"; import authenticationService from "services/authentication.service";
import { AppConfigService } from "services/app-config.service";
// hooks // hooks
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
@ -17,19 +18,19 @@ import {
GithubLoginButton, GithubLoginButton,
EmailCodeForm, EmailCodeForm,
EmailPasswordForm, EmailPasswordForm,
EmailResetPasswordForm,
} from "components/account"; } from "components/account";
// ui // ui
import { Spinner } from "components/ui"; import { Spinner } from "components/ui";
// images // images
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// next themes // types
import { useTheme } from "next-themes";
import { IUser } from "types"; import { IUser } from "types";
const appConfig = new AppConfigService();
// types // types
type EmailPasswordFormValues = { type EmailPasswordFormValues = {
email: string; email: string;
@ -39,11 +40,16 @@ type EmailPasswordFormValues = {
const HomePage: NextPage = observer(() => { const HomePage: NextPage = observer(() => {
const store: any = useMobxStore(); const store: any = useMobxStore();
// theme
const { setTheme } = useTheme(); const { setTheme } = useTheme();
// user
const { isLoading, mutateUser } = useUserAuth("sign-in"); const { isLoading, mutateUser } = useUserAuth("sign-in");
// states
const [isResettingPassword, setIsResettingPassword] = useState(false);
// toast
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// fetch app config
const { data } = useSWR("APP_CONFIG", () => appConfig.envConfig());
const handleTheme = (user: IUser) => { const handleTheme = (user: IUser) => {
const currentTheme = user.theme.theme ?? "system"; const currentTheme = user.theme.theme ?? "system";
@ -79,11 +85,11 @@ const HomePage: NextPage = observer(() => {
const handleGitHubSignIn = async (credential: string) => { const handleGitHubSignIn = async (credential: string) => {
try { try {
if (process.env.NEXT_PUBLIC_GITHUB_ID && credential) { if (data && data.github && credential) {
const socialAuthPayload = { const socialAuthPayload = {
medium: "github", medium: "github",
credential, credential,
clientId: process.env.NEXT_PUBLIC_GITHUB_ID, clientId: data.github,
}; };
const response = await authenticationService.socialAuth(socialAuthPayload); const response = await authenticationService.socialAuth(socialAuthPayload);
if (response && response?.user) { if (response && response?.user) {
@ -149,10 +155,6 @@ const HomePage: NextPage = observer(() => {
} }
}; };
useEffect(() => {
setTheme("system");
}, [setTheme]);
return ( return (
<DefaultLayout> <DefaultLayout>
{isLoading ? ( {isLoading ? (
@ -173,38 +175,54 @@ const HomePage: NextPage = observer(() => {
</> </>
<div className="grid place-items-center h-full overflow-y-auto py-5 px-7"> <div className="grid place-items-center h-full overflow-y-auto py-5 px-7">
<div> <div>
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( <h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
{isResettingPassword ? "Reset your password" : "Sign in to Plane"}
</h1>
{isResettingPassword ? (
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} />
) : (
<> <>
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100"> {data?.email_password_login && (
Sign in to Plane <EmailPasswordForm
</h1> onSubmit={handlePasswordSignIn}
<div className="flex flex-col divide-y divide-custom-border-200"> setIsResettingPassword={setIsResettingPassword}
<div className="pb-7"> />
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} /> )}
</div> {data?.magic_login && (
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden"> <div className="flex flex-col divide-y divide-custom-border-200">
<GoogleLoginButton handleSignIn={handleGoogleSignIn} /> <div className="pb-7">
<GithubLoginButton handleSignIn={handleGitHubSignIn} /> <EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
</div>
</div> </div>
)}
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
{data?.google && (
<GoogleLoginButton
clientId={data?.google}
handleSignIn={handleGoogleSignIn}
/>
)}
{data?.github && (
<GithubLoginButton
clientId={data?.github}
handleSignIn={handleGitHubSignIn}
/>
)}
</div> </div>
</> </>
) : (
<EmailPasswordForm onSubmit={handlePasswordSignIn} />
)} )}
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( <p className="pt-16 text-custom-text-200 text-sm text-center">
<p className="pt-16 text-custom-text-200 text-sm text-center"> By signing up, you agree to the{" "}
By signing up, you agree to the{" "} <a
<a href="https://plane.so/terms-and-conditions"
href="https://plane.so/terms-and-conditions" target="_blank"
target="_blank" rel="noopener noreferrer"
rel="noopener noreferrer" className="font-medium underline"
className="font-medium underline" >
> Terms & Conditions
Terms & Conditions </a>
</a> </p>
</p>
) : null}
</div> </div>
</div> </div>
</> </>

View File

@ -1,8 +1,6 @@
import React, { useEffect, useState } 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";
// next-themes // next-themes
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
// services // services
@ -13,9 +11,7 @@ import useToast from "hooks/use-toast";
// layouts // layouts
import DefaultLayout from "layouts/default-layout"; import DefaultLayout from "layouts/default-layout";
// components // components
import { EmailPasswordForm } from "components/account"; import { EmailPasswordForm, EmailSignUpForm } from "components/account";
// ui
import { Spinner } from "components/ui";
// images // images
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
// types // types
@ -27,8 +23,6 @@ type EmailPasswordFormValues = {
}; };
const SignUp: NextPage = () => { const SignUp: NextPage = () => {
const [isLoading, setIsLoading] = useState(true);
const router = useRouter(); const router = useRouter();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -70,18 +64,6 @@ const SignUp: NextPage = () => {
setTheme("system"); setTheme("system");
}, [setTheme]); }, [setTheme]);
useEffect(() => {
if (parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0")) router.push("/");
else setIsLoading(false);
}, [router]);
if (isLoading)
return (
<div className="grid place-items-center h-screen w-full">
<Spinner />
</div>
);
return ( return (
<DefaultLayout> <DefaultLayout>
<> <>
@ -96,7 +78,8 @@ const SignUp: NextPage = () => {
</> </>
<div className="grid place-items-center h-full w-full overflow-y-auto py-5 px-7"> <div className="grid place-items-center h-full w-full overflow-y-auto py-5 px-7">
<div> <div>
<EmailPasswordForm onSubmit={handleSignUp} /> <h1 className="text-2xl text-center font-">SignUp on Plane</h1>
<EmailSignUpForm onSubmit={handleSignUp} />
</div> </div>
</div> </div>
</DefaultLayout> </DefaultLayout>

View File

@ -0,0 +1,30 @@
// services
import APIService from "services/api.service";
// helper
import { API_BASE_URL } from "helpers/common.helper";
export interface IEnvConfig {
github: string;
google: string;
github_app_name: string | null;
email_password_login: boolean;
magic_login: boolean;
}
export class AppConfigService extends APIService {
constructor() {
super(API_BASE_URL);
}
async envConfig(): Promise<IEnvConfig> {
return this.get("/api/configs/", {
headers: {
"Content-Type": "application/json",
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@ -76,21 +76,23 @@ class FileServices extends APIService {
}); });
} }
async getUnsplashImages(page: number = 1, query?: string): Promise<UnSplashImage[]> { async getUnsplashImages(query?: string): Promise<UnSplashImage[]> {
const url = "/api/unsplash"; return this.get(`/api/unsplash/`, {
return this.request({
method: "get",
url,
params: { params: {
page,
per_page: 20,
query, query,
}, },
}) })
.then((response) => response?.data?.results ?? response?.data) .then((res) => res?.data?.results ?? res?.data)
.catch((error) => { .catch((err) => {
throw error?.response?.data; throw err?.response?.data;
});
}
async getProjectCoverImages(): Promise<string[]> {
return this.get(`/api/project-covers/`)
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
}); });
} }
} }