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 (
@ -170,3 +168,5 @@ 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,6 +90,29 @@ if bool(os.environ.get("SENTRY_DSN", False)):
profiles_sample_rate=1.0, profiles_sample_rate=1.0,
) )
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
# 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. # The AWS region to connect to.
AWS_REGION = os.environ.get("AWS_REGION", "") AWS_REGION = os.environ.get("AWS_REGION", "")
@ -172,7 +196,6 @@ AWS_S3_FILE_OVERWRITE = False
STORAGES["default"] = { STORAGES["default"] = {
"BACKEND": "django_s3_storage.storage.S3Storage", "BACKEND": "django_s3_storage.storage.S3Storage",
} }
# AWS Settings End # AWS Settings End
# Enable Connection Pooling (if desired) # Enable Connection Pooling (if desired)
@ -193,6 +216,17 @@ CSRF_COOKIE_SECURE = True
REDIS_URL = os.environ.get("REDIS_URL") REDIS_URL = os.environ.get("REDIS_URL")
if DOCKERIZED:
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
else:
CACHES = { CACHES = {
"default": { "default": {
"BACKEND": "django_redis.cache.RedisCache", "BACKEND": "django_redis.cache.RedisCache",
@ -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 = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
else:
CELERY_BROKER_URL = broker_url 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,26 +104,21 @@ 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>
<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>
</>
) : (
<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">
{data?.google && <GoogleLoginButton clientId={data.google} handleSignIn={handleGoogleSignIn} />}
</div>
<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
@ -149,7 +130,6 @@ export const SignInView = observer(() => {
Terms & Conditions Terms & Conditions
</a> </a>
</p> </p>
) : null}
</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[] = [];
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({ router.push({
pathname: `/${workspace_slug}/${project_slug}`, pathname: `/${workspace_slug}/${project_slug}`,
query: { query: {
board: "list", board: currentBoard,
}, },
}); });
return projectStore.setActiveBoard("list");
} }
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

@ -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,16 +33,6 @@ 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">
{isResettingPassword
? "Reset your password"
: isSignUpPage
? "Sign up on Plane"
: "Sign in to Plane"}
</h1>
{isResettingPassword ? (
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} />
) : (
<form <form
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto" className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
@ -89,13 +70,6 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
/> />
</div> </div>
<div className="text-right text-xs"> <div className="text-right text-xs">
{isSignUpPage ? (
<Link href="/">
<a className="text-custom-text-200 hover:text-custom-primary-100">
Already have an account? Sign in.
</a>
</Link>
) : (
<button <button
type="button" type="button"
onClick={() => setIsResettingPassword(true)} onClick={() => setIsResettingPassword(true)}
@ -103,7 +77,6 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
> >
Forgot your password? Forgot your password?
</button> </button>
)}
</div> </div>
<div> <div>
<PrimaryButton <PrimaryButton
@ -112,24 +85,10 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
disabled={!isValid && isDirty} disabled={!isValid && isDirty}
loading={isSubmitting} loading={isSubmitting}
> >
{isSignUpPage {isSubmitting ? "Signing in..." : "Sign in"}
? isSubmitting
? "Signing up..."
: "Sign up"
: isSubmitting
? "Signing in..."
: "Sign in"}
</PrimaryButton> </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> </div>
</form> </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,12 +168,13 @@ export const ImagePickerPopover: React.FC<Props> = ({
> >
{tab.title} {tab.title}
</Tab> </Tab>
))} );
})}
</Tab.List> </Tab.List>
</div>
<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">
<div className="flex gap-x-2">
<Input <Input
name="search" name="search"
className="text-sm" className="text-sm"
@ -178,32 +187,87 @@ export const ImagePickerPopover: React.FC<Props> = ({
Search Search
</PrimaryButton> </PrimaryButton>
</div> </div>
{images ? ( {unsplashImages ? (
unsplashImages.length > 0 ? (
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
{images.map((image) => ( {unsplashImages.map((image) => (
<div <div
key={image.id} key={image.id}
className="relative col-span-2 aspect-video md:col-span-1" className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);
}}
> >
<img <img
src={image.urls.small} src={image.urls.small}
alt={image.alt_description} alt={image.alt_description}
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover" className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);
}}
/> />
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<div className="flex justify-center pt-20"> <p className="text-center text-custom-text-300 text-xs pt-7">
<Spinner /> No images found.
</div> </p>
)
) : (
<Loader className="grid grid-cols-4 gap-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>
<Tab.Panel className="h-full w-full pt-5"> )}
{(!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,6 +210,18 @@ export const SingleList: React.FC<Props> = (props) => {
if (!groupedIssues) return null; if (!groupedIssues) return null;
return ( return (
<>
<CreateUpdateDraftIssueModal
isOpen={isDraftIssuesModalOpen}
handleClose={() => 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,
}}
/>
<Disclosure as="div" defaultOpen> <Disclosure as="div" defaultOpen>
{({ open }) => ( {({ open }) => (
<div> <div>
@ -241,9 +255,9 @@ export const SingleList: React.FC<Props> = (props) => {
type="button" type="button"
className="p-1 text-custom-text-200 hover:bg-custom-background-80" 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);
}} }}
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
@ -331,19 +345,21 @@ export const SingleList: React.FC<Props> = (props) => {
prePopulatedData={{ prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }), ...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }), ...(moduleId && { module: moduleId.toString() }),
[displayFilters?.group_by!]: groupTitle, [displayFilters?.group_by! === "labels"
? "labels_list"
: displayFilters?.group_by!]:
displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle,
}} }}
/> />
{!disableAddIssueOption && !isCreateIssueFormOpen && ( {!disableAddIssueOption && !isCreateIssueFormOpen && !isDraftIssuesPage && (
// TODO: add border here
<div className="w-full bg-custom-background-100 px-6 py-3 border-b border-custom-border-100"> <div className="w-full bg-custom-background-100 px-6 py-3 border-b border-custom-border-100">
<button <button
type="button" type="button"
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" className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
> >
@ -357,5 +373,6 @@ export const SingleList: React.FC<Props> = (props) => {
</div> </div>
)} )}
</Disclosure> </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="h-[230px] w-[250px] overflow-auto rounded-[4px] border border-custom-border-200 bg-custom-background-80 p-2 shadow-xl" className="fixed z-10 mt-2 w-[250px] rounded-[4px] border border-custom-border-200 bg-custom-background-80 shadow-lg"
> >
<div className="h-[230px] w-[250px] overflow-auto rounded-[4px] border border-custom-border-200 bg-custom-background-80 p-2 shadow-xl">
<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,21 +122,24 @@ 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}
> >
<div className="space-y-1 pb-2">
{helpOptions.map(({ name, Icon, href, onClick }) => { {helpOptions.map(({ name, Icon, href, onClick }) => {
if (href) if (href)
return ( return (
<Link href={href} key={name}> <Link href={href} key={name}>
<a <a
target="_blank" target="_blank"
className="flex items-center gap-x-2 whitespace-nowrap rounded-md px-2 py-1 text-xs hover:bg-custom-background-90" className="flex 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} />
</div>
<span className="text-xs">{name}</span>
</a> </a>
</Link> </Link>
); );
@ -146,15 +148,19 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = ({ setS
<button <button
key={name} key={name}
type="button" type="button"
onClick={onClick ? onClick : undefined} 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" 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-sidebar-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} />
</div>
<span className="text-xs">{name}</span>
</button> </button>
); );
})} })}
</div> </div>
<div className="px-2 pt-2 pb-1 text-[10px]">Version: v{packageJson.version}</div>
</div>
</Transition> </Transition>
</div> </div>
</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,26 +175,43 @@ 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"> <h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
Sign in to Plane {isResettingPassword ? "Reset your password" : "Sign in to Plane"}
</h1> </h1>
{isResettingPassword ? (
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} />
) : (
<>
{data?.email_password_login && (
<EmailPasswordForm
onSubmit={handlePasswordSignIn}
setIsResettingPassword={setIsResettingPassword}
/>
)}
{data?.magic_login && (
<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>
<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 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
@ -204,7 +223,6 @@ const HomePage: NextPage = observer(() => {
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;
}); });
} }
} }