Compare commits

...
Author SHA1 Message Date
pablohashescobar 1604f4585d dev: create project settings 2023-10-12 13:51:32 +05:30
pablohashescobar 03f9ca45d8 dev: create model for project setting 2023-10-11 19:24:45 +05:30
Anmol Singh BhatiaandGitHub 58ea4d6ec9 chore: integrate popper js (#2398)
* chore: react-popper-js added

* chore: integrate popper js in issue properties dropdown

* chore: integrate popper js in custom menu component

* chore: integrate popper js in custom select component

* chore: integrate popper js in custom search select component

* chore: popper js placement type added

* chore: popper js placement type added
2023-10-11 12:05:53 +05:30
sriram veeraghantaandGitHub d88eb09fad Merge pull request #2271 from makeplane/chore/date_filters
chore: date filters
2023-10-09 16:01:56 +05:30
NikhilandGitHub 0f31585620 Merge branch 'develop' into chore/date_filters 2023-10-09 16:00:52 +05:30
sriram veeraghantaandGitHub 0252734e00 Merge pull request #2400 from makeplane/dev/sync-ce-master-to-ee-develop
dev: sync CE master changes
2023-10-09 15:56:42 +05:30
Manish Gupta 4a380ae242 sync CE Master to EE Develop 2023-10-09 15:09:27 +05:30
sriram veeraghantaandGitHub 5ac2c270f7 Merge pull request #2385 from makeplane/stage-release
release: stage release to master
2023-10-05 14:52:59 +05:30
sriram veeraghantaandGitHub af61054c0b Merge pull request #2384 from makeplane/develop
promote: develop to stage release
2023-10-05 14:03:37 +05:30
guru_sainathandGitHub 67922f9c5d fix: removed default theme setting in the index page (#2382)
* fix: removed default theme setting in the index page

* fix: empty space
2023-10-05 13:47:51 +05:30
Aaryan KhandelwalandGitHub 85147db85f fix: state group icons (#2381) 2023-10-05 13:08:27 +05:30
guru_sainathandGitHub 62035f3bad fix: added user store variables in mobx store observable (#2380) 2023-10-05 12:04:09 +05:30
1c43d313d1 chore: remove unnecessary OAuth envs (#2378)
* chore: remove unnecessary oauth envs

* merge conflicts resolved

* fix: adding new service

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2023-10-04 20:43:05 +05:30
Aaryan KhandelwalandGitHub 77c1b90e6b feat: default project cover images tab on the change cover popover (#2375)
* feat: default project cover images tab

* chore: remove unnecessary env vars from turbo.json
2023-10-04 20:04:35 +05:30
64af5e2e75 chore: project covers endpoint (#2370)
* chore: project covers endpoint

* dev: remove print logs

* dev: formatting

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2023-10-04 19:53:58 +05:30
Anmol Singh BhatiaandGitHub 4ec2811388 fix: project setting member role validation (#2369)
* fix: project setting member role validation

* chore: opacity removed from member setting page

* chore: member setting page validation
2023-10-04 19:24:13 +05:30
9482cc3a73 fix: 404 when redirecting user clicks on Sign In button (#2349)
* fix: 404 when redirecting user to login page

* fix: next_path redirection not working

* fix: authentication workflow update in plane deploy

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2023-10-04 19:21:04 +05:30
NikhilandGitHub 18bcf55f78 dev: configuration endpoint for frontend client (#2355)
* dev: configuration endpoint for frontend clients

* dev: configuration enable magic and email/password signup

* dev: update unsplash keys

* dev: add unsplash API and add  env for magic login
2023-10-04 19:06:38 +05:30
sriram veeraghantaandGitHub ea2c1e2d06 fix: login process validation based on api config (#2361) 2023-10-04 18:55:29 +05:30
guru_sainathandGitHub 48c65c9c95 fix: handle cross project issues in the sub-issues. (#2357) 2023-10-04 18:44:36 +05:30
Aaryan KhandelwalandGitHub d0a51d75f6 fix: table menu positioning (#2354) 2023-10-04 18:36:14 +05:30
Aaryan KhandelwalandGitHub 7bdca9c73a show current version in the help section dropdown (#2353) 2023-10-04 18:31:28 +05:30
guru_sainathandGitHub 05920a72a5 chore: layout access validation and switch in plane deploy issues route (#2351)
* chore: handled route validation and layout access validation in plane deploy issues

* chore: impoved validation condition
2023-10-04 18:17:34 +05:30
sriram veeraghantaandGitHub 7a3b556ae0 adding sync info in pr title (#2373) 2023-10-04 18:03:22 +05:30
Bavisetti NarayanandGitHub c2f0ae1ec5 chore: removed the issue draft log from my profile (#2368) 2023-10-04 17:47:46 +05:30
Bavisetti NarayanandGitHub fb6f6454df chore: member can change role (#2371) 2023-10-04 17:47:01 +05:30
guru_sainathandGitHub 1e9149d872 fix: themening validation in store init. (#2350) 2023-10-04 17:44:54 +05:30
NikhilandGitHub eacf543439 dev: update apiserver configuration files (#2348)
* dev: update apiserver configuration files

* dev: add email and minio redirection urls
2023-10-04 17:39:12 +05:30
Luis CruzandGitHub 981e2aafdd Update index.tsx (#2343)
Fixes #2342
2023-10-04 17:37:01 +05:30
sriram veeraghantaandGitHub 4017f6bc55 fix: sync job pr description escaped values fix (#2366) 2023-10-04 15:29:45 +05:30
sriram veeraghantaandGitHub db1bcdb54f fix: sync workflow fixes (#2365) 2023-10-04 15:07:01 +05:30
Dakshesh JainandGitHub cecdf890de fix: issue relation mutation and draft issue (#2340)
* fix: issue relation mutation and draft issue

* fix: 'New Issue' in gantt view

fix: emoji select going under

* fix: profile page typo
2023-10-04 14:55:41 +05:30
d9bd07886f dev: update sync workflow to run only when the source repo is configured (#2346)
* dev: update sync workflow to run only when the source repo is configured

* fix: naming convention changes

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2023-10-03 18:41:26 +05:30
sriram veeraghantaandGitHub 0d0cf3052a fix: updated readme fixes (#2339) 2023-10-02 16:13:27 +05:30
NikhilandGitHub 6d463ded1c dev: rename token variables (#2338) 2023-10-02 14:50:30 +05:30
NikhilandGitHub de33e6775f dev: update add permissions to the action (#2337) 2023-10-02 14:40:31 +05:30
NikhilandGitHub c96960955b fix: variable name for token (#2336) 2023-10-02 14:31:25 +05:30
sriram veeraghantaandGitHub 7eba4a5032 fix: ui package readme added (#2334) 2023-10-02 13:40:28 +05:30
NikhilandGitHub c56aacdd13 dev: create action to sync PR changes to the repo (#2333) 2023-10-02 13:28:43 +05:30
sriram veeraghantaandGitHub 3ff47b38f4 Merge pull request #2319 from makeplane/stage-release
release: stage release to master
2023-09-29 20:12:15 +05:30
sriram veeraghantaandGitHub 25a973aaf9 Merge pull request #2318 from makeplane/develop
promote: develop to stage release
2023-09-29 20:10:41 +05:30
Anmol Singh BhatiaandGitHub ebf79ee2aa fix: workspace view add issue mutation fix (#2317) 2023-09-29 20:02:56 +05:30
sriram veeraghantaandGitHub f001852f69 Merge pull request #2316 from makeplane/develop
promote: develop to stage release
2023-09-29 19:25:02 +05:30
NikhilandGitHub 6b5fe0fff1 fix: update build workflow for the deploy app (#2315) 2023-09-29 19:14:07 +05:30
Anmol Singh BhatiaandGitHub f73135c504 fix: workspace view redirection fix, style: spreadsheet view shadow scroll fix (#2314)
* fix: workspace view redirection fix

* style: spreadsheet view scroll shadow fix
2023-09-29 19:03:36 +05:30
pablohashescobar 42d3919459 dev: refactor date filters to a single function 2023-09-27 14:33:41 +05:30
pablohashescobar 608ba9d5cb chore: udpate date filters to support dynamic options 2023-09-26 16:33:49 +05:30
pablohashescobar d47efaa0f0 dev: remove auto filter endpoint 2023-09-25 16:51:59 +05:30
126 changed files with 2422 additions and 1428 deletions
+79
View File
@@ -0,0 +1,79 @@
name: Create PR in Plane EE Repository to sync the changes
on:
pull_request:
branches:
- master
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
@@ -39,10 +39,10 @@ jobs:
type=ref,event=tag
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaDeploy
id: metaSpace
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-deploy
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space
tags: |
type=ref,event=tag
@@ -87,7 +87,7 @@ jobs:
file: ./space/Dockerfile.space
platforms: linux/amd64
push: true
tags: ${{ steps.metaDeploy.outputs.tags }}
tags: ${{ steps.metaSpace.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+1 -1
View File
@@ -54,7 +54,7 @@ chmod +x setup.sh
- Run setup.sh
```bash
./setup.sh http://localhost
./setup.sh
```
> If running in a cloud env replace localhost with public facing IP address of the VM
+12 -1
View File
@@ -1,7 +1,7 @@
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
DJANGO_SETTINGS_MODULE="plane.settings.selfhosted"
DJANGO_SETTINGS_MODULE="plane.settings.production"
# Error logs
SENTRY_DSN=""
@@ -59,3 +59,14 @@ DEFAULT_PASSWORD="password123"
# SignUps
ENABLE_SIGNUP="1"
# Enable Email/Password Signup
ENABLE_EMAIL_PASSWORD="1"
# Enable Magic link Login
ENABLE_MAGIC_LINK_LOGIN="0"
# Email redirections and minio domain settings
WEB_URL="http://localhost"
+3 -1
View File
@@ -11,7 +11,9 @@ from .workspace import (
)
from .project import (
ProjectSerializer,
ProjectSettingSerializer,
ProjectDetailSerializer,
ProjectSettingDetailSerializer,
ProjectMemberSerializer,
ProjectMemberInviteSerializer,
ProjectIdentifierSerializer,
@@ -20,7 +22,7 @@ from .project import (
ProjectMemberLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
ProjectPublicMemberSerializer
ProjectPublicMemberSerializer,
)
from .state import StateSerializer, StateLiteSerializer
from .view import GlobalViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer
+26 -4
View File
@@ -10,6 +10,7 @@ from plane.api.serializers.workspace import WorkSpaceSerializer, WorkspaceLiteSe
from plane.api.serializers.user import UserLiteSerializer, UserAdminLiteSerializer
from plane.db.models import (
Project,
ProjectSetting,
ProjectMember,
ProjectMemberInvite,
ProjectIdentifier,
@@ -96,8 +97,6 @@ class ProjectLiteSerializer(BaseSerializer):
class ProjectDetailSerializer(BaseSerializer):
workspace = WorkSpaceSerializer(read_only=True)
default_assignee = UserLiteSerializer(read_only=True)
project_lead = UserLiteSerializer(read_only=True)
is_favorite = serializers.BooleanField(read_only=True)
total_members = serializers.IntegerField(read_only=True)
total_cycles = serializers.IntegerField(read_only=True)
@@ -178,12 +177,12 @@ class ProjectDeployBoardSerializer(BaseSerializer):
fields = "__all__"
read_only_fields = [
"workspace",
"project", "anchor",
"project",
"anchor",
]
class ProjectPublicMemberSerializer(BaseSerializer):
class Meta:
model = ProjectPublicMember
fields = "__all__"
@@ -192,3 +191,26 @@ class ProjectPublicMemberSerializer(BaseSerializer):
"project",
"member",
]
class ProjectSettingSerializer(BaseSerializer):
class Meta:
model = ProjectSetting
fields = "__all__"
read_only_fields = [
"workspace",
"project",
]
class ProjectSettingDetailSerializer(BaseSerializer):
default_assignee = UserLiteSerializer(read_only=True)
project_lead = UserLiteSerializer(read_only=True)
class Meta:
model = ProjectSetting
fields = "__all__"
read_only_fields = [
"workspace",
"project",
]
+47 -14
View File
@@ -59,6 +59,7 @@ from plane.api.views import (
## End File Assets
# Projects
ProjectViewSet,
ProjectSettingViewSet,
InviteProjectEndpoint,
ProjectMemberViewSet,
ProjectMemberEndpoint,
@@ -70,6 +71,7 @@ from plane.api.views import (
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint,
## End Projects
# Issues
IssueViewSet,
@@ -105,7 +107,6 @@ from plane.api.views import (
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewViewSet,
ViewIssuesEndpoint,
IssueViewFavoriteViewSet,
## End Views
# Cycles
@@ -150,12 +151,11 @@ from plane.api.views import (
GlobalSearchEndpoint,
IssueSearchEndpoint,
## End Search
# Gpt
# External
GPTIntegrationEndpoint,
## End Gpt
# Release Notes
ReleaseNotesEndpoint,
## End Release Notes
UnsplashEndpoint,
## End External
# Inbox
InboxViewSet,
InboxIssueViewSet,
@@ -186,6 +186,9 @@ from plane.api.views import (
## Exporter
ExportIssuesEndpoint,
## End Exporter
# Configuration
ConfigurationEndpoint,
## End Configuration
)
@@ -479,6 +482,26 @@ urlpatterns = [
),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/settings/",
ProjectSettingViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/settings/",
ProjectSettingViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
}
),
name="project",
),
path(
"workspaces/<str:slug>/project-identifiers/",
ProjectIdentifierEndpoint.as_view(),
@@ -573,6 +596,11 @@ urlpatterns = [
LeaveProjectEndpoint.as_view(),
name="project",
),
path(
"project-covers/",
ProjectPublicCoverImagesEndpoint.as_view(),
name="project-covers",
),
# End Projects
# States
path(
@@ -649,11 +677,6 @@ urlpatterns = [
),
name="project-view",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:view_id>/issues/",
ViewIssuesEndpoint.as_view(),
name="project-view-issues",
),
path(
"workspaces/<str:slug>/views/",
GlobalViewViewSet.as_view(
@@ -1446,20 +1469,23 @@ urlpatterns = [
name="project-issue-search",
),
## End Search
# Gpt
# External
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
GPTIntegrationEndpoint.as_view(),
name="importer",
),
## End Gpt
# Release Notes
path(
"release-notes/",
ReleaseNotesEndpoint.as_view(),
name="release-notes",
),
## End Release Notes
path(
"unsplash/",
UnsplashEndpoint.as_view(),
name="release-notes",
),
## End External
# Inbox
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/",
@@ -1728,4 +1754,11 @@ urlpatterns = [
name="workspace-project-boards",
),
## End Public Boards
# Configuration
path(
"configs/",
ConfigurationEndpoint.as_view(),
name="configuration",
),
## End Configuration
]
+7 -6
View File
@@ -1,5 +1,6 @@
from .project import (
ProjectViewSet,
ProjectSettingViewSet,
ProjectMemberViewSet,
UserProjectInvitationsViewset,
InviteProjectEndpoint,
@@ -17,6 +18,7 @@ from .project import (
ProjectMemberEndpoint,
WorkspaceProjectDeployBoardEndpoint,
LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint,
)
from .user import (
UserEndpoint,
@@ -56,7 +58,7 @@ from .workspace import (
LeaveWorkspaceEndpoint,
)
from .state import StateViewSet
from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, IssueViewFavoriteViewSet
from .cycle import (
CycleViewSet,
CycleIssueViewSet,
@@ -147,16 +149,13 @@ from .page import (
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
from .gpt import GPTIntegrationEndpoint
from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint
from .estimate import (
ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint,
)
from .release import ReleaseNotesEndpoint
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
from .analytic import (
@@ -169,4 +168,6 @@ from .analytic import (
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
from .exporter import ExportIssuesEndpoint
from .exporter import ExportIssuesEndpoint
from .config import ConfigurationEndpoint
+40
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,
)
+4 -4
View File
@@ -9,7 +9,7 @@ from sentry_sdk import capture_exception
# Module imports
from .base import BaseViewSet, BaseAPIView
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Project, Estimate, EstimatePoint
from plane.db.models import ProjectSetting, Estimate, EstimatePoint
from plane.api.serializers import (
EstimateSerializer,
EstimatePointSerializer,
@@ -24,10 +24,10 @@ class ProjectEstimatePointEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
try:
project = Project.objects.get(workspace__slug=slug, pk=project_id)
if project.estimate_id is not None:
project_setting = ProjectSetting.objects.get(workspace__slug=slug, pk=project_id)
if project_setting.estimate_id is not None:
estimate_points = EstimatePoint.objects.filter(
estimate_id=project.estimate_id,
estimate_id=project_setting.estimate_id,
project_id=project_id,
workspace__slug=slug,
)
@@ -2,9 +2,10 @@
import requests
# Third party imports
import openai
from rest_framework.response import Response
from rest_framework import status
import openai
from rest_framework.permissions import AllowAny
from sentry_sdk import capture_exception
# Django imports
@@ -15,6 +16,7 @@ from .base import BaseAPIView
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Workspace, Project
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
from plane.utils.integrations.github import get_release_notes
class GPTIntegrationEndpoint(BaseAPIView):
@@ -73,3 +75,44 @@ class GPTIntegrationEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ReleaseNotesEndpoint(BaseAPIView):
def get(self, request):
try:
release_notes = get_release_notes()
return Response(release_notes, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class UnsplashEndpoint(BaseAPIView):
def get(self, request):
try:
query = request.GET.get("query", False)
page = request.GET.get("page", 1)
per_page = request.GET.get("per_page", 20)
url = (
f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}"
if query
else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}"
)
headers = {
"Content-Type": "application/json",
}
resp = requests.get(url=url, headers=headers)
return Response(resp.json(), status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+5 -5
View File
@@ -24,7 +24,6 @@ from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError
from django.db import IntegrityError
# Third Party imports
from rest_framework.response import Response
@@ -64,6 +63,7 @@ from plane.api.permissions import (
)
from plane.db.models import (
Project,
ProjectSetting,
Issue,
IssueActivity,
IssueComment,
@@ -84,7 +84,6 @@ from plane.db.models import (
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.export_task import issue_export_task
class IssueViewSet(BaseViewSet):
@@ -295,13 +294,14 @@ class IssueViewSet(BaseViewSet):
def create(self, request, slug, project_id):
try:
project = Project.objects.get(pk=project_id)
project_setting = ProjectSetting.objects.get(workspace__slug=slug, project_id=project_id)
serializer = IssueCreateSerializer(
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
"default_assignee_id": project_setting.default_assignee_id,
},
)
@@ -2567,13 +2567,13 @@ class IssueDraftViewSet(BaseViewSet):
def create(self, request, slug, project_id):
try:
project = Project.objects.get(pk=project_id)
project_setting = ProjectSetting.objects.get(workspace__slug=slug, project_id=project_id)
serializer = IssueCreateSerializer(
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
"default_assignee_id": project_setting.default_assignee_id,
},
)
+123 -9
View File
@@ -1,5 +1,6 @@
# Python imports
import jwt
import boto3
from datetime import datetime
# Django imports
@@ -28,11 +29,12 @@ from sentry_sdk import capture_exception
from .base import BaseViewSet, BaseAPIView
from plane.api.serializers import (
ProjectSerializer,
ProjectSettingDetailSerializer,
ProjectSettingSerializer,
ProjectMemberSerializer,
ProjectDetailSerializer,
ProjectMemberInviteSerializer,
ProjectFavoriteSerializer,
IssueLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
)
@@ -66,6 +68,7 @@ from plane.db.models import (
ModuleMember,
Inbox,
ProjectDeployBoard,
ProjectSetting,
)
from plane.bgtasks.project_invitation_task import project_invitation
@@ -97,7 +100,7 @@ class ProjectViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(Q(project_projectmember__member=self.request.user) | Q(network=2))
.select_related(
"workspace", "workspace__owner", "default_assignee", "project_lead"
"workspace", "workspace__owner",
)
.annotate(is_favorite=Exists(subquery))
.annotate(
@@ -209,20 +212,29 @@ class ProjectViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
project_lead = request.data.get("project_lead", None)
# Create Project Setting
_ = ProjectSetting.objects.create(
project_id=serializer.data["id"],
project_lead_id=request.data.get("project_lead", None)
)
# Add the user as Administrator to the project
project_member = ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20
)
if serializer.data["project_lead"] is not None and str(
serializer.data["project_lead"]
if project_lead is not None and str(
project_lead
) != str(request.user.id):
ProjectMember.objects.create(
project_id=serializer.data["id"],
member_id=serializer.data["project_lead"],
member_id=project_lead,
role=20,
)
# Default states
states = [
{
@@ -306,7 +318,7 @@ class ProjectViewSet(BaseViewSet):
status=status.HTTP_410_GONE,
)
except Exception as e:
capture_exception(e)
print(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
@@ -495,7 +507,7 @@ class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberAdminSerializer
model = ProjectMember
permission_classes = [
ProjectBasePermission,
ProjectMemberPermission,
]
search_fields = [
@@ -617,7 +629,8 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
except ProjectMember.DoesNotExist:
return Response(
{"error": "Project Member does not exist"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Project Member does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
@@ -971,7 +984,7 @@ class ProjectFavoritesViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(user=self.request.user)
.select_related(
"project", "project__project_lead", "project__default_assignee"
"project",
)
.select_related("workspace", "workspace__owner")
)
@@ -1209,3 +1222,104 @@ class LeaveProjectEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectPublicCoverImagesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request):
try:
files = []
s3 = boto3.client(
"s3",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
params = {
"Bucket": settings.AWS_S3_BUCKET_NAME,
"Prefix": "static/project-cover/",
}
response = s3.list_objects_v2(**params)
# Extracting file keys from the response
if "Contents" in response:
for content in response["Contents"]:
if not content["Key"].endswith(
"/"
): # This line ensures we're only getting files, not "sub-folders"
files.append(
f"https://{settings.AWS_S3_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
)
return Response(files, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response([], status=status.HTTP_200_OK)
class ProjectSettingViewSet(BaseViewSet):
model = ProjectSetting
permission_classes = [
ProjectBasePermission,
]
def get_serializer_class(self, *args, **kwargs):
if self.action in ["create", "partial_update"]:
return ProjectSettingSerializer
return ProjectSettingDetailSerializer
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
)
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
def list(self, request, slug, project_id):
try:
project_setting = self.get_queryset().first()
if project_setting is not None:
serializer = ProjectSettingDetailSerializer(project_setting)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
{"error": "Project setting does not exists"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, project_id):
try:
project_setting = self.get_queryset().first()
# Check if it is None
if project_setting is not None:
serializer = ProjectSettingSerializer(
project_setting, data=request.data, partial=True
)
if serializer.is_valid():
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "Project setting does not exists"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
-21
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,
)
-45
View File
@@ -243,51 +243,6 @@ class IssueViewViewSet(BaseViewSet):
)
class ViewIssuesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, view_id):
try:
view = IssueView.objects.get(pk=view_id)
queries = view.query
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(
**queries, project_id=project_id, workspace__slug=slug
)
.filter(**filters)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
serializer = IssueLiteSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except IssueView.DoesNotExist:
return Response(
{"error": "Issue View does not exist"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueViewFavoriteViewSet(BaseViewSet):
serializer_class = IssueViewFavoriteSerializer
model = IssueViewFavorite
+1 -1
View File
@@ -1197,7 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
projects = request.query_params.getlist("project", [])
queryset = IssueActivity.objects.filter(
~Q(field__in=["comment", "vote", "reaction"]),
~Q(field__in=["comment", "vote", "reaction", "draft"]),
workspace__slug=slug,
project__project_projectmember__member=request.user,
actor=user_id,
@@ -12,7 +12,7 @@ from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Issue, Project, State
from plane.db.models import Issue, ProjectSetting, State
from plane.bgtasks.issue_activites_task import issue_activity
@@ -25,11 +25,11 @@ def archive_and_close_old_issues():
def archive_old_issues():
try:
# Get all the projects whose archive_in is greater than 0
projects = Project.objects.filter(archive_in__gt=0)
project_settings = ProjectSetting.objects.filter(archive_in__gt=0)
for project in projects:
project_id = project.id
archive_in = project.archive_in
for project_setting in project_settings:
project_id = project_setting.project_id
archive_in = project_setting.archive_in
# Get all the issues whose updated_at in less that the archive_in month
issues = Issue.issue_objects.filter(
@@ -75,7 +75,7 @@ def archive_old_issues():
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"archived_at": str(archive_at)}),
actor_id=str(project.created_by_id),
actor_id=str(project_setting.created_by_id),
issue_id=issue.id,
project_id=project_id,
current_instance=None,
@@ -95,13 +95,13 @@ def archive_old_issues():
def close_old_issues():
try:
# Get all the projects whose close_in is greater than 0
projects = Project.objects.filter(close_in__gt=0).select_related(
"default_state"
project_settings = ProjectSetting.objects.filter(close_in__gt=0).select_related(
"close_state"
)
for project in projects:
project_id = project.id
close_in = project.close_in
for project_setting in project_settings:
project_id = project_setting.project_id
close_in = project_setting.close_in
# Get all the issues whose updated_at in less that the close_in month
issues = Issue.issue_objects.filter(
@@ -130,10 +130,10 @@ def close_old_issues():
# Check if Issues
if issues:
if project.default_state is None:
if project_setting.close_state is None:
close_state = State.objects.filter(group="cancelled").first()
else:
close_state = project.default_state
close_state = project_setting.close_state
issues_to_update = []
for issue in issues:
@@ -147,7 +147,7 @@ def close_old_issues():
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"closed_to": str(issue.state_id)}),
actor_id=str(project.created_by_id),
actor_id=str(project_setting.created_by_id),
issue_id=issue.id,
project_id=project_id,
current_instance=None,
@@ -0,0 +1,124 @@
# Generated by Django 4.2.3 on 2023-10-11 13:34
import uuid
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
def create_project_settings(apps, schema_editor):
Project = apps.get_model("db", "Project")
ProjectSetting = apps.get_model("db", "ProjectSetting")
ProjectSetting.objects.bulk_create(
[
ProjectSetting(
project=project,
workspace_id=project.workspace_id,
archive_in=project.archive_in,
close_in=project.close_in,
cycle_view=project.cycle_view,
default_assignee=project.default_assignee,
default_state=project.default_state,
estimate=project.estimate,
inbox_view=project.inbox_view,
issue_views_view=project.issue_views_view,
module_view=project.module_view,
page_view=project.page_view,
project_lead=project.project_lead,
)
for project in Project.objects.all()
],
batch_size=1000,
)
class Migration(migrations.Migration):
dependencies = [
('db', '0045_issueactivity_epoch_workspacemember_issue_props_and_more'),
]
operations = [
migrations.CreateModel(
name='ProjectSetting',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('module_view', models.BooleanField(default=True)),
('cycle_view', models.BooleanField(default=True)),
('issue_views_view', models.BooleanField(default=True)),
('page_view', models.BooleanField(default=True)),
('inbox_view', models.BooleanField(default=False)),
('archive_in', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(12)])),
('close_in', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(12)])),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('default_assignee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='default_assignee', to=settings.AUTH_USER_MODEL)),
('default_state', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_state', to='db.state')),
('estimate', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='db.estimate')),
('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='project_settings', to='db.project')),
('project_lead', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_lead', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_settings', to='db.workspace')),
],
options={
'verbose_name': 'Project Settings',
'verbose_name_plural': 'Project Settings',
'db_table': 'project_settings',
'ordering': ('-created_at',),
},
),
migrations.RunPython(create_project_settings),
migrations.RemoveField(
model_name='project',
name='archive_in',
),
migrations.RemoveField(
model_name='project',
name='close_in',
),
migrations.RemoveField(
model_name='project',
name='cycle_view',
),
migrations.RemoveField(
model_name='project',
name='default_assignee',
),
migrations.RemoveField(
model_name='project',
name='default_state',
),
migrations.RemoveField(
model_name='project',
name='description_html',
),
migrations.RemoveField(
model_name='project',
name='description_text',
),
migrations.RemoveField(
model_name='project',
name='estimate',
),
migrations.RemoveField(
model_name='project',
name='inbox_view',
),
migrations.RemoveField(
model_name='project',
name='issue_views_view',
),
migrations.RemoveField(
model_name='project',
name='module_view',
),
migrations.RemoveField(
model_name='project',
name='page_view',
),
migrations.RemoveField(
model_name='project',
name='project_lead',
),
]
@@ -0,0 +1,24 @@
# Generated by Django 4.2.3 on 2023-10-12 07:13
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('db', '0046_auto_20231011_1334'),
]
operations = [
migrations.RemoveField(
model_name='projectsetting',
name='default_state',
),
migrations.AddField(
model_name='projectsetting',
name='close_state',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='close_state', to='db.state'),
),
]
+1
View File
@@ -13,6 +13,7 @@ from .workspace import (
from .project import (
Project,
ProjectSetting,
ProjectMember,
ProjectBaseModel,
ProjectMemberInvite,
+86 -38
View File
@@ -38,7 +38,7 @@ def get_default_props():
},
"display_filters": {
"group_by": None,
"order_by": '-created_at',
"order_by": "-created_at",
"type": None,
"sub_issue": True,
"show_empty_groups": True,
@@ -56,12 +56,6 @@ class Project(BaseModel):
NETWORK_CHOICES = ((0, "Secret"), (2, "Public"))
name = models.CharField(max_length=255, verbose_name="Project Name")
description = models.TextField(verbose_name="Project Description", blank=True)
description_text = models.JSONField(
verbose_name="Project Description RT", blank=True, null=True
)
description_html = models.JSONField(
verbose_name="Project Description HTML", blank=True, null=True
)
network = models.PositiveSmallIntegerField(default=2, choices=NETWORK_CHOICES)
workspace = models.ForeignKey(
"db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project"
@@ -70,40 +64,40 @@ class Project(BaseModel):
max_length=12,
verbose_name="Project Identifier",
)
default_assignee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="default_assignee",
null=True,
blank=True,
)
project_lead = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="project_lead",
null=True,
blank=True,
)
# default_assignee = models.ForeignKey(
# settings.AUTH_USER_MODEL,
# on_delete=models.CASCADE,
# related_name="default_assignee",
# null=True,
# blank=True,
# )
# project_lead = models.ForeignKey(
# settings.AUTH_USER_MODEL,
# on_delete=models.CASCADE,
# related_name="project_lead",
# null=True,
# blank=True,
# )
emoji = models.CharField(max_length=255, null=True, blank=True)
icon_prop = models.JSONField(null=True)
module_view = models.BooleanField(default=True)
cycle_view = models.BooleanField(default=True)
issue_views_view = models.BooleanField(default=True)
page_view = models.BooleanField(default=True)
inbox_view = models.BooleanField(default=False)
# module_view = models.BooleanField(default=True)
# cycle_view = models.BooleanField(default=True)
# issue_views_view = models.BooleanField(default=True)
# page_view = models.BooleanField(default=True)
# inbox_view = models.BooleanField(default=False)
cover_image = models.URLField(blank=True, null=True, max_length=800)
estimate = models.ForeignKey(
"db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True
)
archive_in = models.IntegerField(
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
)
close_in = models.IntegerField(
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
)
default_state = models.ForeignKey(
"db.State", on_delete=models.SET_NULL, null=True, related_name="default_state"
)
# estimate = models.ForeignKey(
# "db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True
# )
# archive_in = models.IntegerField(
# default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
# )
# close_in = models.IntegerField(
# default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
# )
# default_state = models.ForeignKey(
# "db.State", on_delete=models.SET_NULL, null=True, related_name="default_state"
# )
def __str__(self):
"""Return name of the project"""
@@ -121,6 +115,60 @@ class Project(BaseModel):
return super().save(*args, **kwargs)
class ProjectSetting(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="project_settings"
)
project = models.OneToOneField(
"db.Project", on_delete=models.CASCADE, related_name="project_settings"
)
default_assignee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="default_assignee",
null=True,
blank=True,
)
project_lead = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="project_lead",
null=True,
blank=True,
)
module_view = models.BooleanField(default=True)
cycle_view = models.BooleanField(default=True)
issue_views_view = models.BooleanField(default=True)
page_view = models.BooleanField(default=True)
inbox_view = models.BooleanField(default=False)
estimate = models.ForeignKey(
"db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True
)
archive_in = models.IntegerField(
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
)
close_in = models.IntegerField(
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
)
close_state = models.ForeignKey(
"db.State", on_delete=models.SET_NULL, null=True, related_name="close_state"
)
def save(self, *args, **kwargs):
self.workspace = self.project.workspace
super(ProjectSetting, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the project"""
return f"{self.project.name} <{self.workspace.name}>"
class Meta:
verbose_name = "Project Settings"
verbose_name_plural = "Project Settings"
db_table = "project_settings"
ordering = ("-created_at",)
class ProjectBaseModel(BaseModel):
project = models.ForeignKey(
Project, on_delete=models.CASCADE, related_name="project_%(class)s"
+3
View File
@@ -114,3 +114,6 @@ CELERY_BROKER_URL = os.environ.get("REDIS_URL")
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
+115 -74
View File
@@ -7,6 +7,7 @@ import dj_database_url
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from urllib.parse import urlparse
from .common import * # noqa
@@ -89,90 +90,112 @@ if bool(os.environ.get("SENTRY_DSN", False)):
profiles_sample_rate=1.0,
)
# The AWS region to connect to.
AWS_REGION = os.environ.get("AWS_REGION", "")
if DOCKERIZED and USE_MINIO:
INSTALLED_APPS += ("storages",)
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
# The name of the bucket to store files in.
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get(
"AWS_S3_ENDPOINT_URL", "http://plane-minio:9000"
)
# Default permissions
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
# Custom Domain settings
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
else:
# The AWS region to connect to.
AWS_REGION = os.environ.get("AWS_REGION", "")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
# The optional AWS session token to use.
# AWS_SESSION_TOKEN = ""
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
# The name of the bucket to store files in.
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
# The optional AWS session token to use.
# AWS_SESSION_TOKEN = ""
# How to construct S3 URLs ("auto", "path", "virtual").
AWS_S3_ADDRESSING_STYLE = "auto"
# The name of the bucket to store files in.
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
# How to construct S3 URLs ("auto", "path", "virtual").
AWS_S3_ADDRESSING_STYLE = "auto"
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
AWS_S3_KEY_PREFIX = ""
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
# and their permissions will be set to "public-read".
AWS_S3_BUCKET_AUTH = False
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
AWS_S3_KEY_PREFIX = ""
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
# is True. It also affects the "Cache-Control" header of the files.
# Important: Changing this setting will not affect existing files.
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
# and their permissions will be set to "public-read".
AWS_S3_BUCKET_AUTH = False
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
# cannot be used with `AWS_S3_BUCKET_AUTH`.
AWS_S3_PUBLIC_URL = ""
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
# is True. It also affects the "Cache-Control" header of the files.
# Important: Changing this setting will not affect existing files.
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
# understand the consequences before enabling.
# Important: Changing this setting will not affect existing files.
AWS_S3_REDUCED_REDUNDANCY = False
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
# cannot be used with `AWS_S3_BUCKET_AUTH`.
AWS_S3_PUBLIC_URL = ""
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_DISPOSITION = ""
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
# understand the consequences before enabling.
# Important: Changing this setting will not affect existing files.
AWS_S3_REDUCED_REDUNDANCY = False
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_LANGUAGE = ""
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_DISPOSITION = ""
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_METADATA = {}
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_LANGUAGE = ""
# If True, then files will be stored using AES256 server-side encryption.
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
# Otherwise, server-side encryption is not be enabled.
# Important: Changing this setting will not affect existing files.
AWS_S3_ENCRYPT_KEY = False
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_METADATA = {}
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
# If True, then files will be stored using AES256 server-side encryption.
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
# Otherwise, server-side encryption is not be enabled.
# Important: Changing this setting will not affect existing files.
AWS_S3_ENCRYPT_KEY = False
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
# compressed size is smaller than their uncompressed size.
# Important: Changing this setting will not affect existing files.
AWS_S3_GZIP = True
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
# The signature version to use for S3 requests.
AWS_S3_SIGNATURE_VERSION = None
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
# compressed size is smaller than their uncompressed size.
# Important: Changing this setting will not affect existing files.
AWS_S3_GZIP = True
# If True, then files with the same name will overwrite each other. By default it's set to False to have
# extra characters appended.
AWS_S3_FILE_OVERWRITE = False
# The signature version to use for S3 requests.
AWS_S3_SIGNATURE_VERSION = None
STORAGES["default"] = {
"BACKEND": "django_s3_storage.storage.S3Storage",
}
# If True, then files with the same name will overwrite each other. By default it's set to False to have
# extra characters appended.
AWS_S3_FILE_OVERWRITE = False
STORAGES["default"] = {
"BACKEND": "django_s3_storage.storage.S3Storage",
}
# AWS Settings End
# Enable Connection Pooling (if desired)
@@ -193,16 +216,27 @@ CSRF_COOKIE_SECURE = True
REDIS_URL = os.environ.get("REDIS_URL")
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
},
if DOCKERIZED:
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
else:
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
},
}
}
}
WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so")
@@ -225,8 +259,12 @@ broker_url = (
f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
)
CELERY_RESULT_BACKEND = broker_url
CELERY_BROKER_URL = broker_url
if DOCKERIZED:
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
else:
CELERY_BROKER_URL = broker_url
CELERY_RESULT_BACKEND = broker_url
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
@@ -238,3 +276,6 @@ SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
SCOUT_NAME = "Plane"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
+1
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_KEY = os.environ.get("OPENAI_API_KEY", False)
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
+4
View File
@@ -218,3 +218,7 @@ CELERY_BROKER_URL = broker_url
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
+70 -63
View File
@@ -1,5 +1,62 @@
from django.utils.timezone import make_aware
from django.utils.dateparse import parse_datetime
import re
from datetime import timedelta
from django.utils import timezone
# The date from pattern
pattern = re.compile(r"\d+_(weeks|months)$")
# Get the 2_weeks, 3_months
def string_date_filter(filter, duration, subsequent, term, date_filter, offset):
now = timezone.now().date()
if term == "months":
if subsequent == "after":
if offset == "fromnow":
filter[f"{date_filter}__gte"] = now + timedelta(days=duration * 30)
else:
filter[f"{date_filter}__gte"] = now - timedelta(days=duration * 30)
else:
if offset == "fromnow":
filter[f"{date_filter}__lte"] = now + timedelta(days=duration * 30)
else:
filter[f"{date_filter}__lte"] = now - timedelta(days=duration * 30)
if term == "weeks":
if subsequent == "after":
if offset == "fromnow":
filter[f"{date_filter}__gte"] = now + timedelta(weeks=duration)
else:
filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration)
else:
if offset == "fromnow":
filter[f"{date_filter}__lte"] = now + timedelta(days=duration)
else:
filter[f"{date_filter}__lte"] = now - timedelta(days=duration)
def date_filter(filter, date_term, queries):
"""
Handle all date filters
"""
for query in queries:
date_query = query.split(";")
if len(date_query) >= 2:
match = pattern.match(date_query[0])
if match:
if len(date_query) == 3:
digit, term = date_query[0].split("_")
string_date_filter(
filter=filter,
duration=int(digit),
subsequent=date_query[1],
term=term,
date_filter="created_at__date",
offset=date_query[2],
)
else:
if "after" in date_query:
filter[f"{date_term}__gte"] = date_query[0]
else:
filter[f"{date_term}__lte"] = date_query[0]
def filter_state(params, filter, method):
@@ -97,20 +154,10 @@ def filter_created_at(params, filter, method):
if method == "GET":
created_ats = params.get("created_at").split(",")
if len(created_ats) and "" not in created_ats:
for query in created_ats:
created_at_query = query.split(";")
if len(created_at_query) == 2 and "after" in created_at_query:
filter["created_at__date__gte"] = created_at_query[0]
else:
filter["created_at__date__lte"] = created_at_query[0]
date_filter(filter=filter, date_term="created_at__date", queries=created_ats)
else:
if params.get("created_at", None) and len(params.get("created_at")):
for query in params.get("created_at"):
created_at_query = query.split(";")
if len(created_at_query) == 2 and "after" in created_at_query:
filter["created_at__date__gte"] = created_at_query[0]
else:
filter["created_at__date__lte"] = created_at_query[0]
date_filter(filter=filter, date_term="created_at__date", queries=params.get("created_at", []))
return filter
@@ -118,20 +165,10 @@ def filter_updated_at(params, filter, method):
if method == "GET":
updated_ats = params.get("updated_at").split(",")
if len(updated_ats) and "" not in updated_ats:
for query in updated_ats:
updated_at_query = query.split(";")
if len(updated_at_query) == 2 and "after" in updated_at_query:
filter["updated_at__date__gte"] = updated_at_query[0]
else:
filter["updated_at__date__lte"] = updated_at_query[0]
date_filter(filter=filter, date_term="created_at__date", queries=updated_ats)
else:
if params.get("updated_at", None) and len(params.get("updated_at")):
for query in params.get("updated_at"):
updated_at_query = query.split(";")
if len(updated_at_query) == 2 and "after" in updated_at_query:
filter["updated_at__date__gte"] = updated_at_query[0]
else:
filter["updated_at__date__lte"] = updated_at_query[0]
date_filter(filter=filter, date_term="created_at__date", queries=params.get("updated_at", []))
return filter
@@ -139,20 +176,10 @@ def filter_start_date(params, filter, method):
if method == "GET":
start_dates = params.get("start_date").split(",")
if len(start_dates) and "" not in start_dates:
for query in start_dates:
start_date_query = query.split(";")
if len(start_date_query) == 2 and "after" in start_date_query:
filter["start_date__gte"] = start_date_query[0]
else:
filter["start_date__lte"] = start_date_query[0]
date_filter(filter=filter, date_term="start_date", queries=start_dates)
else:
if params.get("start_date", None) and len(params.get("start_date")):
for query in params.get("start_date"):
start_date_query = query.split(";")
if len(start_date_query) == 2 and "after" in start_date_query:
filter["start_date__gte"] = start_date_query[0]
else:
filter["start_date__lte"] = start_date_query[0]
date_filter(filter=filter, date_term="start_date", queries=params.get("start_date", []))
return filter
@@ -160,21 +187,11 @@ def filter_target_date(params, filter, method):
if method == "GET":
target_dates = params.get("target_date").split(",")
if len(target_dates) and "" not in target_dates:
for query in target_dates:
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gte"] = target_date_query[0]
else:
filter["target_date__lte"] = target_date_query[0]
date_filter(filter=filter, date_term="target_date", queries=target_dates)
else:
if params.get("target_date", None) and len(params.get("target_date")):
for query in params.get("target_date"):
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gte"] = target_date_query[0]
else:
filter["target_date__lte"] = target_date_query[0]
date_filter(filter=filter, date_term="target_date", queries=params.get("target_date", []))
return filter
@@ -182,20 +199,10 @@ def filter_completed_at(params, filter, method):
if method == "GET":
completed_ats = params.get("completed_at").split(",")
if len(completed_ats) and "" not in completed_ats:
for query in completed_ats:
completed_at_query = query.split(";")
if len(completed_at_query) == 2 and "after" in completed_at_query:
filter["completed_at__date__gte"] = completed_at_query[0]
else:
filter["completed_at__lte"] = completed_at_query[0]
date_filter(filter=filter, date_term="completed_at__date", queries=completed_ats)
else:
if params.get("completed_at", None) and len(params.get("completed_at")):
for query in params.get("completed_at"):
completed_at_query = query.split(";")
if len(completed_at_query) == 2 and "after" in completed_at_query:
filter["completed_at__date__gte"] = completed_at_query[0]
else:
filter["completed_at__lte"] = completed_at_query[0]
date_filter(filter=filter, date_term="completed_at__date", queries=params.get("completed_at", []))
return filter
+1
View File
@@ -1,5 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.13.2",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "eslint-config-custom",
"version": "0.0.0",
"version": "0.13.2",
"main": "index.js",
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tailwind-config-custom",
"version": "0.0.1",
"version": "0.13.2",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tsconfig",
"version": "0.0.0",
"version": "0.13.2",
"private": true,
"files": [
"base.json",
+1
View File
@@ -0,0 +1 @@
# UI Package
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ui",
"version": "0.0.0",
"version": "0.13.2",
"main": "./index.tsx",
"types": "./index.tsx",
"license": "MIT",
@@ -10,9 +10,12 @@ import githubWhiteImage from "public/logos/github-white.svg";
export interface GithubLoginButtonProps {
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 [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">
<Link
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]">
<Image
+7 -6
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";
export interface IGoogleLoginButton {
text?: string;
clientId: string;
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);
// states
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
const loadScript = useCallback(() => {
if (!googleSignInButton.current || gsiScriptLoaded) return;
(window as any)?.google?.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
client_id: clientId,
callback: handleSignIn,
});
+42 -62
View File
@@ -1,26 +1,30 @@
import React, { useEffect } from "react";
import Image from "next/image";
import React from "react";
import useSWR from "swr";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// services
import authenticationService from "services/authentication.service";
import { AppConfigService } from "services/app-config.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
import { EmailPasswordForm, GoogleLoginButton, EmailCodeForm } from "components/accounts";
// images
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
const appConfig = new AppConfigService();
export const SignInView = observer(() => {
const { user: userStore } = useMobxStore();
// router
const router = useRouter();
const { next_path } = router.query as { next_path: string };
// toast
const { setToastAlert } = useToast();
// fetch app config
const { data } = useSWR("APP_CONFIG", () => appConfig.envConfig());
const onSignInError = (error: any) => {
setToastAlert({
@@ -31,17 +35,17 @@ export const SignInView = observer(() => {
};
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);
if (!isOnboarded) {
router.push(`/onboarding?next_path=${nextPath}`);
return;
const isOnboard = response?.user?.onboarding_step?.profile_complete || false;
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) => {
@@ -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) => {
await authenticationService
.emailLogin(formData)
@@ -118,38 +104,32 @@ export const SignInView = observer(() => {
</div>
<div className="grid place-items-center h-full overflow-y-auto py-5 px-7">
<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>
<div className="flex flex-col divide-y divide-custom-border-200">
<div className="pb-7">
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
</div>
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
<GoogleLoginButton handleSignIn={handleGoogleSignIn} />
{/* <GithubLoginButton handleSignIn={handleGitHubSignIn} /> */}
</div>
<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} />}
{data?.magic_login && (
<div className="flex flex-col divide-y divide-custom-border-200">
<div className="pb-7">
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
</div>
</>
) : (
<EmailPasswordForm onSubmit={handlePasswordSignIn} />
</div>
)}
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
<p className="pt-16 text-custom-text-200 text-sm text-center">
By signing up, you agree to the{" "}
<a
href="https://plane.so/terms-and-conditions"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline"
>
Terms & Conditions
</a>
</p>
) : null}
<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">
By signing up, you agree to the{" "}
<a
href="https://plane.so/terms-and-conditions"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline"
>
Terms & Conditions
</a>
</p>
</div>
</div>
</div>
+36 -12
View File
@@ -44,19 +44,43 @@ const IssueNavbar = observer(() => {
}, [projectStore, workspace_slug, project_slug]);
useEffect(() => {
if (workspace_slug && project_slug) {
if (!board) {
router.push({
pathname: `/${workspace_slug}/${project_slug}`,
query: {
board: "list",
},
});
return projectStore.setActiveBoard("list");
if (workspace_slug && project_slug && projectStore?.deploySettings) {
const viewsAcceptable: string[] = [];
let currentBoard: string | null = null;
if (projectStore?.deploySettings?.views?.list) viewsAcceptable.push("list");
if (projectStore?.deploySettings?.views?.kanban) viewsAcceptable.push("kanban");
if (projectStore?.deploySettings?.views?.calendar) viewsAcceptable.push("calendar");
if (projectStore?.deploySettings?.views?.gantt) viewsAcceptable.push("gantt");
if (projectStore?.deploySettings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet");
if (board) {
if (viewsAcceptable.includes(board.toString())) {
currentBoard = board.toString();
} else {
if (viewsAcceptable && viewsAcceptable.length > 0) {
currentBoard = viewsAcceptable[0];
}
}
} else {
if (viewsAcceptable && viewsAcceptable.length > 0) {
currentBoard = viewsAcceptable[0];
}
}
if (currentBoard) {
if (projectStore?.activeBoard === null || projectStore?.activeBoard !== currentBoard) {
projectStore.setActiveBoard(currentBoard);
router.push({
pathname: `/${workspace_slug}/${project_slug}`,
query: {
board: currentBoard,
},
});
}
}
projectStore.setActiveBoard(board.toString());
}
}, [board, workspace_slug, project_slug]);
}, [board, workspace_slug, project_slug, router, projectStore, projectStore?.deploySettings]);
return (
<div className="px-5 relative w-full flex items-center gap-4">
@@ -105,7 +129,7 @@ const IssueNavbar = observer(() => {
</div>
) : (
<div className="flex-shrink-0">
<Link href={`/?next_path=${router.asPath}`}>
<Link href={`/login/?next_path=${router.asPath}`}>
<a>
<PrimaryButton className="flex-shrink-0" outline>
Sign in
+9 -3
View File
@@ -7,7 +7,13 @@ import { SignInView, UserLoggedIn } from "components/accounts";
export const LoginView = observer(() => {
const { user: userStore } = useMobxStore();
if (!userStore.currentUser) return <SignInView />;
return <UserLoggedIn />;
return (
<>
{userStore?.loader ? (
<div className="relative w-screen h-screen flex justify-center items-center">Loading</div>
) : (
<>{userStore.currentUser ? <UserLoggedIn /> : <SignInView />}</>
)}
</>
);
});
+8 -1
View File
@@ -3,12 +3,14 @@
import { useEffect } from "react";
// next imports
import { useRouter } from "next/router";
// js cookie
import Cookie from "js-cookie";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
const MobxStoreInit = () => {
const store: RootStore = useMobxStore();
const { user: userStore }: RootStore = useMobxStore();
const router = useRouter();
const { states, labels, priorities } = router.query as { states: string[]; labels: string[]; priorities: string[] };
@@ -19,6 +21,11 @@ const MobxStoreInit = () => {
// store.issue.userSelectedStates = states || [];
// }, [store.issue]);
useEffect(() => {
const authToken = Cookie.get("accessToken") || null;
if (authToken) userStore.fetchCurrentUser();
}, [userStore]);
return <></>;
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "0.0.1",
"version": "0.13.2",
"private": true,
"scripts": {
"dev": "next dev -p 4000",
+19
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;
+1 -1
View File
@@ -5,4 +5,4 @@ import { LoginView } from "components/views";
const LoginPage = () => <LoginView />;
export default LoginPage;
export default LoginPage;
+30
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;
});
}
}
-18
View File
@@ -74,24 +74,6 @@ class FileServices extends APIService {
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();
+13
View File
@@ -7,12 +7,17 @@ import { ActorDetail } from "types/issue";
import { IUser } from "types/user";
export interface IUserStore {
loader: boolean;
error: any | null;
currentUser: any | null;
fetchCurrentUser: () => void;
currentActor: () => any;
}
class UserStore implements IUserStore {
loader: boolean = false;
error: any | null = null;
currentUser: IUser | null = null;
// root store
rootStore;
@@ -22,6 +27,9 @@ class UserStore implements IUserStore {
constructor(_rootStore: any) {
makeObservable(this, {
// observable
loader: observable.ref,
error: observable.ref,
currentUser: observable.ref,
// actions
setCurrentUser: action,
@@ -73,14 +81,19 @@ class UserStore implements IUserStore {
fetchCurrentUser = async () => {
try {
this.loader = true;
this.error = null;
const response = await this.userService.currentUser();
if (response) {
runInAction(() => {
this.loader = false;
this.currentUser = response;
});
}
} catch (error) {
console.error("Failed to fetch current user", error);
this.loader = false;
this.error = error;
}
};
}
-4
View File
@@ -1,8 +1,6 @@
{
"$schema": "https://turbo.build/schema.json",
"globalEnv": [
"NEXT_PUBLIC_GITHUB_ID",
"NEXT_PUBLIC_GOOGLE_CLIENTID",
"NEXT_PUBLIC_API_BASE_URL",
"NEXT_PUBLIC_DEPLOY_URL",
"API_BASE_URL",
@@ -12,8 +10,6 @@
"NEXT_PUBLIC_GITHUB_APP_NAME",
"NEXT_PUBLIC_ENABLE_SENTRY",
"NEXT_PUBLIC_ENABLE_OAUTH",
"NEXT_PUBLIC_UNSPLASH_ACCESS",
"NEXT_PUBLIC_UNSPLASH_ENABLED",
"NEXT_PUBLIC_TRACK_EVENTS",
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
"NEXT_PUBLIC_CRISP_ID",
+61 -102
View File
@@ -1,12 +1,5 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
// react hook form
import React from "react";
import { useForm } from "react-hook-form";
// components
import { EmailResetPasswordForm } from "components/account";
// ui
import { Input, PrimaryButton } from "components/ui";
// types
@@ -18,14 +11,12 @@ type EmailPasswordFormValues = {
type Props = {
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
setIsResettingPassword: (value: boolean) => void;
};
export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
const [isResettingPassword, setIsResettingPassword] = useState(false);
const router = useRouter();
const isSignUpPage = router.pathname === "/sign-up";
export const EmailPasswordForm: React.FC<Props> = (props) => {
const { onSubmit, setIsResettingPassword } = props;
// form info
const {
register,
handleSubmit,
@@ -42,94 +33,62 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
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
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="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
type="button"
onClick={() => setIsResettingPassword(true)}
className="text-custom-text-200 hover:text-custom-primary-100"
>
Forgot your password?
</button>
)}
</div>
<div>
<PrimaryButton
type="submit"
className="w-full text-center h-[46px]"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSignUpPage
? isSubmitting
? "Signing up..."
: "Sign up"
: isSubmitting
? "Signing in..."
: "Sign in"}
</PrimaryButton>
{!isSignUpPage && (
<Link href="/sign-up">
<a className="block text-custom-text-200 hover:text-custom-primary-100 text-xs mt-4">
Don{"'"}t have an account? Sign up.
</a>
</Link>
)}
</div>
</form>
)}
<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="text-right text-xs">
<button
type="button"
onClick={() => setIsResettingPassword(true)}
className="text-custom-text-200 hover:text-custom-primary-100"
>
Forgot your password?
</button>
</div>
<div>
<PrimaryButton
type="submit"
className="w-full text-center h-[46px]"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign in"}
</PrimaryButton>
</div>
</form>
</>
);
};
@@ -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>
</>
);
};
@@ -1,29 +1,27 @@
import { useEffect, useState, FC } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
// next-themes
import { useTheme } from "next-themes";
// images
import githubBlackImage from "/public/logos/github-black.png";
import githubWhiteImage from "/public/logos/github-white.png";
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
export interface GithubLoginButtonProps {
handleSignIn: React.Dispatch<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 [gitCode, setGitCode] = useState<null | string>(null);
// router
const {
query: { code },
} = useRouter();
// theme
const { theme } = useTheme();
useEffect(() => {
@@ -42,7 +40,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn })
return (
<div className="w-full flex justify-center items-center">
<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]">
<Image
+8 -7
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";
export interface IGoogleLoginButton {
text?: string;
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);
// states
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
const loadScript = useCallback(() => {
if (!googleSignInButton.current || gsiScriptLoaded) return;
window?.google?.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
client_id: clientId,
callback: handleSignIn,
});
@@ -39,7 +40,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
setGsiScriptLoaded(true);
}, [handleSignIn, gsiScriptLoaded]);
}, [handleSignIn, gsiScriptLoaded, clientId]);
useEffect(() => {
if (window?.google?.accounts?.id) {
+1
View File
@@ -3,3 +3,4 @@ export * from "./email-password-form";
export * from "./email-reset-password-form";
export * from "./github-login-button";
export * from "./google-login";
export * from "./email-signup-form";
@@ -13,9 +13,14 @@ import { IProject } from "types";
type Props = {
projectDetails: IProject | undefined;
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 initialValues: Partial<IProject> = { archive_in: 1 };
@@ -49,6 +54,7 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
: handleChange({ archive_in: 0 })
}
size="sm"
disabled={disabled}
/>
</div>
@@ -68,8 +74,8 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
handleChange({ archive_in: val });
}}
input
verticalPosition="bottom"
width="w-full"
disabled={disabled}
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
@@ -24,9 +24,14 @@ import { getStatesList } from "helpers/state.helper";
type Props = {
projectDetails: IProject | undefined;
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 router = useRouter();
@@ -98,6 +103,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
: handleChange({ close_in: 0, default_state: null })
}
size="sm"
disabled={disabled}
/>
</div>
@@ -119,6 +125,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
}}
input
width="w-full"
disabled={disabled}
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
+133 -69
View File
@@ -1,32 +1,23 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
// next
import Image from "next/image";
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// react-dropdown
import { useDropzone } from "react-dropzone";
// headless ui
import { Tab, Transition, Popover } from "@headlessui/react";
// services
import fileService from "services/file.service";
// components
import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui";
// hooks
import useWorkspaceDetails from "hooks/use-workspace-details";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
const unsplashEnabled =
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "1";
// components
import { Input, PrimaryButton, SecondaryButton, Loader } from "components/ui";
const tabOptions = [
{
key: "unsplash",
title: "Unsplash",
},
{
key: "images",
title: "Images",
@@ -64,8 +55,22 @@ export const ImagePickerPopover: React.FC<Props> = ({
search: "",
});
const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchParams}`, () =>
fileService.getUnsplashImages(1, searchParams)
const { data: unsplashImages, error: unsplashError } = useSWR(
`UNSPLASH_IMAGES_${searchParams}`,
() => fileService.getUnsplashImages(searchParams),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
const { data: projectCoverImages } = useSWR(
`PROJECT_COVER_IMAGES`,
() => fileService.getProjectCoverImages(),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
const imagePickerRef = useRef<HTMLDivElement>(null);
@@ -115,18 +120,17 @@ export const ImagePickerPopover: React.FC<Props> = ({
};
useEffect(() => {
if (!images || value !== null) return;
onChange(images[0].urls.regular);
}, [value, onChange, images]);
if (!unsplashImages || value !== null) return;
onChange(unsplashImages[0].urls.regular);
}, [value, onChange, unsplashImages]);
useOutsideClickDetector(imagePickerRef, () => setIsOpen(false));
if (!unsplashEnabled) return null;
return (
<Popover className="relative z-[2]" ref={ref}>
<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)}
disabled={disabled}
>
@@ -141,15 +145,19 @@ export const ImagePickerPopover: React.FC<Props> = ({
leaveFrom="transform opacity-100 scale-100"
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
ref={imagePickerRef}
className="h-96 md:h-[28rem] w-80 md:w-[36rem] flex flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl"
>
<Tab.Group>
<div>
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
{tabOptions.map((tab) => (
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
{tabOptions.map((tab) => {
if (!unsplashImages && unsplashError && tab.key === "unsplash") return null;
if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images")
return null;
return (
<Tab
key={tab.key}
className={({ selected }) =>
@@ -160,50 +168,106 @@ export const ImagePickerPopover: React.FC<Props> = ({
>
{tab.title}
</Tab>
))}
</Tab.List>
</div>
);
})}
</Tab.List>
<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">
<div className="flex gap-x-2 pt-7">
<Input
name="search"
className="text-sm"
id="search"
value={formData.search}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
placeholder="Search for images"
/>
<PrimaryButton onClick={() => setSearchParams(formData.search)} size="sm">
Search
</PrimaryButton>
</div>
{images ? (
<div className="grid grid-cols-4 gap-4">
{images.map((image) => (
<div
key={image.id}
className="relative col-span-2 aspect-video md:col-span-1"
>
<img
src={image.urls.small}
alt={image.alt_description}
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);
}}
/>
{(unsplashImages || !unsplashError) && (
<Tab.Panel className="h-full w-full space-y-4 mt-4">
<div className="flex gap-x-2">
<Input
name="search"
className="text-sm"
id="search"
value={formData.search}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
placeholder="Search for images"
/>
<PrimaryButton onClick={() => setSearchParams(formData.search)} size="sm">
Search
</PrimaryButton>
</div>
{unsplashImages ? (
unsplashImages.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{unsplashImages.map((image) => (
<div
key={image.id}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);
}}
>
<img
src={image.urls.small}
alt={image.alt_description}
className="cursor-pointer rounded absolute top-0 left-0 h-full w-full object-cover"
/>
</div>
))}
</div>
))}
</div>
) : (
<div className="flex justify-center pt-20">
<Spinner />
</div>
)}
</Tab.Panel>
<Tab.Panel className="h-full w-full pt-5">
) : (
<p className="text-center text-custom-text-300 text-xs pt-7">
No images found.
</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>
)}
{(!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="flex items-center gap-3 w-full flex-1">
<div
@@ -100,7 +100,6 @@ export const ThemeSwitch: React.FC<Props> = observer(
}}
input
width="w-full"
position="right"
>
{THEMES_OBJ.map(({ value, label, type, icon }) => (
<CustomSelect.Option key={value} value={{ value, type }}>
@@ -6,6 +6,7 @@ import { useRouter } from "next/router";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd";
// components
import { CreateUpdateDraftIssueModal } from "components/issues";
import { BoardHeader, SingleBoardIssue, BoardInlineCreateIssueForm } from "components/core";
// ui
import { CustomMenu } from "components/ui";
@@ -57,6 +58,7 @@ export const SingleBoard: React.FC<Props> = (props) => {
const [isCollapsed, setIsCollapsed] = useState(true);
const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false);
const [isCreateDraftIssueModalOpen, setIsCreateDraftIssueModalOpen] = useState(false);
const { displayFilters, groupedIssues } = viewProps;
@@ -96,10 +98,27 @@ export const SingleBoard: React.FC<Props> = (props) => {
scrollToBottom();
};
const handleAddIssueToGroup = () => {
if (isDraftIssuesPage) setIsCreateDraftIssueModalOpen(true);
else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
else onCreateClick();
};
return (
<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
addIssueToGroup={addIssueToGroup}
addIssueToGroup={handleAddIssueToGroup}
currentState={currentState}
groupTitle={groupTitle}
isCollapsed={isCollapsed}
@@ -218,35 +237,38 @@ export const SingleBoard: React.FC<Props> = (props) => {
{displayFilters?.group_by !== "created_by" && (
<div>
{type === "issue"
? !disableAddIssueOption && (
? !disableAddIssueOption &&
!isDraftIssuesPage && (
<button
type="button"
className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1"
onClick={() => {
if (isDraftIssuesPage || isMyIssuesPage || isProfileIssuesPage) {
addIssueToGroup();
} else onCreateClick();
if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
else onCreateClick();
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
)
: !disableUserActions && (
: !disableUserActions &&
!isDraftIssuesPage && (
<CustomMenu
customButton={
<button
type="button"
className="flex items-center gap-2 font-medium text-custom-primary outline-none whitespace-nowrap"
>
<div className="flex items-center gap-2 font-medium text-custom-primary outline-none whitespace-nowrap">
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
</div>
}
position="left"
noBorder
>
<CustomMenu.MenuItem onClick={() => onCreateClick()}>
<CustomMenu.MenuItem
onClick={() => {
if (isDraftIssuesPage) setIsCreateDraftIssueModalOpen(true);
else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
else onCreateClick();
}}
>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
@@ -369,12 +369,12 @@ export const SingleBoardIssue: React.FC<Props> = ({
{type && !isNotAllowed && (
<CustomMenu
customButton={
<button
<div
className="flex w-full cursor-pointer items-center justify-between gap-1 rounded p-1 text-left text-xs duration-300 hover:bg-custom-background-80"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<EllipsisHorizontalIcon className="h-4 w-4" />
</button>
</div>
}
>
<CustomMenu.MenuItem
@@ -13,6 +13,7 @@ import projectService from "services/project.service";
// hooks
import useProjects from "hooks/use-projects";
// components
import { CreateUpdateDraftIssueModal } from "components/issues";
import { SingleListIssue, ListInlineCreateIssueForm } from "components/core";
// ui
import { Avatar, CustomMenu } from "components/ui";
@@ -75,6 +76,7 @@ export const SingleList: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false);
const [isDraftIssuesModalOpen, setIsDraftIssuesModalOpen] = useState(false);
const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues";
const isProfileIssuesPage = router.pathname.split("/")[2] === "profile";
@@ -208,154 +210,168 @@ export const SingleList: React.FC<Props> = (props) => {
if (!groupedIssues) return null;
return (
<Disclosure as="div" defaultOpen>
{({ open }) => (
<div>
<div className="flex items-center justify-between px-4 py-2.5 bg-custom-background-90">
<Disclosure.Button>
<div className="flex items-center gap-x-3">
{displayFilters?.group_by !== null && (
<div className="flex items-center">{getGroupIcon()}</div>
)}
{displayFilters?.group_by !== null ? (
<h2
className={`text-sm font-semibold leading-6 text-custom-text-100 ${
displayFilters?.group_by === "created_by" ? "" : "capitalize"
}`}
>
{getGroupTitle()}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<span className="text-custom-text-200 min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs">
{groupedIssues[groupTitle as keyof IIssue].length}
</span>
</div>
</Disclosure.Button>
{isArchivedIssues ? (
""
) : type === "issue" ? (
!disableAddIssueOption && (
<button
type="button"
className="p-1 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => {
if (isDraftIssuesPage || isMyIssuesPage || isProfileIssuesPage) {
addIssueToGroup();
} else setIsCreateIssueFormOpen(true);
}}
>
<PlusIcon className="h-4 w-4" />
</button>
)
) : disableUserActions ? (
""
) : (
<CustomMenu
customButton={
<div className="flex cursor-pointer items-center">
<PlusIcon className="h-4 w-4" />
</div>
}
position="right"
noBorder
>
<CustomMenu.MenuItem onClick={() => setIsCreateIssueFormOpen(true)}>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
{groupedIssues[groupTitle] ? (
groupedIssues[groupTitle].length > 0 ? (
groupedIssues[groupTitle].map((issue, index) => (
<SingleListIssue
key={issue.id}
type={type}
issue={issue}
projectId={issue.project_detail.id}
groupTitle={groupTitle}
index={index}
editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
handleDraftIssueSelect={
handleDraftIssueAction
? () => handleDraftIssueAction(issue, "edit")
: undefined
}
handleDraftIssueDelete={
handleDraftIssueAction
? () => handleDraftIssueAction(issue, "delete")
: undefined
}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={() => {
if (removeIssue !== null && issue.bridge_id)
removeIssue(issue.bridge_id, issue.id);
}}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
viewProps={viewProps}
/>
))
) : (
<p className="bg-custom-background-100 px-4 py-2.5 text-sm text-custom-text-200">
No issues.
</p>
)
) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div>
)}
<>
<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,
}}
/>
<ListInlineCreateIssueForm
isOpen={isCreateIssueFormOpen && !disableAddIssueOption}
handleClose={() => setIsCreateIssueFormOpen(false)}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
[displayFilters?.group_by!]: groupTitle,
}}
/>
{!disableAddIssueOption && !isCreateIssueFormOpen && (
// TODO: add border here
<div className="w-full bg-custom-background-100 px-6 py-3 border-b border-custom-border-100">
<Disclosure as="div" defaultOpen>
{({ open }) => (
<div>
<div className="flex items-center justify-between px-4 py-2.5 bg-custom-background-90">
<Disclosure.Button>
<div className="flex items-center gap-x-3">
{displayFilters?.group_by !== null && (
<div className="flex items-center">{getGroupIcon()}</div>
)}
{displayFilters?.group_by !== null ? (
<h2
className={`text-sm font-semibold leading-6 text-custom-text-100 ${
displayFilters?.group_by === "created_by" ? "" : "capitalize"
}`}
>
{getGroupTitle()}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<span className="text-custom-text-200 min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs">
{groupedIssues[groupTitle as keyof IIssue].length}
</span>
</div>
</Disclosure.Button>
{isArchivedIssues ? (
""
) : type === "issue" ? (
!disableAddIssueOption && (
<button
type="button"
className="p-1 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => {
if (isDraftIssuesPage || isMyIssuesPage || isProfileIssuesPage) {
addIssueToGroup();
} else setIsCreateIssueFormOpen(true);
if (isDraftIssuesPage) setIsDraftIssuesModalOpen(true);
else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
else setIsCreateIssueFormOpen(true);
}}
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
</div>
)
) : disableUserActions ? (
""
) : (
<CustomMenu
customButton={
<div className="flex cursor-pointer items-center">
<PlusIcon className="h-4 w-4" />
</div>
}
noBorder
>
<CustomMenu.MenuItem onClick={() => setIsCreateIssueFormOpen(true)}>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
{groupedIssues[groupTitle] ? (
groupedIssues[groupTitle].length > 0 ? (
groupedIssues[groupTitle].map((issue, index) => (
<SingleListIssue
key={issue.id}
type={type}
issue={issue}
projectId={issue.project_detail.id}
groupTitle={groupTitle}
index={index}
editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
handleDraftIssueSelect={
handleDraftIssueAction
? () => handleDraftIssueAction(issue, "edit")
: undefined
}
handleDraftIssueDelete={
handleDraftIssueAction
? () => handleDraftIssueAction(issue, "delete")
: undefined
}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={() => {
if (removeIssue !== null && issue.bridge_id)
removeIssue(issue.bridge_id, issue.id);
}}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
viewProps={viewProps}
/>
))
) : (
<p className="bg-custom-background-100 px-4 py-2.5 text-sm text-custom-text-200">
No issues.
</p>
)
) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div>
)}
<ListInlineCreateIssueForm
isOpen={isCreateIssueFormOpen && !disableAddIssueOption}
handleClose={() => setIsCreateIssueFormOpen(false)}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
[displayFilters?.group_by! === "labels"
? "labels_list"
: displayFilters?.group_by!]:
displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle,
}}
/>
{!disableAddIssueOption && !isCreateIssueFormOpen && !isDraftIssuesPage && (
<div className="w-full bg-custom-background-100 px-6 py-3 border-b border-custom-border-100">
<button
type="button"
onClick={() => {
if (isDraftIssuesPage) setIsDraftIssuesModalOpen(true);
else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup();
else setIsCreateIssueFormOpen(true);
}}
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
</div>
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</>
);
};
@@ -255,7 +255,6 @@ export const SpreadsheetView: React.FC<Props> = ({
<CustomMenu
customButtonClassName="!w-full"
className="!w-full"
position="left"
customButton={
<div
className={`relative group flex items-center justify-between gap-1.5 cursor-pointer text-sm text-custom-text-200 hover:text-custom-text-100 w-full py-3 px-2 ${
@@ -500,9 +499,10 @@ export const SpreadsheetView: React.FC<Props> = ({
<>
<div className="sticky left-0 w-[28rem] z-[2]">
<div
className={`relative flex flex-col h-max w-full bg-custom-background-100 z-[2] ${
isScrolled ? "shadow-r shadow-custom-shadow-xs" : ""
}`}
className="relative flex flex-col h-max w-full bg-custom-background-100 z-[2]"
style={{
boxShadow: isScrolled ? "8px -9px 12px rgba(0, 0, 0, 0.15)" : "",
}}
>
<div className="flex items-center text-sm font-medium z-[2] h-11 w-full sticky top-0 bg-custom-background-90 border border-l-0 border-custom-border-100">
{currentViewProperties.key && (
@@ -640,16 +640,11 @@ export const SpreadsheetView: React.FC<Props> = ({
<CustomMenu
className="sticky left-0 z-10"
customButton={
<button
className="flex gap-1.5 items-center text-custom-primary-100 pl-4 py-2.5 text-sm sticky left-0 z-[1] border-custom-border-200 w-full"
type="button"
>
<div className="flex gap-1.5 items-center text-custom-primary-100 pl-4 py-2.5 text-sm sticky left-0 z-[1] border-custom-border-200 w-full">
<PlusIcon className="h-4 w-4" />
New Issue
</button>
</div>
}
position="left"
verticalPosition="top"
optionsClassName="left-5 !w-36"
noBorder
>
+11 -5
View File
@@ -4,6 +4,7 @@ import { Tab, Transition, Popover } from "@headlessui/react";
// react colors
import { TwitterPicker } from "react-color";
// hooks
import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// types
import { Props } from "./types";
@@ -38,6 +39,7 @@ const EmojiIconPicker: React.FC<Props> = ({
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
const buttonRef = useRef<HTMLButtonElement>(null);
const emojiPickerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -49,10 +51,12 @@ const EmojiIconPicker: React.FC<Props> = ({
}, [value, onChange]);
useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false));
useDynamicDropdownPosition(isOpen, () => setIsOpen(false), buttonRef, emojiPickerRef);
return (
<Popover className="relative z-[1]">
<Popover.Button
ref={buttonRef}
onClick={() => setIsOpen((prev) => !prev)}
className="outline-none"
disabled={disabled}
@@ -61,6 +65,8 @@ const EmojiIconPicker: React.FC<Props> = ({
</Popover.Button>
<Transition
show={isOpen}
static
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
@@ -68,11 +74,11 @@ const EmojiIconPicker: React.FC<Props> = ({
leaveFrom="transform opacity-100 scale-100"
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">
<div
ref={emojiPickerRef}
className="h-[230px] w-[250px] overflow-auto rounded-[4px] border border-custom-border-200 bg-custom-background-80 p-2 shadow-xl"
>
<Popover.Panel
ref={emojiPickerRef}
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.List className="flex-0 -mx-2 flex justify-around gap-1 p-1">
{tabOptions.map((tab) => (
+4 -1
View File
@@ -80,6 +80,9 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
const router = useRouter();
const { cycleId, moduleId } = router.query;
const isCyclePage = router.pathname.split("/")[4] === "cycles" && !cycleId;
const isModulePage = router.pathname.split("/")[4] === "modules" && !moduleId;
const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) =>
blocks && blocks.length > 0
? blocks.map((block: any) => ({
@@ -317,7 +320,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
SidebarBlockRender={SidebarBlockRender}
enableReorder={enableReorder}
/>
{chartBlocks && (
{chartBlocks && !(isCyclePage || isModulePage) && (
<div className="pl-2.5 py-3">
<GanttInlineCreateIssueForm
isOpen={isCreateIssueFormOpen}
+21 -3
View File
@@ -15,10 +15,28 @@ export const StateGroupBacklogIcon: React.FC<Props> = ({
height={height}
width={width}
className={className}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 323.15 323.03"
>
<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>
);
+26 -5
View File
@@ -9,17 +9,38 @@ export const StateGroupStartedIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
color = "#f59e0b",
color = "#f39e1f",
}) => (
<svg
height={height}
width={width}
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
fill="none"
viewBox="0 0 152.93 152.95"
>
<circle cx="6" cy="6" r="5.6" stroke={color} strokeWidth="0.8" />
<circle cx="6" cy="6" r="3.35" stroke={color} strokeWidth="0.8" strokeDasharray="2.4 2.4" />
<g id="Layer_2" data-name="Layer 2">
<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>
);
@@ -92,7 +92,6 @@ export const SelectRepository: React.FC<Props> = ({
)}
</>
}
position="right"
optionsClassName="w-full"
/>
);
@@ -143,7 +143,6 @@ export const JiraGetImportDetail: React.FC = () => {
)}
</span>
}
verticalPosition="top"
>
{projects && projects.length > 0 ? (
projects.map((project) => (
+33 -1
View File
@@ -8,6 +8,7 @@ import { Controller, useForm } from "react-hook-form";
import aiService from "services/ai.service";
// hooks
import useToast from "hooks/use-toast";
import useLocalStorage from "hooks/use-local-storage";
// components
import { GptAssistantModal } from "components/core";
import { ParentIssuesListModal } from "components/issues";
@@ -60,6 +61,7 @@ interface IssueFormProps {
action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue"
) => Promise<void>;
data?: Partial<IIssue> | null;
isOpen: boolean;
prePopulatedData?: Partial<IIssue> | null;
projectId: string;
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
@@ -89,6 +91,7 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
const {
handleFormSubmit,
data,
isOpen,
prePopulatedData,
projectId,
setActiveProject,
@@ -109,6 +112,8 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {});
const editorRef = useRef<any>(null);
const router = useRouter();
@@ -133,6 +138,33 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
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 = () => {
handleClose();
};
@@ -273,7 +305,7 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
)}
<form
onSubmit={handleSubmit((formData) =>
handleCreateUpdateIssue(formData, "convertToNewIssue")
handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createDraft")
)}
>
<div className="space-y-5">
@@ -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">
<DraftIssueForm
isOpen={isOpen}
handleFormSubmit={handleFormSubmit}
prePopulatedData={prePopulateData}
data={data}
+13 -12
View File
@@ -129,18 +129,19 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
const issueName = watch("name");
const payload: Partial<IIssue> = {
name: getValues("name"),
description: getValues("description"),
state: getValues("state"),
priority: getValues("priority"),
assignees: getValues("assignees"),
labels: getValues("labels"),
start_date: getValues("start_date"),
target_date: getValues("target_date"),
project: getValues("project"),
parent: getValues("parent"),
cycle: getValues("cycle"),
module: getValues("module"),
name: watch("name"),
description: watch("description"),
description_html: watch("description_html"),
state: watch("state"),
priority: watch("priority"),
assignees: watch("assignees"),
labels: watch("labels"),
start_date: watch("start_date"),
target_date: watch("target_date"),
project: watch("project"),
parent: watch("parent"),
cycle: watch("cycle"),
module: watch("module"),
};
useEffect(() => {
+1 -1
View File
@@ -158,7 +158,7 @@ export const IssueMainContent: React.FC<Props> = ({
</a>
</Link>
<CustomMenu position="left" ellipsis optionsClassName="px-1.5">
<CustomMenu ellipsis optionsClassName="px-1.5">
{siblingIssuesList ? (
siblingIssuesList.length > 0 ? (
<>
+48 -1
View File
@@ -20,6 +20,7 @@ import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
import useProjects from "hooks/use-projects";
import useMyIssues from "hooks/my-issues/use-my-issues";
import useLocalStorage from "hooks/use-local-storage";
import { useWorkspaceView } from "hooks/use-workspace-view";
// components
import { IssueForm, ConfirmIssueDiscard } from "components/issues";
// types
@@ -37,6 +38,7 @@ import {
VIEW_ISSUES,
INBOX_ISSUES,
PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS,
WORKSPACE_VIEW_ISSUES,
} from "constants/fetch-keys";
// constants
import { INBOX_ISSUE_SOURCE } from "constants/inbox";
@@ -81,7 +83,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const [prePopulateData, setPreloadedData] = useState<Partial<IIssue>>({});
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId, inboxId } = router.query;
const { workspaceSlug, projectId, cycleId, moduleId, viewId, globalViewId, inboxId } =
router.query;
const { displayFilters, params } = useIssuesView();
const { params: calendarParams } = useCalendarIssuesView();
@@ -94,6 +97,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
const { params: globalViewParams } = useWorkspaceView();
const { setValue: setValueInLocalStorage, clearValue: clearLocalStorageValue } =
useLocalStorage<any>("draftedIssue", {});
@@ -276,6 +281,40 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
});
};
const workspaceIssuesPath = [
{
params: {
sub_issue: false,
},
path: "workspace-views/all-issues",
},
{
params: {
assignees: user?.id ?? undefined,
sub_issue: false,
},
path: "workspace-views/assigned",
},
{
params: {
created_by: user?.id ?? undefined,
sub_issue: false,
},
path: "workspace-views/created",
},
{
params: {
subscriber: user?.id ?? undefined,
sub_issue: false,
},
path: "workspace-views/subscribed",
},
];
const currentWorkspaceIssuePath = workspaceIssuesPath.find((path) =>
router.pathname.includes(path.path)
);
const calendarFetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams)
: moduleId
@@ -332,6 +371,14 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
mutate(USER_ISSUE(workspaceSlug as string));
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
if (globalViewId)
mutate(WORKSPACE_VIEW_ISSUES(globalViewId.toString(), globalViewParams));
if (currentWorkspaceIssuePath)
mutate(
WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), currentWorkspaceIssuePath?.params)
);
})
.catch(() => {
setToastAlert({
@@ -93,7 +93,6 @@ export const PeekOverviewHeader: React.FC<Props> = ({
<Icon iconName={peekModes.find((m) => m.key === mode)?.icon ?? ""} />
</button>
}
position="left"
>
{peekModes.map((mode) => (
<CustomSelect.Option key={mode.key} value={mode.key}>
@@ -33,7 +33,6 @@ export const IssueEstimateSelect: React.FC<Props> = ({ value, onChange }) => {
</div>
}
onChange={onChange}
position="right"
width="w-full min-w-[8rem]"
noChevron
>
@@ -14,7 +14,7 @@ import { ExistingIssuesListModal } from "components/core";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { BlockedIcon } from "components/icons";
// types
import { BlockeIssueDetail, IIssue, ISearchIssueResponse, UserAuth } from "types";
import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
type Props = {
issueId?: string;
@@ -41,6 +41,9 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
setIsBlockedModalOpen(false);
};
const blockedByIssue =
watch("related_issues")?.filter((i) => i.relation_type === "blocked_by") || [];
const onSubmit = async (data: ISearchIssueResponse[]) => {
if (data.length === 0) {
setToastAlert({
@@ -80,18 +83,13 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
})
.then((response) => {
submitChanges({
related_issues: [
...watch("related_issues")?.filter((i) => i.relation_type !== "blocked_by"),
...response,
],
related_issues: [...watch("related_issues"), ...response],
});
});
handleClose();
};
const blockedByIssue = watch("related_issues")?.filter((i) => i.relation_type === "blocked_by");
return (
<>
<ExistingIssuesListModal
@@ -15,7 +15,7 @@ import issuesService from "services/issues.service";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { BlockerIcon } from "components/icons";
// types
import { BlockeIssueDetail, IIssue, ISearchIssueResponse, UserAuth } from "types";
import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
type Props = {
issueId?: string;
@@ -91,7 +91,6 @@ export const SidebarCycleSelect: React.FC<Props> = ({
: handleCycleChange(incompleteCycles?.find((c) => c.id === value) as ICycle);
}}
width="w-full"
position="right"
maxHeight="rg"
disabled={disabled}
>
@@ -75,10 +75,8 @@ export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
})),
],
})
.then((response) => {
submitChanges({
related_issues: [...watch("related_issues"), ...(response ?? [])],
});
.then(() => {
submitChanges();
});
handleClose();
@@ -20,17 +20,14 @@ export const SidebarEstimateSelect: React.FC<Props> = ({ value, onChange, disabl
<CustomSelect
value={value}
customButton={
<button
type="button"
className="flex items-center gap-1.5 !text-sm bg-custom-background-80 rounded px-2.5 py-0.5"
>
<div className="flex items-center gap-1.5 !text-sm bg-custom-background-80 rounded px-2.5 py-0.5">
<PlayIcon
className={`h-4 w-4 -rotate-90 ${
value !== null ? "text-custom-text-100" : "text-custom-text-200"
}`}
/>
{estimatePoints?.find((e) => e.key === value)?.value ?? "No estimate"}
</button>
</div>
}
onChange={onChange}
disabled={disabled}
@@ -87,7 +87,6 @@ export const SidebarModuleSelect: React.FC<Props> = ({
: handleModuleChange(modules?.find((m) => m.id === value) as IModule);
}}
width="w-full"
position="right"
maxHeight="rg"
disabled={disabled}
>
@@ -18,8 +18,7 @@ type Props = {
export const SidebarPrioritySelect: React.FC<Props> = ({ value, onChange, disabled = false }) => (
<CustomSelect
customButton={
<button
type="button"
<div
className={`flex items-center gap-1.5 text-left text-xs capitalize rounded px-2.5 py-0.5 ${
value === "urgent"
? "border-red-500/20 bg-red-500/20 text-red-500"
@@ -36,7 +35,7 @@ export const SidebarPrioritySelect: React.FC<Props> = ({ value, onChange, disabl
<PriorityIcon priority={value} className="!text-sm" />
</span>
<span>{value ?? "None"}</span>
</button>
</div>
}
value={value}
onChange={onChange}
@@ -75,10 +75,8 @@ export const SidebarRelatesSelect: React.FC<Props> = (props) => {
})),
],
})
.then((response) => {
submitChanges({
related_issues: [...watch("related_issues"), ...(response ?? [])],
});
.then(() => {
submitChanges();
});
handleClose();
@@ -39,7 +39,7 @@ export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, disabled
return (
<CustomSelect
customButton={
<button type="button" className="bg-custom-background-80 text-xs rounded px-2.5 py-0.5">
<div className="bg-custom-background-80 text-xs rounded px-2.5 py-0.5">
{selectedState ? (
<div className="flex items-center gap-1.5 text-left text-custom-text-100">
<StateGroupIcon stateGroup={selectedState.group} color={selectedState.color} />
@@ -53,12 +53,11 @@ export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, disabled
) : (
"None"
)}
</button>
</div>
}
value={value}
onChange={onChange}
optionsClassName="w-min"
position="left"
disabled={disabled}
>
{states ? (
+5 -1
View File
@@ -53,7 +53,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
// types
import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types";
// fetch-keys
import { ISSUE_DETAILS } from "constants/fetch-keys";
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import { ContrastIcon } from "components/icons";
type Props = {
@@ -480,6 +480,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
},
false
);
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
}}
watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
@@ -500,6 +501,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
},
false
);
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
}}
watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
@@ -517,6 +519,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
...data,
};
});
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
}}
watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
@@ -534,6 +537,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
...data,
};
});
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
}}
watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
+18 -7
View File
@@ -1,7 +1,8 @@
import React from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// lucide icons
import {
ChevronDown,
@@ -13,6 +14,7 @@ import {
Loader,
} from "lucide-react";
// components
import { IssuePeekOverview } from "components/issues/peek-overview";
import { SubIssuesRootList } from "./issues-list";
import { IssueProperty } from "./properties";
// ui
@@ -20,6 +22,8 @@ import { Tooltip, CustomMenu } from "components/ui";
// types
import { ICurrentUserResponse, IIssue } from "types";
import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
// fetch keys
import { SUB_ISSUES } from "constants/fetch-keys";
export interface ISubIssues {
workspaceSlug: string;
@@ -38,7 +42,6 @@ export interface ISubIssues {
issueId: string,
issue?: IIssue | null
) => void;
setPeekParentId: (id: string) => void;
}
export const SubIssues: React.FC<ISubIssues> = ({
@@ -54,14 +57,12 @@ export const SubIssues: React.FC<ISubIssues> = ({
handleIssuesLoader,
copyText,
handleIssueCrudOperation,
setPeekParentId,
}) => {
const router = useRouter();
const { query } = router;
const { peekIssue } = query as { peekIssue: string };
const openPeekOverview = (issue_id: string) => {
const { query } = router;
setPeekParentId(parentIssue?.id);
router.push({
pathname: router.pathname,
query: { ...query, peekIssue: issue_id },
@@ -199,7 +200,17 @@ export const SubIssues: React.FC<ISubIssues> = ({
handleIssuesLoader={handleIssuesLoader}
copyText={copyText}
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>
@@ -27,7 +27,6 @@ export interface ISubIssuesRootList {
issueId: string,
issue?: IIssue | null
) => void;
setPeekParentId: (id: string) => void;
}
export const SubIssuesRootList: React.FC<ISubIssuesRootList> = ({
@@ -42,7 +41,6 @@ export const SubIssuesRootList: React.FC<ISubIssuesRootList> = ({
handleIssuesLoader,
copyText,
handleIssueCrudOperation,
setPeekParentId,
}) => {
const { data: issues, isLoading } = useSWR(
workspaceSlug && projectId && parentIssue && parentIssue?.id
@@ -83,7 +81,6 @@ export const SubIssuesRootList: React.FC<ISubIssuesRootList> = ({
handleIssuesLoader={handleIssuesLoader}
copyText={copyText}
handleIssueCrudOperation={handleIssueCrudOperation}
setPeekParentId={setPeekParentId}
/>
))}
-13
View File
@@ -10,7 +10,6 @@ import { ExistingIssuesListModal } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { SubIssuesRootList } from "./issues-list";
import { ProgressBar } from "./progressbar";
import { IssuePeekOverview } from "components/issues/peek-overview";
// ui
import { CustomMenu } from "components/ui";
// hooks
@@ -60,8 +59,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user }) =
: null
);
const [peekParentId, setPeekParentId] = React.useState<string | null>("");
const [issuesLoader, setIssuesLoader] = React.useState<ISubIssuesRootLoaders>({
visibility: [parentIssue?.id],
delete: [],
@@ -237,7 +234,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user }) =
handleIssuesLoader={handleIssuesLoader}
copyText={copyText}
handleIssueCrudOperation={handleIssueCrudOperation}
setPeekParentId={setPeekParentId}
/>
</div>
)}
@@ -251,7 +247,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user }) =
</>
}
buttonClassName="whitespace-nowrap"
position="left"
noBorder
noChevron
>
@@ -287,7 +282,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user }) =
</>
}
buttonClassName="whitespace-nowrap"
position="left"
noBorder
noChevron
>
@@ -363,13 +357,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user }) =
)}
</>
)}
<IssuePeekOverview
handleMutation={() => peekParentId && peekIssue && mutateSubIssues(peekParentId)}
projectId={projectId ?? ""}
workspaceSlug={workspaceSlug ?? ""}
readOnly={!isEditable}
/>
</div>
);
};
@@ -113,10 +113,8 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
{...(customButton ? { customButton: assigneeLabel } : { label: assigneeLabel })}
multiple
noChevron
position={position}
disabled={isNotAllowed}
onOpen={() => setFetchAssignees(true)}
selfPositioned={selfPositioned}
width="w-full min-w-[12rem]"
/>
);
@@ -74,8 +74,6 @@ export const ViewEstimateSelect: React.FC<Props> = ({
maxHeight="md"
noChevron
disabled={isNotAllowed}
position={position}
selfPositioned={selfPositioned}
width="w-full min-w-[8rem]"
>
<CustomSelect.Option value={null}>
@@ -146,9 +146,7 @@ export const ViewLabelSelect: React.FC<Props> = ({
{...(customButton ? { customButton: labelsLabel } : { label: labelsLabel })}
multiple
noChevron
position={position}
disabled={isNotAllowed}
selfPositioned={selfPositioned}
footerOption={footerOption}
width="w-full min-w-[12rem]"
/>
@@ -59,8 +59,7 @@ export const ViewPrioritySelect: React.FC<Props> = ({
}}
maxHeight="md"
customButton={
<button
type="button"
<div
className={`grid place-items-center rounded ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} ${noBorder ? "" : "h-6 w-6 border shadow-sm"} ${
@@ -94,12 +93,10 @@ export const ViewPrioritySelect: React.FC<Props> = ({
{noBorder ? capitalizeFirstLetter(issue.priority ?? "None") : ""}
</span>
</Tooltip>
</button>
</div>
}
noChevron
disabled={isNotAllowed}
position={position}
selfPositioned={selfPositioned}
>
{PRIORITIES?.map((priority) => (
<CustomSelect.Option key={priority} value={priority} className="capitalize">
@@ -65,7 +65,6 @@ export const SidebarLeadSelect: React.FC<Props> = ({ value, onChange }) => {
}
options={options}
maxHeight="md"
position="right"
onChange={onChange}
/>
</div>
@@ -64,7 +64,6 @@ export const SidebarMembersSelect: React.FC<Props> = ({ value, onChange }) => {
options={options}
onChange={onChange}
maxHeight="md"
position="right"
multiple
/>
</div>
@@ -214,12 +214,9 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
e.stopPropagation();
}}
customButton={
<button
type="button"
className="text-sm flex w-full items-center gap-x-2 bg-custom-background-80 hover:bg-custom-background-100 p-0.5 rounded"
>
<div className="text-sm flex w-full items-center gap-x-2 bg-custom-background-80 hover:bg-custom-background-100 p-0.5 rounded">
<Icon iconName="schedule" className="h-5 w-5 text-custom-text-300" />
</button>
</div>
}
optionsClassName="!z-20"
>
@@ -167,7 +167,6 @@ export const UserDetails: React.FC<Props> = ({ user }) => {
}
input
width="w-full"
verticalPosition="top"
>
{USER_ROLES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
@@ -197,7 +196,6 @@ export const UserDetails: React.FC<Props> = ({ user }) => {
}
options={timeZoneOptions}
onChange={onChange}
verticalPosition="top"
optionsClassName="w-full"
input
/>
+2 -2
View File
@@ -384,12 +384,12 @@ export const SinglePageBlock: React.FC<Props> = ({
</button>
<CustomMenu
customButton={
<button
<div
className="flex w-full cursor-pointer items-center justify-between gap-1 rounded px-2.5 py-1 text-left text-xs duration-300 hover:bg-custom-background-90"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<BoltIcon className="h-4.5 w-3.5" />
</button>
</div>
}
>
{block.issue ? (
@@ -393,7 +393,7 @@ export const CreateProjectModal: React.FC<Props> = ({
value={value}
onChange={onChange}
options={options}
buttonClassName="!px-2 shadow-md"
buttonClassName="border-[0.5px] !px-2 shadow-md"
label={
<div className="flex items-center justify-center gap-2 py-[1px]">
{value ? (
@@ -418,7 +418,6 @@ export const CreateProjectModal: React.FC<Props> = ({
)}
</div>
}
verticalPosition="top"
noChevron
/>
);
+39 -33
View File
@@ -1,13 +1,13 @@
import React, { useRef, useState } from "react";
import React, { useState } from "react";
import useSWR from "swr";
import { useRouter } from "next/router";
// react-popper
import { usePopper } from "react-popper";
// services
import issuesService from "services/issues.service";
// hooks
import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown";
// headless ui
import { Combobox } from "@headlessui/react";
// component
@@ -19,6 +19,7 @@ import { PlusIcon } from "lucide-react";
// types
import { Tooltip } from "components/ui";
import { ICurrentUserResponse, IIssueLabels } from "types";
import { Placement } from "@popperjs/core";
// constants
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
@@ -31,6 +32,7 @@ type Props = {
buttonClassName?: string;
optionsClassName?: string;
maxRender?: number;
placement?: Placement;
hideDropdownArrow?: boolean;
disabled?: boolean;
user: ICurrentUserResponse | undefined;
@@ -45,21 +47,25 @@ export const LabelSelect: React.FC<Props> = ({
buttonClassName = "",
optionsClassName = "",
maxRender = 2,
placement,
hideDropdownArrow = false,
disabled = false,
user,
}) => {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [fetchStates, setFetchStates] = useState(false);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [labelModal, setLabelModal] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const dropdownBtn = useRef<any>(null);
const dropdownOptions = useRef<any>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
});
const { data: issueLabels } = useSWR<IIssueLabels[]>(
projectId && fetchStates ? PROJECT_ISSUE_LABELS(projectId) : null,
@@ -131,8 +137,6 @@ export const LabelSelect: React.FC<Props> = ({
</div>
);
useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions);
const footerOption = (
<button
type="button"
@@ -165,33 +169,35 @@ export const LabelSelect: React.FC<Props> = ({
multiple
>
{({ open }: { open: boolean }) => {
if (open) {
if (!isOpen) setIsOpen(true);
setFetchStates(true);
} else if (isOpen) setIsOpen(false);
if (open) setFetchStates(true);
return (
<>
<Combobox.Button
ref={dropdownBtn}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: value.length <= maxRender
? "cursor-pointer"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: value.length <= maxRender
? "cursor-pointer"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
</button>
</Combobox.Button>
<div className={`${open ? "fixed z-20 top-0 left-0 h-full w-full cursor-auto" : ""}`}>
<Combobox.Options
ref={dropdownOptions}
className={`absolute z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-lg focus:outline-none w-48 whitespace-nowrap mt-1 ${optionsClassName}`}
<Combobox.Options>
<div
className={`z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap my-1 ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-custom-text-300" />
@@ -234,8 +240,8 @@ export const LabelSelect: React.FC<Props> = ({
)}
</div>
{footerOption}
</Combobox.Options>
</div>
</div>
</Combobox.Options>
</>
);
}}
+3 -2
View File
@@ -16,9 +16,10 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
value: any;
onChange: (val: string) => void;
isDisabled?: boolean;
};
export const MemberSelect: React.FC<Props> = ({ value, onChange }) => {
export const MemberSelect: React.FC<Props> = ({ value, onChange, isDisabled = false }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@@ -76,9 +77,9 @@ export const MemberSelect: React.FC<Props> = ({ value, onChange }) => {
]
}
maxHeight="md"
position="right"
width="w-full"
onChange={onChange}
disabled={isDisabled}
/>
);
};
+36 -30
View File
@@ -1,9 +1,10 @@
import React, { useRef, useState } from "react";
import React, { useState } from "react";
import { useRouter } from "next/router";
// react-popper
import { usePopper } from "react-popper";
// hooks
import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown";
import useProjectMembers from "hooks/use-project-members";
import useWorkspaceMembers from "hooks/use-workspace-members";
// headless ui
@@ -15,6 +16,7 @@ import { ChevronDownIcon } from "@heroicons/react/20/solid";
import { CheckIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// types
import { IUser } from "types";
import { Placement } from "@popperjs/core";
type Props = {
value: string | string[];
@@ -25,6 +27,7 @@ type Props = {
className?: string;
buttonClassName?: string;
optionsClassName?: string;
placement?: Placement;
hideDropdownArrow?: boolean;
disabled?: boolean;
};
@@ -38,18 +41,22 @@ export const MembersSelect: React.FC<Props> = ({
className = "",
buttonClassName = "",
optionsClassName = "",
placement,
hideDropdownArrow = false,
disabled = false,
}) => {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [fetchStates, setFetchStates] = useState(false);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const router = useRouter();
const { workspaceSlug } = router.query;
const dropdownBtn = useRef<any>(null);
const dropdownOptions = useRef<any>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
});
const { members } = useProjectMembers(
workspaceSlug?.toString(),
@@ -105,8 +112,6 @@ export const MembersSelect: React.FC<Props> = ({
</Tooltip>
);
useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions);
return (
<Combobox
as="div"
@@ -117,31 +122,32 @@ export const MembersSelect: React.FC<Props> = ({
multiple
>
{({ open }: { open: boolean }) => {
if (open) {
if (!isOpen) setIsOpen(true);
setFetchStates(true);
} else if (isOpen) setIsOpen(false);
if (open) setFetchStates(true);
return (
<>
<Combobox.Button
ref={dropdownBtn}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
</button>
</Combobox.Button>
<div className={`${open ? "fixed z-20 top-0 left-0 h-full w-full cursor-auto" : ""}`}>
<Combobox.Options
ref={dropdownOptions}
className={`absolute z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-lg focus:outline-none w-48 whitespace-nowrap mt-1 ${optionsClassName}`}
<Combobox.Options>
<div
className={`z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap my-1 ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-custom-text-300" />
@@ -183,8 +189,8 @@ export const MembersSelect: React.FC<Props> = ({
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</Combobox.Options>
</div>
</div>
</Combobox.Options>
</>
);
}}
+77 -79
View File
@@ -1,7 +1,7 @@
import React, { useRef, useState } from "react";
import React, { useState } from "react";
// hooks
import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown";
// react-popper
import { usePopper } from "react-popper";
// headless ui
import { Combobox } from "@headlessui/react";
// icons
@@ -12,6 +12,7 @@ import { PriorityIcon } from "components/icons";
import { Tooltip } from "components/ui";
// types
import { TIssuePriorities } from "types";
import { Placement } from "@popperjs/core";
// constants
import { PRIORITIES } from "constants/project";
@@ -21,6 +22,7 @@ type Props = {
className?: string;
buttonClassName?: string;
optionsClassName?: string;
placement?: Placement;
hideDropdownArrow?: boolean;
disabled?: boolean;
};
@@ -31,14 +33,18 @@ export const PrioritySelect: React.FC<Props> = ({
className = "",
buttonClassName = "",
optionsClassName = "",
placement,
hideDropdownArrow = false,
disabled = false,
}) => {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const dropdownBtn = useRef<any>(null);
const dropdownOptions = useRef<any>(null);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
});
const options = PRIORITIES?.map((priority) => ({
value: priority,
@@ -87,8 +93,6 @@ export const PrioritySelect: React.FC<Props> = ({
</Tooltip>
);
useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions);
return (
<Combobox
as="div"
@@ -97,77 +101,71 @@ export const PrioritySelect: React.FC<Props> = ({
onChange={onChange}
disabled={disabled}
>
{({ open }: { open: boolean }) => {
if (open) {
if (!isOpen) setIsOpen(true);
} else if (isOpen) setIsOpen(false);
return (
<>
<Combobox.Button
ref={dropdownBtn}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
</Combobox.Button>
<div className={`${open ? "fixed z-20 top-0 left-0 h-full w-full cursor-auto" : ""}`}>
<Combobox.Options
ref={dropdownOptions}
className={`absolute z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-lg focus:outline-none w-48 whitespace-nowrap mt-1 ${optionsClassName}`}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
active && !selected ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && <CheckIcon className={`h-3.5 w-3.5`} />}
</>
)}
</Combobox.Option>
))
) : (
<span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p>
</span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</Combobox.Options>
</div>
</>
);
}}
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
</button>
</Combobox.Button>
<Combobox.Options>
<div
className={`z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap my-1 ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
active && !selected ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && <CheckIcon className={`h-3.5 w-3.5`} />}
</>
)}
</Combobox.Option>
))
) : (
<span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p>
</span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
</Combobox>
);
};
@@ -230,7 +230,6 @@ const SendProjectInvitationModal: React.FC<Props> = (props) => {
onChange(val);
}}
options={options}
position="left"
width="w-full min-w-[12rem]"
/>
)}
@@ -252,12 +251,12 @@ const SendProjectInvitationModal: React.FC<Props> = (props) => {
<CustomSelect
{...field}
customButton={
<button className="flex w-full items-center justify-between gap-1 rounded-md border border-custom-border-200 shadow-sm duration-300 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 focus:outline-none px-3 py-2.5 text-sm text-left">
<div className="flex w-full items-center justify-between gap-1 rounded-md border border-custom-border-200 shadow-sm duration-300 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 focus:outline-none px-3 py-2.5 text-sm text-left">
<span className="capitalize">
{field.value ? ROLE[field.value] : "Select role"}
</span>
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</button>
</div>
}
input
width="w-full"

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