diff --git a/.github/workflows/Build_Test_Pull_Request.yml b/.github/workflows/build-test-pull-request.yml similarity index 100% rename from .github/workflows/Build_Test_Pull_Request.yml rename to .github/workflows/build-test-pull-request.yml diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml new file mode 100644 index 000000000..28e47a0d6 --- /dev/null +++ b/.github/workflows/create-sync-pr.yml @@ -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 < If running in a cloud env replace localhost with public facing IP address of the VM diff --git a/apiserver/.env.example b/apiserver/.env.example index 4969f1766..8193b5e77 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -1,7 +1,7 @@ # Backend # Debug value for api server use it as 0 for production use DEBUG=0 -DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" +DJANGO_SETTINGS_MODULE="plane.settings.production" # Error logs SENTRY_DSN="" @@ -59,3 +59,14 @@ DEFAULT_PASSWORD="password123" # SignUps 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" + diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index c10c4a745..2213c0d9d 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -70,6 +70,7 @@ from plane.api.views import ( ProjectIdentifierEndpoint, ProjectFavoritesViewSet, LeaveProjectEndpoint, + ProjectPublicCoverImagesEndpoint, ## End Projects # Issues IssueViewSet, @@ -150,12 +151,11 @@ from plane.api.views import ( GlobalSearchEndpoint, IssueSearchEndpoint, ## End Search - # Gpt + # External GPTIntegrationEndpoint, - ## End Gpt - # Release Notes ReleaseNotesEndpoint, - ## End Release Notes + UnsplashEndpoint, + ## End External # Inbox InboxViewSet, InboxIssueViewSet, @@ -186,6 +186,9 @@ from plane.api.views import ( ## Exporter ExportIssuesEndpoint, ## End Exporter + # Configuration + ConfigurationEndpoint, + ## End Configuration ) @@ -573,6 +576,11 @@ urlpatterns = [ LeaveProjectEndpoint.as_view(), name="project", ), + path( + "project-covers/", + ProjectPublicCoverImagesEndpoint.as_view(), + name="project-covers", + ), # End Projects # States path( @@ -1446,20 +1454,23 @@ urlpatterns = [ name="project-issue-search", ), ## End Search - # Gpt + # External path( "workspaces//projects//ai-assistant/", GPTIntegrationEndpoint.as_view(), name="importer", ), - ## End Gpt - # Release Notes path( "release-notes/", ReleaseNotesEndpoint.as_view(), name="release-notes", ), - ## End Release Notes + path( + "unsplash/", + UnsplashEndpoint.as_view(), + name="release-notes", + ), + ## End External # Inbox path( "workspaces//projects//inboxes/", @@ -1728,4 +1739,11 @@ urlpatterns = [ name="workspace-project-boards", ), ## End Public Boards + # Configuration + path( + "configs/", + ConfigurationEndpoint.as_view(), + name="configuration", + ), + ## End Configuration ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index c03d6d5b7..f7ad735c1 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -17,6 +17,7 @@ from .project import ( ProjectMemberEndpoint, WorkspaceProjectDeployBoardEndpoint, LeaveProjectEndpoint, + ProjectPublicCoverImagesEndpoint, ) from .user import ( UserEndpoint, @@ -147,16 +148,13 @@ from .page import ( from .search import GlobalSearchEndpoint, IssueSearchEndpoint -from .gpt import GPTIntegrationEndpoint +from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint from .estimate import ( ProjectEstimatePointEndpoint, BulkEstimatePointEndpoint, ) - -from .release import ReleaseNotesEndpoint - from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet from .analytic import ( @@ -169,4 +167,6 @@ from .analytic import ( from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet -from .exporter import ExportIssuesEndpoint \ No newline at end of file +from .exporter import ExportIssuesEndpoint + +from .config import ConfigurationEndpoint \ No newline at end of file diff --git a/apiserver/plane/api/views/config.py b/apiserver/plane/api/views/config.py new file mode 100644 index 000000000..ea1b39d9c --- /dev/null +++ b/apiserver/plane/api/views/config.py @@ -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, + ) diff --git a/apiserver/plane/api/views/gpt.py b/apiserver/plane/api/views/external.py similarity index 62% rename from apiserver/plane/api/views/gpt.py rename to apiserver/plane/api/views/external.py index 63c3f4f18..00a0270e4 100644 --- a/apiserver/plane/api/views/gpt.py +++ b/apiserver/plane/api/views/external.py @@ -2,9 +2,10 @@ import requests # Third party imports +import openai from rest_framework.response import Response from rest_framework import status -import openai +from rest_framework.permissions import AllowAny from sentry_sdk import capture_exception # Django imports @@ -15,6 +16,7 @@ from .base import BaseAPIView from plane.api.permissions import ProjectEntityPermission from plane.db.models import Workspace, Project from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer +from plane.utils.integrations.github import get_release_notes class GPTIntegrationEndpoint(BaseAPIView): @@ -73,3 +75,44 @@ class GPTIntegrationEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, 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, + ) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index c72b8d423..1ba227177 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -1,5 +1,6 @@ # Python imports import jwt +import boto3 from datetime import datetime # Django imports @@ -495,7 +496,7 @@ class ProjectMemberViewSet(BaseViewSet): serializer_class = ProjectMemberAdminSerializer model = ProjectMember permission_classes = [ - ProjectBasePermission, + ProjectMemberPermission, ] search_fields = [ @@ -617,7 +618,8 @@ class ProjectMemberViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) except ProjectMember.DoesNotExist: 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: capture_exception(e) @@ -1209,3 +1211,38 @@ class LeaveProjectEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, 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) diff --git a/apiserver/plane/api/views/release.py b/apiserver/plane/api/views/release.py deleted file mode 100644 index de827c896..000000000 --- a/apiserver/plane/api/views/release.py +++ /dev/null @@ -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, - ) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 753fd861b..8d518b160 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -1197,7 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView): projects = request.query_params.getlist("project", []) queryset = IssueActivity.objects.filter( - ~Q(field__in=["comment", "vote", "reaction"]), + ~Q(field__in=["comment", "vote", "reaction", "draft"]), workspace__slug=slug, project__project_projectmember__member=request.user, actor=user_id, diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 9d293c019..6f4833a6c 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -114,3 +114,6 @@ CELERY_BROKER_URL = os.environ.get("REDIS_URL") GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" + +# Unsplash Access key +UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index e434f9742..9c6bd95a9 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -7,6 +7,7 @@ import dj_database_url import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.redis import RedisIntegration +from urllib.parse import urlparse from .common import * # noqa @@ -89,90 +90,112 @@ if bool(os.environ.get("SENTRY_DSN", False)): profiles_sample_rate=1.0, ) -# The AWS region to connect to. -AWS_REGION = os.environ.get("AWS_REGION", "") +if DOCKERIZED and USE_MINIO: + 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. -AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "") + # Custom Domain settings + parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost")) + AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}" + AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:" +else: + # The AWS region to connect to. + AWS_REGION = os.environ.get("AWS_REGION", "") -# The AWS secret access key to use. -AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "") + # The AWS access key to use. + AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "") -# The optional AWS session token to use. -# AWS_SESSION_TOKEN = "" + # The AWS secret access key to use. + AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "") -# The name of the bucket to store files in. -AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") + # The optional AWS session token to use. + # AWS_SESSION_TOKEN = "" -# How to construct S3 URLs ("auto", "path", "virtual"). -AWS_S3_ADDRESSING_STYLE = "auto" + # The name of the bucket to store files in. + 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. -AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "") + # How to construct S3 URLs ("auto", "path", "virtual"). + AWS_S3_ADDRESSING_STYLE = "auto" -# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. -AWS_S3_KEY_PREFIX = "" + # 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", "") -# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication -# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, -# and their permissions will be set to "public-read". -AWS_S3_BUCKET_AUTH = False + # A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. + AWS_S3_KEY_PREFIX = "" -# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` -# is True. It also affects the "Cache-Control" header of the files. -# Important: Changing this setting will not affect existing files. -AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours. + # Whether to enable authentication for stored files. If True, then generated URLs will include an authentication + # token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, + # and their permissions will be set to "public-read". + AWS_S3_BUCKET_AUTH = False -# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting -# cannot be used with `AWS_S3_BUCKET_AUTH`. -AWS_S3_PUBLIC_URL = "" + # How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` + # is True. It also affects the "Cache-Control" header of the files. + # 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 -# understand the consequences before enabling. -# Important: Changing this setting will not affect existing files. -AWS_S3_REDUCED_REDUNDANCY = False + # A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting + # cannot be used with `AWS_S3_BUCKET_AUTH`. + AWS_S3_PUBLIC_URL = "" -# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a -# single `name` argument. -# Important: Changing this setting will not affect existing files. -AWS_S3_CONTENT_DISPOSITION = "" + # If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you + # understand the consequences before enabling. + # Important: Changing this setting will not affect existing files. + 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 -# single `name` argument. -# Important: Changing this setting will not affect existing files. -AWS_S3_CONTENT_LANGUAGE = "" + # The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a + # single `name` argument. + # Important: Changing this setting will not affect existing files. + AWS_S3_CONTENT_DISPOSITION = "" -# A mapping of custom metadata for each file. Each value can be a string, or a function taking a -# single `name` argument. -# Important: Changing this setting will not affect existing files. -AWS_S3_METADATA = {} + # The Content-Language header used when the file is downloaded. This can be a string, or a function taking a + # single `name` argument. + # Important: Changing this setting will not affect existing files. + AWS_S3_CONTENT_LANGUAGE = "" -# If True, then files will be stored using AES256 server-side encryption. -# If this is a string value (e.g., "aws:kms"), that encryption type will be used. -# Otherwise, server-side encryption is not be enabled. -# Important: Changing this setting will not affect existing files. -AWS_S3_ENCRYPT_KEY = False + # A mapping of custom metadata for each file. Each value can be a string, or a function taking a + # single `name` argument. + # Important: Changing this setting will not affect existing files. + AWS_S3_METADATA = {} -# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present. -# This is only relevant if AWS S3 KMS server-side encryption is enabled (above). -# AWS_S3_KMS_ENCRYPTION_KEY_ID = "" + # If True, then files will be stored using AES256 server-side encryption. + # If this is a string value (e.g., "aws:kms"), that encryption type will be used. + # 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 -# compressed size is smaller than their uncompressed size. -# Important: Changing this setting will not affect existing files. -AWS_S3_GZIP = True + # The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present. + # This is only relevant if AWS S3 KMS server-side encryption is enabled (above). + # AWS_S3_KMS_ENCRYPTION_KEY_ID = "" -# The signature version to use for S3 requests. -AWS_S3_SIGNATURE_VERSION = None + # If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their + # 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 -# extra characters appended. -AWS_S3_FILE_OVERWRITE = False + # The signature version to use for S3 requests. + AWS_S3_SIGNATURE_VERSION = None -STORAGES["default"] = { - "BACKEND": "django_s3_storage.storage.S3Storage", -} + # If True, then files with the same name will overwrite each other. By default it's set to False to have + # extra characters appended. + AWS_S3_FILE_OVERWRITE = False + STORAGES["default"] = { + "BACKEND": "django_s3_storage.storage.S3Storage", + } # AWS Settings End # Enable Connection Pooling (if desired) @@ -193,16 +216,27 @@ CSRF_COOKIE_SECURE = True REDIS_URL = os.environ.get("REDIS_URL") -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": REDIS_URL, - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, - }, +if DOCKERIZED: + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "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") @@ -225,8 +259,12 @@ broker_url = ( f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" ) -CELERY_RESULT_BACKEND = broker_url -CELERY_BROKER_URL = broker_url +if DOCKERIZED: + 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) @@ -238,3 +276,6 @@ SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False) SCOUT_KEY = os.environ.get("SCOUT_KEY", "") SCOUT_NAME = "Plane" +# Unsplash Access key +UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") + diff --git a/apiserver/plane/settings/selfhosted.py b/apiserver/plane/settings/selfhosted.py index 948ba22da..ee529a7c3 100644 --- a/apiserver/plane/settings/selfhosted.py +++ b/apiserver/plane/settings/selfhosted.py @@ -126,3 +126,4 @@ ANALYTICS_BASE_API = False OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") + diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index 5e274f8f3..f776afd91 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -218,3 +218,7 @@ CELERY_BROKER_URL = broker_url GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" + + +# Unsplash Access key +UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") diff --git a/package.json b/package.json index de09c6ee9..1f2f96414 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", + "version": "0.13.2", "license": "AGPL-3.0", "private": true, "workspaces": [ diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index 16fed7a78..12a7ab8c8 100644 --- a/packages/eslint-config-custom/package.json +++ b/packages/eslint-config-custom/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-custom", - "version": "0.0.0", + "version": "0.13.2", "main": "index.js", "license": "MIT", "dependencies": { diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index 1bd5a0e1c..6edaa0ec4 100644 --- a/packages/tailwind-config-custom/package.json +++ b/packages/tailwind-config-custom/package.json @@ -1,6 +1,6 @@ { "name": "tailwind-config-custom", - "version": "0.0.1", + "version": "0.13.2", "description": "common tailwind configuration across monorepo", "main": "index.js", "devDependencies": { diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index f4810fc3f..58bfb8451 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "tsconfig", - "version": "0.0.0", + "version": "0.13.2", "private": true, "files": [ "base.json", diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 000000000..ce447bf1c --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1 @@ +# UI Package diff --git a/packages/ui/package.json b/packages/ui/package.json index 6a9132fca..d107e711c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "ui", - "version": "0.0.0", + "version": "0.13.2", "main": "./index.tsx", "types": "./index.tsx", "license": "MIT", diff --git a/space/components/accounts/github-login-button.tsx b/space/components/accounts/github-login-button.tsx index e9b30ab73..b1bd586fe 100644 --- a/space/components/accounts/github-login-button.tsx +++ b/space/components/accounts/github-login-button.tsx @@ -10,9 +10,12 @@ import githubWhiteImage from "public/logos/github-white.svg"; export interface GithubLoginButtonProps { handleSignIn: React.Dispatch; + clientId: string; } -export const GithubLoginButton: FC = ({ handleSignIn }) => { +export const GithubLoginButton: FC = (props) => { + const { handleSignIn, clientId } = props; + // states const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); const [gitCode, setGitCode] = useState(null); @@ -38,7 +41,7 @@ export const GithubLoginButton: FC = ({ handleSignIn })
- {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( - <> -

- Sign in to Plane -

-
-
- -
-
- - {/* */} -
+

Sign in to Plane

+ {data?.email_password_login && } + + {data?.magic_login && ( +
+
+
- - ) : ( - +
)} - {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( -

- By signing up, you agree to the{" "} - - Terms & Conditions - -

- ) : null} +
+ {data?.google && } +
+ +

+ By signing up, you agree to the{" "} + + Terms & Conditions + +

diff --git a/space/components/issues/navbar/index.tsx b/space/components/issues/navbar/index.tsx index 509d676b7..03f082f33 100644 --- a/space/components/issues/navbar/index.tsx +++ b/space/components/issues/navbar/index.tsx @@ -44,19 +44,43 @@ const IssueNavbar = observer(() => { }, [projectStore, workspace_slug, project_slug]); useEffect(() => { - if (workspace_slug && project_slug) { - if (!board) { - router.push({ - pathname: `/${workspace_slug}/${project_slug}`, - query: { - board: "list", - }, - }); - return projectStore.setActiveBoard("list"); + if (workspace_slug && project_slug && projectStore?.deploySettings) { + const viewsAcceptable: string[] = []; + let currentBoard: string | null = null; + + if (projectStore?.deploySettings?.views?.list) viewsAcceptable.push("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"); + 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 (
@@ -105,7 +129,7 @@ const IssueNavbar = observer(() => {
) : (
- + Sign in diff --git a/space/components/views/login.tsx b/space/components/views/login.tsx index d01a22681..406d6be98 100644 --- a/space/components/views/login.tsx +++ b/space/components/views/login.tsx @@ -7,7 +7,13 @@ import { SignInView, UserLoggedIn } from "components/accounts"; export const LoginView = observer(() => { const { user: userStore } = useMobxStore(); - if (!userStore.currentUser) return ; - - return ; + return ( + <> + {userStore?.loader ? ( +
Loading
+ ) : ( + <>{userStore.currentUser ? : } + )} + + ); }); diff --git a/space/lib/mobx/store-init.tsx b/space/lib/mobx/store-init.tsx index 6e38d9c6d..4fc761ad1 100644 --- a/space/lib/mobx/store-init.tsx +++ b/space/lib/mobx/store-init.tsx @@ -3,12 +3,14 @@ import { useEffect } from "react"; // next imports import { useRouter } from "next/router"; +// js cookie +import Cookie from "js-cookie"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; const MobxStoreInit = () => { - const store: RootStore = useMobxStore(); + const { user: userStore }: RootStore = useMobxStore(); const router = useRouter(); 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]); + useEffect(() => { + const authToken = Cookie.get("accessToken") || null; + if (authToken) userStore.fetchCurrentUser(); + }, [userStore]); + return <>; }; diff --git a/space/package.json b/space/package.json index 6ce9ecefe..b539fbb65 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.0.1", + "version": "0.13.2", "private": true, "scripts": { "dev": "next dev -p 4000", diff --git a/space/pages/index.tsx b/space/pages/index.tsx new file mode 100644 index 000000000..1ff239253 --- /dev/null +++ b/space/pages/index.tsx @@ -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; diff --git a/space/pages/login/index.tsx b/space/pages/login/index.tsx index a80eff873..9f20f099f 100644 --- a/space/pages/login/index.tsx +++ b/space/pages/login/index.tsx @@ -5,4 +5,4 @@ import { LoginView } from "components/views"; const LoginPage = () => ; -export default LoginPage; \ No newline at end of file +export default LoginPage; diff --git a/space/services/app-config.service.ts b/space/services/app-config.service.ts new file mode 100644 index 000000000..713cda3da --- /dev/null +++ b/space/services/app-config.service.ts @@ -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 { + return this.get("/api/configs/", { + headers: { + "Content-Type": "application/json", + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/space/services/file.service.ts b/space/services/file.service.ts index d9783d29c..6df6423f4 100644 --- a/space/services/file.service.ts +++ b/space/services/file.service.ts @@ -74,24 +74,6 @@ class FileServices extends APIService { throw error?.response?.data; }); } - - async getUnsplashImages(page: number = 1, query?: string): Promise { - 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(); diff --git a/space/store/user.ts b/space/store/user.ts index 3a76c2111..cec2d340f 100644 --- a/space/store/user.ts +++ b/space/store/user.ts @@ -7,12 +7,17 @@ import { ActorDetail } from "types/issue"; import { IUser } from "types/user"; export interface IUserStore { + loader: boolean; + error: any | null; currentUser: any | null; fetchCurrentUser: () => void; currentActor: () => any; } class UserStore implements IUserStore { + loader: boolean = false; + error: any | null = null; + currentUser: IUser | null = null; // root store rootStore; @@ -22,6 +27,9 @@ class UserStore implements IUserStore { constructor(_rootStore: any) { makeObservable(this, { // observable + loader: observable.ref, + error: observable.ref, + currentUser: observable.ref, // actions setCurrentUser: action, @@ -73,14 +81,19 @@ class UserStore implements IUserStore { fetchCurrentUser = async () => { try { + this.loader = true; + this.error = null; const response = await this.userService.currentUser(); if (response) { runInAction(() => { + this.loader = false; this.currentUser = response; }); } } catch (error) { console.error("Failed to fetch current user", error); + this.loader = false; + this.error = error; } }; } diff --git a/turbo.json b/turbo.json index 59bbe741f..e40a56ab7 100644 --- a/turbo.json +++ b/turbo.json @@ -1,8 +1,6 @@ { "$schema": "https://turbo.build/schema.json", "globalEnv": [ - "NEXT_PUBLIC_GITHUB_ID", - "NEXT_PUBLIC_GOOGLE_CLIENTID", "NEXT_PUBLIC_API_BASE_URL", "NEXT_PUBLIC_DEPLOY_URL", "API_BASE_URL", @@ -12,8 +10,6 @@ "NEXT_PUBLIC_GITHUB_APP_NAME", "NEXT_PUBLIC_ENABLE_SENTRY", "NEXT_PUBLIC_ENABLE_OAUTH", - "NEXT_PUBLIC_UNSPLASH_ACCESS", - "NEXT_PUBLIC_UNSPLASH_ENABLED", "NEXT_PUBLIC_TRACK_EVENTS", "NEXT_PUBLIC_PLAUSIBLE_DOMAIN", "NEXT_PUBLIC_CRISP_ID", diff --git a/web/components/account/email-password-form.tsx b/web/components/account/email-password-form.tsx index bb341b371..7a95095ee 100644 --- a/web/components/account/email-password-form.tsx +++ b/web/components/account/email-password-form.tsx @@ -1,12 +1,5 @@ -import React, { useState } from "react"; - -import { useRouter } from "next/router"; -import Link from "next/link"; - -// react hook form +import React from "react"; import { useForm } from "react-hook-form"; -// components -import { EmailResetPasswordForm } from "components/account"; // ui import { Input, PrimaryButton } from "components/ui"; // types @@ -18,14 +11,12 @@ type EmailPasswordFormValues = { type Props = { onSubmit: (formData: EmailPasswordFormValues) => Promise; + setIsResettingPassword: (value: boolean) => void; }; -export const EmailPasswordForm: React.FC = ({ onSubmit }) => { - const [isResettingPassword, setIsResettingPassword] = useState(false); - - const router = useRouter(); - const isSignUpPage = router.pathname === "/sign-up"; - +export const EmailPasswordForm: React.FC = (props) => { + const { onSubmit, setIsResettingPassword } = props; + // form info const { register, handleSubmit, @@ -42,94 +33,62 @@ export const EmailPasswordForm: React.FC = ({ onSubmit }) => { return ( <> -

- {isResettingPassword - ? "Reset your password" - : isSignUpPage - ? "Sign up on Plane" - : "Sign in to Plane"} -

- {isResettingPassword ? ( - - ) : ( -
-
- - /^(([^<>()[\]\\.,;:\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]" - /> -
-
- -
-
- {isSignUpPage ? ( - - - Already have an account? Sign in. - - - ) : ( - - )} -
-
- - {isSignUpPage - ? isSubmitting - ? "Signing up..." - : "Sign up" - : isSubmitting - ? "Signing in..." - : "Sign in"} - - {!isSignUpPage && ( - - - Don{"'"}t have an account? Sign up. - - - )} -
-
- )} +
+
+ + /^(([^<>()[\]\\.,;:\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]" + /> +
+
+ +
+
+ +
+
+ + {isSubmitting ? "Signing in..." : "Sign in"} + +
+
); }; diff --git a/web/components/account/email-signup-form.tsx b/web/components/account/email-signup-form.tsx new file mode 100644 index 000000000..0a219741f --- /dev/null +++ b/web/components/account/email-signup-form.tsx @@ -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; +}; + +export const EmailSignUpForm: React.FC = (props) => { + const { onSubmit } = props; + + const { + register, + handleSubmit, + watch, + formState: { errors, isSubmitting, isValid, isDirty }, + } = useForm({ + defaultValues: { + email: "", + password: "", + confirm_password: "", + medium: "email", + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + return ( + <> +
+
+ + /^(([^<>()[\]\\.,;:\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]" + /> +
+
+ +
+
+ { + 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]" + /> +
+ +
+ + {isSubmitting ? "Signing up..." : "Sign up"} + +
+
+ + ); +}; diff --git a/web/components/account/github-login-button.tsx b/web/components/account/github-login-button.tsx index 2f4fcbc4d..9ea5b7df2 100644 --- a/web/components/account/github-login-button.tsx +++ b/web/components/account/github-login-button.tsx @@ -1,29 +1,27 @@ import { useEffect, useState, FC } from "react"; - import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/router"; - -// next-themes import { useTheme } from "next-themes"; // images import githubBlackImage from "/public/logos/github-black.png"; import githubWhiteImage from "/public/logos/github-white.png"; -const { NEXT_PUBLIC_GITHUB_ID } = process.env; - export interface GithubLoginButtonProps { handleSignIn: React.Dispatch; + clientId: string; } -export const GithubLoginButton: FC = ({ handleSignIn }) => { +export const GithubLoginButton: FC = (props) => { + const { handleSignIn, clientId } = props; + // states const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); const [gitCode, setGitCode] = useState(null); - + // router const { query: { code }, } = useRouter(); - + // theme const { theme } = useTheme(); useEffect(() => { @@ -42,7 +40,7 @@ export const GithubLoginButton: FC = ({ handleSignIn }) return (
@@ -70,6 +76,7 @@ export const AutoArchiveAutomation: React.FC = ({ projectDetails, handleC input verticalPosition="bottom" width="w-full" + disabled={disabled} > <> {PROJECT_AUTOMATION_MONTHS.map((month) => ( diff --git a/web/components/automation/auto-close-automation.tsx b/web/components/automation/auto-close-automation.tsx index 868d64557..063501036 100644 --- a/web/components/automation/auto-close-automation.tsx +++ b/web/components/automation/auto-close-automation.tsx @@ -24,9 +24,14 @@ import { getStatesList } from "helpers/state.helper"; type Props = { projectDetails: IProject | undefined; handleChange: (formData: Partial) => Promise; + disabled?: boolean; }; -export const AutoCloseAutomation: React.FC = ({ projectDetails, handleChange }) => { +export const AutoCloseAutomation: React.FC = ({ + projectDetails, + handleChange, + disabled = false, +}) => { const [monthModal, setmonthModal] = useState(false); const router = useRouter(); @@ -98,6 +103,7 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha : handleChange({ close_in: 0, default_state: null }) } size="sm" + disabled={disabled} />
@@ -119,6 +125,7 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha }} input width="w-full" + disabled={disabled} > <> {PROJECT_AUTOMATION_MONTHS.map((month) => ( diff --git a/web/components/core/image-picker-popover.tsx b/web/components/core/image-picker-popover.tsx index 957f1131c..cfe18cd97 100644 --- a/web/components/core/image-picker-popover.tsx +++ b/web/components/core/image-picker-popover.tsx @@ -1,32 +1,23 @@ import React, { useEffect, useState, useRef, useCallback } from "react"; - -// next import Image from "next/image"; import { useRouter } from "next/router"; - -// swr import useSWR from "swr"; - -// react-dropdown import { useDropzone } from "react-dropzone"; - -// headless ui import { Tab, Transition, Popover } from "@headlessui/react"; // services import fileService from "services/file.service"; - -// components -import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui"; // hooks import useWorkspaceDetails from "hooks/use-workspace-details"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; - -const unsplashEnabled = - process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" || - process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "1"; +// components +import { Input, PrimaryButton, SecondaryButton, Loader } from "components/ui"; const tabOptions = [ + { + key: "unsplash", + title: "Unsplash", + }, { key: "images", title: "Images", @@ -64,8 +55,22 @@ export const ImagePickerPopover: React.FC = ({ search: "", }); - const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchParams}`, () => - fileService.getUnsplashImages(1, searchParams) + const { data: unsplashImages, error: unsplashError } = useSWR( + `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(null); @@ -115,18 +120,17 @@ export const ImagePickerPopover: React.FC = ({ }; useEffect(() => { - if (!images || value !== null) return; - onChange(images[0].urls.regular); - }, [value, onChange, images]); + if (!unsplashImages || value !== null) return; + + onChange(unsplashImages[0].urls.regular); + }, [value, onChange, unsplashImages]); useOutsideClickDetector(imagePickerRef, () => setIsOpen(false)); - if (!unsplashEnabled) return null; - return ( setIsOpen((prev) => !prev)} disabled={disabled} > @@ -141,15 +145,19 @@ export const ImagePickerPopover: React.FC = ({ leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - +
-
- - {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 ( @@ -160,50 +168,106 @@ export const ImagePickerPopover: React.FC = ({ > {tab.title} - ))} - -
+ ); + })} + - -
- setFormData({ ...formData, search: e.target.value })} - placeholder="Search for images" - /> - setSearchParams(formData.search)} size="sm"> - Search - -
- {images ? ( -
- {images.map((image) => ( -
- {image.alt_description} { - setIsOpen(false); - onChange(image.urls.regular); - }} - /> + {(unsplashImages || !unsplashError) && ( + +
+ setFormData({ ...formData, search: e.target.value })} + placeholder="Search for images" + /> + setSearchParams(formData.search)} size="sm"> + Search + +
+ {unsplashImages ? ( + unsplashImages.length > 0 ? ( +
+ {unsplashImages.map((image) => ( +
{ + setIsOpen(false); + onChange(image.urls.regular); + }} + > + {image.alt_description} +
+ ))}
- ))} -
- ) : ( -
- -
- )} - - + ) : ( +

+ No images found. +

+ ) + ) : ( + + + + + + + + + + + )} +
+ )} + {(!projectCoverImages || projectCoverImages.length !== 0) && ( + + {projectCoverImages ? ( + projectCoverImages.length > 0 ? ( +
+ {projectCoverImages.map((image, index) => ( +
{ + setIsOpen(false); + onChange(image); + }} + > + {`Default +
+ ))} +
+ ) : ( +

+ No images found. +

+ ) + ) : ( + + + + + + + + + + + )} +
+ )} +
= (props) => { const [isCollapsed, setIsCollapsed] = useState(true); const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false); + const [isCreateDraftIssueModalOpen, setIsCreateDraftIssueModalOpen] = useState(false); const { displayFilters, groupedIssues } = viewProps; @@ -96,10 +98,27 @@ export const SingleBoard: React.FC = (props) => { scrollToBottom(); }; + const handleAddIssueToGroup = () => { + if (isDraftIssuesPage) setIsCreateDraftIssueModalOpen(true); + else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup(); + else onCreateClick(); + }; + return (
+ 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, + }} + /> + = (props) => { {displayFilters?.group_by !== "created_by" && (
{type === "issue" - ? !disableAddIssueOption && ( + ? !disableAddIssueOption && + !isDraftIssuesPage && ( ) - : !disableUserActions && ( + : !disableUserActions && + !isDraftIssuesPage && ( = (props) => { position="left" noBorder > - onCreateClick()}> + { + if (isDraftIssuesPage) setIsCreateDraftIssueModalOpen(true); + else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup(); + else onCreateClick(); + }} + > Create new {openIssuesListModal && ( diff --git a/web/components/core/views/list-view/single-list.tsx b/web/components/core/views/list-view/single-list.tsx index 9d3e9cb96..8cba67112 100644 --- a/web/components/core/views/list-view/single-list.tsx +++ b/web/components/core/views/list-view/single-list.tsx @@ -13,6 +13,7 @@ import projectService from "services/project.service"; // hooks import useProjects from "hooks/use-projects"; // components +import { CreateUpdateDraftIssueModal } from "components/issues"; import { SingleListIssue, ListInlineCreateIssueForm } from "components/core"; // ui import { Avatar, CustomMenu } from "components/ui"; @@ -75,6 +76,7 @@ export const SingleList: React.FC = (props) => { const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false); + const [isDraftIssuesModalOpen, setIsDraftIssuesModalOpen] = useState(false); const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues"; const isProfileIssuesPage = router.pathname.split("/")[2] === "profile"; @@ -208,154 +210,169 @@ export const SingleList: React.FC = (props) => { if (!groupedIssues) return null; return ( - - {({ open }) => ( -
-
- -
- {displayFilters?.group_by !== null && ( -
{getGroupIcon()}
- )} - {displayFilters?.group_by !== null ? ( -

- {getGroupTitle()} -

- ) : ( -

All Issues

- )} - - {groupedIssues[groupTitle as keyof IIssue].length} - -
-
- {isArchivedIssues ? ( - "" - ) : type === "issue" ? ( - !disableAddIssueOption && ( - - ) - ) : disableUserActions ? ( - "" - ) : ( - - -
- } - position="right" - noBorder - > - setIsCreateIssueFormOpen(true)}> - Create new - - {openIssuesListModal && ( - - Add an existing issue - - )} - - )} -
- - - {groupedIssues[groupTitle] ? ( - groupedIssues[groupTitle].length > 0 ? ( - groupedIssues[groupTitle].map((issue, index) => ( - 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} - /> - )) - ) : ( -

- No issues. -

- ) - ) : ( -
Loading...
- )} + <> + setIsDraftIssuesModalOpen(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, + }} + /> - setIsCreateIssueFormOpen(false)} - prePopulatedData={{ - ...(cycleId && { cycle: cycleId.toString() }), - ...(moduleId && { module: moduleId.toString() }), - [displayFilters?.group_by!]: groupTitle, - }} - /> - - {!disableAddIssueOption && !isCreateIssueFormOpen && ( - // TODO: add border here -
+ + {({ open }) => ( +
+
+ +
+ {displayFilters?.group_by !== null && ( +
{getGroupIcon()}
+ )} + {displayFilters?.group_by !== null ? ( +

+ {getGroupTitle()} +

+ ) : ( +

All Issues

+ )} + + {groupedIssues[groupTitle as keyof IIssue].length} + +
+
+ {isArchivedIssues ? ( + "" + ) : type === "issue" ? ( + !disableAddIssueOption && ( -
+ ) + ) : disableUserActions ? ( + "" + ) : ( + + +
+ } + position="right" + noBorder + > + setIsCreateIssueFormOpen(true)}> + Create new + + {openIssuesListModal && ( + + Add an existing issue + + )} + )} - - -
- )} -
+
+ + + {groupedIssues[groupTitle] ? ( + groupedIssues[groupTitle].length > 0 ? ( + groupedIssues[groupTitle].map((issue, index) => ( + 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} + /> + )) + ) : ( +

+ No issues. +

+ ) + ) : ( +
Loading...
+ )} + + 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 && ( +
+ +
+ )} +
+
+
+ )} + + ); }; diff --git a/web/components/emoji-icon-picker/index.tsx b/web/components/emoji-icon-picker/index.tsx index ab4eb022e..cc42dbec8 100644 --- a/web/components/emoji-icon-picker/index.tsx +++ b/web/components/emoji-icon-picker/index.tsx @@ -4,6 +4,7 @@ import { Tab, Transition, Popover } from "@headlessui/react"; // react colors import { TwitterPicker } from "react-color"; // hooks +import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // types import { Props } from "./types"; @@ -38,6 +39,7 @@ const EmojiIconPicker: React.FC = ({ const [recentEmojis, setRecentEmojis] = useState([]); + const buttonRef = useRef(null); const emojiPickerRef = useRef(null); useEffect(() => { @@ -49,10 +51,12 @@ const EmojiIconPicker: React.FC = ({ }, [value, onChange]); useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false)); + useDynamicDropdownPosition(isOpen, () => setIsOpen(false), buttonRef, emojiPickerRef); return ( setIsOpen((prev) => !prev)} className="outline-none" disabled={disabled} @@ -61,6 +65,8 @@ const EmojiIconPicker: React.FC = ({ = ({ leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - -
+ +
{tabOptions.map((tab) => ( diff --git a/web/components/gantt-chart/chart/index.tsx b/web/components/gantt-chart/chart/index.tsx index 61e3078d6..e21e803de 100644 --- a/web/components/gantt-chart/chart/index.tsx +++ b/web/components/gantt-chart/chart/index.tsx @@ -80,6 +80,9 @@ export const ChartViewRoot: FC = ({ const router = useRouter(); 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) => blocks && blocks.length > 0 ? blocks.map((block: any) => ({ @@ -317,7 +320,7 @@ export const ChartViewRoot: FC = ({ SidebarBlockRender={SidebarBlockRender} enableReorder={enableReorder} /> - {chartBlocks && ( + {chartBlocks && !(isCyclePage || isModulePage) && (
= ({ height={height} width={width} className={className} - viewBox="0 0 12 12" - fill="none" xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 323.15 323.03" > - + + + + + + + + ); diff --git a/web/components/icons/state/started.tsx b/web/components/icons/state/started.tsx index 7bc39f9f7..f4796548b 100644 --- a/web/components/icons/state/started.tsx +++ b/web/components/icons/state/started.tsx @@ -9,17 +9,38 @@ export const StateGroupStartedIcon: React.FC = ({ width = "20", height = "20", className, - color = "#f59e0b", + color = "#f39e1f", }) => ( - - + + + + + + + + + ); diff --git a/web/components/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx index 7433da82c..4a653dd86 100644 --- a/web/components/issues/draft-issue-form.tsx +++ b/web/components/issues/draft-issue-form.tsx @@ -8,6 +8,7 @@ import { Controller, useForm } from "react-hook-form"; import aiService from "services/ai.service"; // hooks import useToast from "hooks/use-toast"; +import useLocalStorage from "hooks/use-local-storage"; // components import { GptAssistantModal } from "components/core"; import { ParentIssuesListModal } from "components/issues"; @@ -60,6 +61,7 @@ interface IssueFormProps { action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" ) => Promise; data?: Partial | null; + isOpen: boolean; prePopulatedData?: Partial | null; projectId: string; setActiveProject: React.Dispatch>; @@ -89,6 +91,7 @@ export const DraftIssueForm: FC = (props) => { const { handleFormSubmit, data, + isOpen, prePopulatedData, projectId, setActiveProject, @@ -109,6 +112,8 @@ export const DraftIssueForm: FC = (props) => { const [gptAssistantModal, setGptAssistantModal] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); + const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {}); + const editorRef = useRef(null); const router = useRouter(); @@ -133,6 +138,33 @@ export const DraftIssueForm: FC = (props) => { const issueName = watch("name"); + const payload: Partial = { + 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 = () => { handleClose(); }; @@ -273,7 +305,7 @@ export const DraftIssueForm: FC = (props) => { )}
- handleCreateUpdateIssue(formData, "convertToNewIssue") + handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createDraft") )} >
diff --git a/web/components/issues/draft-issue-modal.tsx b/web/components/issues/draft-issue-modal.tsx index b6479d067..ca37ae03f 100644 --- a/web/components/issues/draft-issue-modal.tsx +++ b/web/components/issues/draft-issue-modal.tsx @@ -385,6 +385,7 @@ export const CreateUpdateDraftIssueModal: React.FC = (props) = > = (props) => { const issueName = watch("name"); const payload: Partial = { - name: getValues("name"), - description: getValues("description"), - state: getValues("state"), - priority: getValues("priority"), - assignees: getValues("assignees"), - labels: getValues("labels"), - start_date: getValues("start_date"), - target_date: getValues("target_date"), - project: getValues("project"), - parent: getValues("parent"), - cycle: getValues("cycle"), - module: getValues("module"), + 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(() => { diff --git a/web/components/issues/sidebar-select/blocked.tsx b/web/components/issues/sidebar-select/blocked.tsx index d7e448377..a4ebe0108 100644 --- a/web/components/issues/sidebar-select/blocked.tsx +++ b/web/components/issues/sidebar-select/blocked.tsx @@ -14,7 +14,7 @@ import { ExistingIssuesListModal } from "components/core"; import { XMarkIcon } from "@heroicons/react/24/outline"; import { BlockedIcon } from "components/icons"; // types -import { BlockeIssueDetail, IIssue, ISearchIssueResponse, UserAuth } from "types"; +import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types"; type Props = { issueId?: string; @@ -41,6 +41,9 @@ export const SidebarBlockedSelect: React.FC = ({ setIsBlockedModalOpen(false); }; + const blockedByIssue = + watch("related_issues")?.filter((i) => i.relation_type === "blocked_by") || []; + const onSubmit = async (data: ISearchIssueResponse[]) => { if (data.length === 0) { setToastAlert({ @@ -80,18 +83,13 @@ export const SidebarBlockedSelect: React.FC = ({ }) .then((response) => { submitChanges({ - related_issues: [ - ...watch("related_issues")?.filter((i) => i.relation_type !== "blocked_by"), - ...response, - ], + related_issues: [...watch("related_issues"), ...response], }); }); handleClose(); }; - const blockedByIssue = watch("related_issues")?.filter((i) => i.relation_type === "blocked_by"); - return ( <> = (props) => { })), ], }) - .then((response) => { - submitChanges({ - related_issues: [...watch("related_issues"), ...(response ?? [])], - }); + .then(() => { + submitChanges(); }); handleClose(); diff --git a/web/components/issues/sidebar-select/relates-to.tsx b/web/components/issues/sidebar-select/relates-to.tsx index deadf4d20..b9e56dbeb 100644 --- a/web/components/issues/sidebar-select/relates-to.tsx +++ b/web/components/issues/sidebar-select/relates-to.tsx @@ -75,10 +75,8 @@ export const SidebarRelatesSelect: React.FC = (props) => { })), ], }) - .then((response) => { - submitChanges({ - related_issues: [...watch("related_issues"), ...(response ?? [])], - }); + .then(() => { + submitChanges(); }); handleClose(); diff --git a/web/components/issues/sidebar.tsx b/web/components/issues/sidebar.tsx index 5455192fb..e0c370eaf 100644 --- a/web/components/issues/sidebar.tsx +++ b/web/components/issues/sidebar.tsx @@ -53,7 +53,7 @@ import { copyTextToClipboard } from "helpers/string.helper"; // types import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types"; // fetch-keys -import { ISSUE_DETAILS } from "constants/fetch-keys"; +import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; import { ContrastIcon } from "components/icons"; type Props = { @@ -480,6 +480,7 @@ export const IssueDetailsSidebar: React.FC = ({ }, false ); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} @@ -500,6 +501,7 @@ export const IssueDetailsSidebar: React.FC = ({ }, false ); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} @@ -517,6 +519,7 @@ export const IssueDetailsSidebar: React.FC = ({ ...data, }; }); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} @@ -534,6 +537,7 @@ export const IssueDetailsSidebar: React.FC = ({ ...data, }; }); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} diff --git a/web/components/issues/sub-issues/issue.tsx b/web/components/issues/sub-issues/issue.tsx index d9c6fd303..a53d18bb9 100644 --- a/web/components/issues/sub-issues/issue.tsx +++ b/web/components/issues/sub-issues/issue.tsx @@ -1,7 +1,8 @@ import React from "react"; // next imports -import Link from "next/link"; import { useRouter } from "next/router"; +// swr +import { mutate } from "swr"; // lucide icons import { ChevronDown, @@ -13,6 +14,7 @@ import { Loader, } from "lucide-react"; // components +import { IssuePeekOverview } from "components/issues/peek-overview"; import { SubIssuesRootList } from "./issues-list"; import { IssueProperty } from "./properties"; // ui @@ -20,6 +22,8 @@ import { Tooltip, CustomMenu } from "components/ui"; // types import { ICurrentUserResponse, IIssue } from "types"; import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root"; +// fetch keys +import { SUB_ISSUES } from "constants/fetch-keys"; export interface ISubIssues { workspaceSlug: string; @@ -38,7 +42,6 @@ export interface ISubIssues { issueId: string, issue?: IIssue | null ) => void; - setPeekParentId: (id: string) => void; } export const SubIssues: React.FC = ({ @@ -54,14 +57,12 @@ export const SubIssues: React.FC = ({ handleIssuesLoader, copyText, handleIssueCrudOperation, - setPeekParentId, }) => { const router = useRouter(); + const { query } = router; + const { peekIssue } = query as { peekIssue: string }; const openPeekOverview = (issue_id: string) => { - const { query } = router; - - setPeekParentId(parentIssue?.id); router.push({ pathname: router.pathname, query: { ...query, peekIssue: issue_id }, @@ -199,7 +200,17 @@ export const SubIssues: React.FC = ({ handleIssuesLoader={handleIssuesLoader} copyText={copyText} handleIssueCrudOperation={handleIssueCrudOperation} - setPeekParentId={setPeekParentId} + /> + )} + + {peekIssue && peekIssue === issue?.id && ( + + parentIssue && parentIssue?.id && mutate(SUB_ISSUES(parentIssue?.id)) + } + projectId={issue?.project ?? ""} + workspaceSlug={workspaceSlug ?? ""} + readOnly={!editable} /> )}
diff --git a/web/components/issues/sub-issues/issues-list.tsx b/web/components/issues/sub-issues/issues-list.tsx index a713d6fb8..9fc77992e 100644 --- a/web/components/issues/sub-issues/issues-list.tsx +++ b/web/components/issues/sub-issues/issues-list.tsx @@ -27,7 +27,6 @@ export interface ISubIssuesRootList { issueId: string, issue?: IIssue | null ) => void; - setPeekParentId: (id: string) => void; } export const SubIssuesRootList: React.FC = ({ @@ -42,7 +41,6 @@ export const SubIssuesRootList: React.FC = ({ handleIssuesLoader, copyText, handleIssueCrudOperation, - setPeekParentId, }) => { const { data: issues, isLoading } = useSWR( workspaceSlug && projectId && parentIssue && parentIssue?.id @@ -83,7 +81,6 @@ export const SubIssuesRootList: React.FC = ({ handleIssuesLoader={handleIssuesLoader} copyText={copyText} handleIssueCrudOperation={handleIssueCrudOperation} - setPeekParentId={setPeekParentId} /> ))} diff --git a/web/components/issues/sub-issues/root.tsx b/web/components/issues/sub-issues/root.tsx index 352546eab..4b29b97c9 100644 --- a/web/components/issues/sub-issues/root.tsx +++ b/web/components/issues/sub-issues/root.tsx @@ -10,7 +10,6 @@ import { ExistingIssuesListModal } from "components/core"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { SubIssuesRootList } from "./issues-list"; import { ProgressBar } from "./progressbar"; -import { IssuePeekOverview } from "components/issues/peek-overview"; // ui import { CustomMenu } from "components/ui"; // hooks @@ -60,8 +59,6 @@ export const SubIssuesRoot: React.FC = ({ parentIssue, user }) = : null ); - const [peekParentId, setPeekParentId] = React.useState(""); - const [issuesLoader, setIssuesLoader] = React.useState({ visibility: [parentIssue?.id], delete: [], @@ -237,7 +234,6 @@ export const SubIssuesRoot: React.FC = ({ parentIssue, user }) = handleIssuesLoader={handleIssuesLoader} copyText={copyText} handleIssueCrudOperation={handleIssueCrudOperation} - setPeekParentId={setPeekParentId} />
)} @@ -363,13 +359,6 @@ export const SubIssuesRoot: React.FC = ({ parentIssue, user }) = )} )} - - peekParentId && peekIssue && mutateSubIssues(peekParentId)} - projectId={projectId ?? ""} - workspaceSlug={workspaceSlug ?? ""} - readOnly={!isEditable} - />
); }; diff --git a/web/components/project/create-project-modal.tsx b/web/components/project/create-project-modal.tsx index b6a2be8bb..5593d8c7c 100644 --- a/web/components/project/create-project-modal.tsx +++ b/web/components/project/create-project-modal.tsx @@ -393,7 +393,7 @@ export const CreateProjectModal: React.FC = ({ value={value} onChange={onChange} options={options} - buttonClassName="!px-2 shadow-md" + buttonClassName="border-[0.5px] !px-2 shadow-md" label={
{value ? ( diff --git a/web/components/project/member-select.tsx b/web/components/project/member-select.tsx index 64baa945d..4fcb04268 100644 --- a/web/components/project/member-select.tsx +++ b/web/components/project/member-select.tsx @@ -16,9 +16,10 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys"; type Props = { value: any; onChange: (val: string) => void; + isDisabled?: boolean; }; -export const MemberSelect: React.FC = ({ value, onChange }) => { +export const MemberSelect: React.FC = ({ value, onChange, isDisabled = false }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -79,6 +80,7 @@ export const MemberSelect: React.FC = ({ value, onChange }) => { position="right" width="w-full" onChange={onChange} + disabled={isDisabled} /> ); }; diff --git a/web/components/tiptap/index.tsx b/web/components/tiptap/index.tsx index 84f691c35..44076234e 100644 --- a/web/components/tiptap/index.tsx +++ b/web/components/tiptap/index.tsx @@ -89,7 +89,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => { onClick={() => { editor?.chain().focus().run(); }} - className={`tiptap-editor-container cursor-text ${editorClassNames}`} + className={`tiptap-editor-container relative cursor-text ${editorClassNames}`} > {editor && }
diff --git a/web/components/tiptap/table-menu/index.tsx b/web/components/tiptap/table-menu/index.tsx index 94f9c0f8d..daa8f6953 100644 --- a/web/components/tiptap/table-menu/index.tsx +++ b/web/components/tiptap/table-menu/index.tsx @@ -80,8 +80,6 @@ export const TableMenu = ({ editor }: { editor: any }) => { const range = selection.getRangeAt(0); const tableNode = findTableAncestor(range.startContainer); - let parent = tableNode?.parentElement; - if (tableNode) { const tableRect = tableNode.getBoundingClientRect(); const tableCenter = tableRect.left + tableRect.width / 2; @@ -90,18 +88,6 @@ export const TableMenu = ({ editor }: { editor: any }) => { const tableBottom = tableRect.bottom; setTableLocation({ bottom: tableBottom, left: menuLeft }); - - while (parent) { - if (!parent.classList.contains("disable-scroll")) - parent.classList.add("disable-scroll"); - parent = parent.parentElement; - } - } else { - const scrollDisabledContainers = document.querySelectorAll(".disable-scroll"); - - scrollDisabledContainers.forEach((container) => { - container.classList.remove("disable-scroll"); - }); } } }; @@ -115,13 +101,9 @@ export const TableMenu = ({ editor }: { editor: any }) => { return (
{items.map((item, index) => ( diff --git a/web/components/ui/dropdowns/custom-menu.tsx b/web/components/ui/dropdowns/custom-menu.tsx index 41450b2b3..f456804f0 100644 --- a/web/components/ui/dropdowns/custom-menu.tsx +++ b/web/components/ui/dropdowns/custom-menu.tsx @@ -46,6 +46,7 @@ const CustomMenu = ({ type="button" onClick={menuButtonOnClick} className={customButtonClassName} + disabled={disabled} > {customButton} diff --git a/web/components/ui/empty-state.tsx b/web/components/ui/empty-state.tsx index 098c3f152..e39b10801 100644 --- a/web/components/ui/empty-state.tsx +++ b/web/components/ui/empty-state.tsx @@ -16,6 +16,7 @@ type Props = { }; secondaryButton?: React.ReactNode; isFullScreen?: boolean; + disabled?: boolean; }; export const EmptyState: React.FC = ({ @@ -25,6 +26,7 @@ export const EmptyState: React.FC = ({ primaryButton, secondaryButton, isFullScreen = true, + disabled = false, }) => (
= ({ {description &&

{description}

}
{primaryButton && ( - + {primaryButton.icon} {primaryButton.text} diff --git a/web/components/ui/toggle-switch.tsx b/web/components/ui/toggle-switch.tsx index d6c512ad7..5ad9377de 100644 --- a/web/components/ui/toggle-switch.tsx +++ b/web/components/ui/toggle-switch.tsx @@ -21,7 +21,7 @@ export const ToggleSwitch: React.FC = (props) => { size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10" } flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${ value ? "bg-custom-primary-100" : "bg-gray-700" - } ${className || ""}`} + } ${className || ""} ${disabled ? "cursor-not-allowed" : ""}`} > {label} = (props) => { ? "translate-x-4" : "translate-x-5") + " bg-white" : "translate-x-0.5 bg-custom-background-90" - }`} + } ${disabled ? "cursor-not-allowed" : ""}`} /> ); diff --git a/web/components/workspace/help-section.tsx b/web/components/workspace/help-section.tsx index 965a9e8a3..9f35f337c 100644 --- a/web/components/workspace/help-section.tsx +++ b/web/components/workspace/help-section.tsx @@ -1,24 +1,23 @@ import React, { useRef, useState } from "react"; - import Link from "next/link"; - -// headless ui import { Transition } from "@headlessui/react"; + +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // hooks -import useTheme from "hooks/use-theme"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // icons import { Bolt, HelpOutlineOutlined, WestOutlined } from "@mui/icons-material"; -import { ChatBubbleOvalLeftEllipsisIcon } from "@heroicons/react/24/outline"; -import { DocumentIcon, DiscordIcon, GithubIcon } from "components/icons"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { DiscordIcon } from "components/icons"; +import { FileText, Github, MessagesSquare } from "lucide-react"; +// assets +import packageJson from "package.json"; const helpOptions = [ { name: "Documentation", href: "https://docs.plane.so/", - Icon: DocumentIcon, + Icon: FileText, }, { name: "Join our Discord", @@ -28,13 +27,13 @@ const helpOptions = [ { name: "Report a bug", href: "https://github.com/makeplane/plane/issues/new/choose", - Icon: GithubIcon, + Icon: Github, }, { name: "Chat with us", href: null, onClick: () => (window as any).$crisp.push(["do", "chat:show"]), - Icon: ChatBubbleOvalLeftEllipsisIcon, + Icon: MessagesSquare, }, ]; @@ -123,37 +122,44 @@ export const WorkspaceHelpSection: React.FC = ({ setS leaveTo="transform opacity-0 scale-95" >
- {helpOptions.map(({ name, Icon, href, onClick }) => { - if (href) - return ( - - + {helpOptions.map(({ name, Icon, href, onClick }) => { + if (href) + return ( + + +
+ +
+ {name} +
+ + ); + else + return ( + - ); - })} +
+ +
+ {name} + + ); + })} +
+
Version: v{packageJson.version}
diff --git a/web/lib/mobx/store-init.tsx b/web/lib/mobx/store-init.tsx index 5c2ee0558..771d8eaba 100644 --- a/web/lib/mobx/store-init.tsx +++ b/web/lib/mobx/store-init.tsx @@ -24,9 +24,12 @@ const MobxStoreInit = () => { ); // 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"); - currentTheme = currentTheme ? currentTheme : "system"; + currentTheme = currentTheme && currentTheme != "undefined" ? currentTheme : "system"; // validating the theme and applying for initial state if (currentTheme) { diff --git a/web/next.config.js b/web/next.config.js index 1d466aaf5..058a68b7d 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -15,6 +15,7 @@ const nextConfig = { "vinci-web.s3.amazonaws.com", "planefs-staging.s3.ap-south-1.amazonaws.com", "planefs.s3.amazonaws.com", + "planefs-staging.s3.amazonaws.com", "images.unsplash.com", "avatars.githubusercontent.com", "localhost", diff --git a/web/package.json b/web/package.json index 93fc9366e..e0cf4efed 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "0.1.0", + "version": "0.13.2", "private": true, "scripts": { "dev": "next dev --port 3000", diff --git a/web/pages/[workspaceSlug]/me/profile/index.tsx b/web/pages/[workspaceSlug]/me/profile/index.tsx index 40ba8ecf3..8738ff46f 100644 --- a/web/pages/[workspaceSlug]/me/profile/index.tsx +++ b/web/pages/[workspaceSlug]/me/profile/index.tsx @@ -375,7 +375,7 @@ const Profile: NextPage = () => {
- {isSubmitting ? "Updating Project..." : "Update Project"} + {isSubmitting ? "Updating Profile..." : "Update Profile"}
diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx index 5dcea0838..1f74a74c3 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useRouter } from "next/router"; -import { mutate } from "swr"; +import useSWR, { mutate } from "swr"; // services import projectService from "services/project.service"; @@ -21,7 +21,7 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import type { NextPage } from "next"; import { IProject } from "types"; // constant -import { PROJECTS_LIST, PROJECT_DETAILS } from "constants/fetch-keys"; +import { PROJECTS_LIST, PROJECT_DETAILS, USER_PROJECT_VIEW } from "constants/fetch-keys"; // helper import { truncateText } from "helpers/string.helper"; @@ -34,6 +34,13 @@ const AutomationsSettings: NextPage = () => { 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) => { if (!workspaceSlug || !projectId || !projectDetails) return; @@ -62,6 +69,8 @@ const AutomationsSettings: NextPage = () => { }); }; + const isAdmin = memberDetails?.role === 20; + return ( {
-
+

Automations

- - + +
diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/features.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/features.tsx index 499aaea86..ba74cf2a5 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/features.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/features.tsx @@ -25,7 +25,7 @@ import { ContrastOutlined } from "@mui/icons-material"; import { IProject } from "types"; import type { NextPage } from "next"; // fetch-keys -import { PROJECTS_LIST, PROJECT_DETAILS } from "constants/fetch-keys"; +import { PROJECTS_LIST, PROJECT_DETAILS, USER_PROJECT_VIEW } from "constants/fetch-keys"; // helper import { truncateText } from "helpers/string.helper"; @@ -102,6 +102,13 @@ const FeaturesSettings: NextPage = () => { : 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) => { if (!workspaceSlug || !projectId || !projectDetails) return; @@ -140,6 +147,8 @@ const FeaturesSettings: NextPage = () => { ); }; + const isAdmin = memberDetails?.role === 20; + return ( {
-
+

Features

@@ -199,6 +208,7 @@ const FeaturesSettings: NextPage = () => { [feature.property]: !projectDetails?.[feature.property as keyof IProject], }); }} + disabled={!isAdmin} size="sm" />
diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx index ca64b8e22..181c9f8ec 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx @@ -22,7 +22,7 @@ import emptyIntegration from "public/empty-state/integration.svg"; import { IProject } from "types"; import type { NextPage } from "next"; // fetch-keys -import { PROJECT_DETAILS, WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; +import { PROJECT_DETAILS, USER_PROJECT_VIEW, WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; // helper import { truncateText } from "helpers/string.helper"; @@ -45,6 +45,15 @@ const ProjectIntegrations: NextPage = () => { : 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 ( {
-
+

Integrations

@@ -85,6 +94,7 @@ const ProjectIntegrations: NextPage = () => { text: "Configure now", onClick: () => router.push(`/${workspaceSlug}/settings/integrations`), }} + disabled={!isAdmin} /> ) ) : ( diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/members.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/members.tsx index 59e218ee4..51143d868 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/members.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/members.tsx @@ -43,6 +43,7 @@ import { PROJECT_INVITATIONS_WITH_EMAIL, PROJECT_MEMBERS, PROJECT_MEMBERS_WITH_EMAIL, + USER_PROJECT_VIEW, WORKSPACE_DETAILS, } from "constants/fetch-keys"; // constants @@ -111,6 +112,13 @@ const MembersSettings: NextPage = () => { : null ); + const { data: memberDetails } = useSWR( + workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null, + workspaceSlug && projectId + ? () => projectService.projectMemberMe(workspaceSlug.toString(), projectId.toString()) + : null + ); + const members = [ ...(projectMembers?.map((item) => ({ id: item.id, @@ -212,6 +220,8 @@ const MembersSettings: NextPage = () => { }); }; + const isAdmin = memberDetails?.role === 20; + return ( {
-
+

Defaults

@@ -296,6 +306,7 @@ const MembersSettings: NextPage = () => { onChange={(val: string) => { submitChanges({ project_lead: val }); }} + isDisabled={!isAdmin} /> )} /> @@ -320,6 +331,7 @@ const MembersSettings: NextPage = () => { onChange={(val: string) => { submitChanges({ default_assignee: val }); }} + isDisabled={!isAdmin} /> )} /> @@ -467,7 +479,7 @@ const MembersSettings: NextPage = () => { ); })} - + { if (member.member) setSelectedRemoveMember(member.id); diff --git a/web/pages/index.tsx b/web/pages/index.tsx index dec63f9f4..cccd32407 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -1,13 +1,14 @@ -import React, { useEffect } from "react"; - +import React, { useEffect, useState } from "react"; import Image from "next/image"; - import type { NextPage } from "next"; - +import { useTheme } from "next-themes"; +import useSWR from "swr"; +import { observer } from "mobx-react-lite"; // layouts import DefaultLayout from "layouts/default-layout"; // services import authenticationService from "services/authentication.service"; +import { AppConfigService } from "services/app-config.service"; // hooks import useUserAuth from "hooks/use-user-auth"; import useToast from "hooks/use-toast"; @@ -17,19 +18,19 @@ import { GithubLoginButton, EmailCodeForm, EmailPasswordForm, + EmailResetPasswordForm, } from "components/account"; // ui import { Spinner } from "components/ui"; // images import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; -// mobx react lite -import { observer } from "mobx-react-lite"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; -// next themes -import { useTheme } from "next-themes"; +// types import { IUser } from "types"; +const appConfig = new AppConfigService(); + // types type EmailPasswordFormValues = { email: string; @@ -39,11 +40,16 @@ type EmailPasswordFormValues = { const HomePage: NextPage = observer(() => { const store: any = useMobxStore(); + // theme const { setTheme } = useTheme(); - + // user const { isLoading, mutateUser } = useUserAuth("sign-in"); - + // states + const [isResettingPassword, setIsResettingPassword] = useState(false); + // toast const { setToastAlert } = useToast(); + // fetch app config + const { data } = useSWR("APP_CONFIG", () => appConfig.envConfig()); const handleTheme = (user: IUser) => { const currentTheme = user.theme.theme ?? "system"; @@ -79,11 +85,11 @@ const HomePage: NextPage = observer(() => { const handleGitHubSignIn = async (credential: string) => { try { - if (process.env.NEXT_PUBLIC_GITHUB_ID && credential) { + if (data && data.github && credential) { const socialAuthPayload = { medium: "github", credential, - clientId: process.env.NEXT_PUBLIC_GITHUB_ID, + clientId: data.github, }; const response = await authenticationService.socialAuth(socialAuthPayload); if (response && response?.user) { @@ -149,10 +155,6 @@ const HomePage: NextPage = observer(() => { } }; - useEffect(() => { - setTheme("system"); - }, [setTheme]); - return ( {isLoading ? ( @@ -173,38 +175,54 @@ const HomePage: NextPage = observer(() => {
- {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( +

+ {isResettingPassword ? "Reset your password" : "Sign in to Plane"} +

+ {isResettingPassword ? ( + + ) : ( <> -

- Sign in to Plane -

-
-
- -
-
- - + {data?.email_password_login && ( + + )} + {data?.magic_login && ( +
+
+ +
+ )} +
+ {data?.google && ( + + )} + {data?.github && ( + + )}
- ) : ( - )} - {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( -

- By signing up, you agree to the{" "} - - Terms & Conditions - -

- ) : null} +

+ By signing up, you agree to the{" "} + + Terms & Conditions + +

diff --git a/web/pages/sign-up.tsx b/web/pages/sign-up.tsx index 72a391ea4..d2d33032d 100644 --- a/web/pages/sign-up.tsx +++ b/web/pages/sign-up.tsx @@ -1,8 +1,6 @@ -import React, { useEffect, useState } from "react"; - +import React, { useEffect } from "react"; import Image from "next/image"; import { useRouter } from "next/router"; - // next-themes import { useTheme } from "next-themes"; // services @@ -13,9 +11,7 @@ import useToast from "hooks/use-toast"; // layouts import DefaultLayout from "layouts/default-layout"; // components -import { EmailPasswordForm } from "components/account"; -// ui -import { Spinner } from "components/ui"; +import { EmailPasswordForm, EmailSignUpForm } from "components/account"; // images import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; // types @@ -27,8 +23,6 @@ type EmailPasswordFormValues = { }; const SignUp: NextPage = () => { - const [isLoading, setIsLoading] = useState(true); - const router = useRouter(); const { setToastAlert } = useToast(); @@ -70,18 +64,6 @@ const SignUp: NextPage = () => { setTheme("system"); }, [setTheme]); - useEffect(() => { - if (parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0")) router.push("/"); - else setIsLoading(false); - }, [router]); - - if (isLoading) - return ( -
- -
- ); - return ( <> @@ -96,7 +78,8 @@ const SignUp: NextPage = () => {
- +

SignUp on Plane

+
diff --git a/web/services/app-config.service.ts b/web/services/app-config.service.ts new file mode 100644 index 000000000..713cda3da --- /dev/null +++ b/web/services/app-config.service.ts @@ -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 { + return this.get("/api/configs/", { + headers: { + "Content-Type": "application/json", + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/web/services/file.service.ts b/web/services/file.service.ts index cbed73fc8..15cdc1786 100644 --- a/web/services/file.service.ts +++ b/web/services/file.service.ts @@ -76,21 +76,23 @@ class FileServices extends APIService { }); } - async getUnsplashImages(page: number = 1, query?: string): Promise { - const url = "/api/unsplash"; - - return this.request({ - method: "get", - url, + async getUnsplashImages(query?: string): Promise { + return this.get(`/api/unsplash/`, { params: { - page, - per_page: 20, query, }, }) - .then((response) => response?.data?.results ?? response?.data) - .catch((error) => { - throw error?.response?.data; + .then((res) => res?.data?.results ?? res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } + + async getProjectCoverImages(): Promise { + return this.get(`/api/project-covers/`) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; }); } }