Compare commits

..

24 Commits

Author SHA1 Message Date
rahulramesha 5a249f28e1 fix sorting of existing values and add sorting to all fields 2023-11-29 11:33:12 +05:30
rahulramesha 1fadcdd1f4 fix profile draft and archived issues 2023-11-28 17:43:52 +05:30
rahulramesha edd1f6e423 fix profile issue filters and kanban 2023-11-27 17:05:08 +05:30
guru_sainath 2bf7e63625 issues rendering in all issue layouts fir profile and project issues and global issues store implementation (#2886)
* dev: draft and archived issue store

* connect draft and archived issues

* kanban for draft issues

* fix filter store for calendar and kanban

* dev: profile issues store and draft issues filters in header

* disble issue creation for draft issues

* dev: profile issues store filters

* disable kanban properties in draft issues

* dev: profile issues store filters

* dev: seperated adding issues to the cycle and module as seperate methds in cycle and module store

* dev: workspace profile issues store

* dev: sub group issues in the swimlanes

* profile issues and create issue connection

* fix profile issues

* fix spreadsheet issues

* fix dissapearing project from create issue modal

* page level modifications

* fix additional bugs

* dev: issues profile and global iisues and filters update

* fix issue related bugs

* fix project views for list and kanban

* fix build errors

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
2023-11-27 14:15:33 +05:30
Anmol Singh Bhatia eb78fd6088 fix: resolve modal overlapping issue (#2885) 2023-11-27 12:16:59 +05:30
Lakhan Baheti 202ecd21df fix: bug fixes & UI improvements (#2884)
* chore: access restriction for api tokens

* fix: on create module total issues undefined

* fix: cycle board card typo

* chore: fetch modules after creation

* fix: peek module on delete

* fix: peek cycle on delete

* fix: cycle detail sidebar copy link toast

* chore: router replace -> push
2023-11-27 12:15:10 +05:30
Aaryan Khandelwal b2ac7b9ac6 chore: revamp the API tokens workflow (#2880)
* chore: added getLayout method to api tokens pages

* revamp: api tokens workflow

* chore: add title validation and update types

* chore: minor UI updates

* chore: update route
2023-11-27 12:14:06 +05:30
Lakhan Baheti 51dff31926 fix: user state after logout (#2849)
* fix: user state after logout

* chore: user state handle with mobx

* chore: signout update for profile setting

* fix: minor fixes

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-11-25 23:04:56 +05:30
sriram veeraghanta e89f152779 fix: remove slack notification on build branch workflow (#2881) 2023-11-25 22:43:27 +05:30
Lakhan Baheti 3c9f57f8f4 fix: workspace & user avatar tooltip (#2851)
* fix: workspace & user avatar tooltip

* chore: user name update while typing on top right avatar

* chore: imports placement

* fix: rendering condition

* chore: component re-arrangement

* fix: imports
2023-11-25 21:31:09 +05:30
Bavisetti Narayan 1bc859c68c chore: seperated delete endpoint for file upload (#2870) 2023-11-25 21:28:03 +05:30
Ramesh Kumar Chandra 11d57a5bf0 fix: track events updated, extra parameters added, added events for issues, pages, states, cycles (#2875)
* fix: event tracking method updated to store, chore: updated and added events for workspace, projects and create issue

* fix: posthog auth event tracking

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-11-25 21:26:26 +05:30
Prateek Shourya 2980c7b00d Feat: God Mode UI Updates and More Config Settings (#2877)
* feat: Images in Plane config screen.
* feat: Enable/ Disable Magic Login config toggle.
* style: UX copy and design updates across all screens.
* style: SSO and OAuth Screen revamp.
* style: Enter God Mode button for Profile Settings sidebar.
* fix: update input type to password for password fields.
2023-11-25 21:23:50 +05:30
Anmol Singh Bhatia 5c6a59ba35 dev: badge component added in planu ui package (#2876) 2023-11-25 21:21:03 +05:30
Anmol Singh Bhatia a3ea7c8f10 fix: issue peek overview state select dropdown overflow fix (#2873) 2023-11-25 21:18:54 +05:30
Anmol Singh Bhatia cb922fb113 fix: module sidebar date select fix and code refactor (#2872) 2023-11-25 21:18:16 +05:30
sriram veeraghanta 06564ee856 fix: remove slack notify (#2871)
* fix: remove slack notifications on workflows

* fix: bugfix
2023-11-24 14:31:44 +05:30
Nikhil c7e6118804 refactor: image upload modals, file size limit added to config (#2868)
* chore: add file size limit as config in the config api

* refactor: image upload modals

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-11-24 13:23:46 +05:30
Manish Gupta 069b8b3ed9 Updated the slack notification message to PR Title (#2869) 2023-11-24 13:11:21 +05:30
Lakhan Baheti 38a5b7bec0 chore: added error toast for invitation (#2853) 2023-11-24 12:47:02 +05:30
Bavisetti Narayan 236caaafe8 chore: user deactivation and login restriction (#2855)
* chore: user deactivation

* chore: deactivation and login disabled

* chore: added get configuration value

* chore: serializer message change

* chore: instance admin passowrd change

* chore: removed triage

* chore: v3 endpoint for user profile

* chore: added enable signin

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-11-24 12:22:24 +05:30
Nikhil a6d5eab634 chore: api and webhook refactor (#2861)
* chore: bug fix

* dev: changes in api endpoints for invitations and inbox

* chore: improvements

* dev: update webhook send

* dev: webhook validation and fix webhook flow for app

* dev: error messages for deactivation

* chore: api fixes

* dev: update webhook and workspace leave

* chore: issue comment

* dev: default values for environment variables

* dev: make the user active if he was already part of project member

* chore: webhook cycle and module event

* dev: disable ssl for emails

* dev: webhooks restructuring

* dev: updated webhook configuration

* dev: webhooks

* dev: state get object

* dev: update workspace slug validation

* dev: remove deactivation flag if max retries exceeded

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-11-24 12:19:26 +05:30
sriram veeraghanta 8d76c96a6f fix: adding slack notification when build is failed to upload to docker (#2862)
* fix: removing logs

* fix: adding slack notification when build is failed to upload to docker

* minor changes

---------

Co-authored-by: Manish Gupta <59428681+manishg3@users.noreply.github.com>
2023-11-24 12:17:31 +05:30
Aaryan Khandelwal 97be4b60ae chore: update profile and God mode routes (#2860)
* chore: update profile and god mode routes

* fix: profile activity loader

* chore: update profile route in the change password page
2023-11-24 12:16:37 +05:30
243 changed files with 6656 additions and 2919 deletions
+16 -18
View File
@@ -1,11 +1,10 @@
name: Branch Build
on:
pull_request:
types:
types:
- closed
branches:
branches:
- master
- release
- qa
@@ -23,7 +22,7 @@ jobs:
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
# - name: Set Target Branch Name on PR close
# if: ${{ github.event_name == 'pull_request' && github.event.action =='closed' }}
# run: echo "TARGET_BRANCH=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV
@@ -31,7 +30,7 @@ jobs:
# - name: Set Target Branch Name on other than PR close
# if: ${{ github.event_name == 'push' }}
# run: echo "TARGET_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
- uses: ASzc/change-string-case-action@v2
id: gh_branch_upper_lower
with:
@@ -41,22 +40,22 @@ jobs:
id: gh_branch_replace_slash
with:
source: ${{ steps.gh_branch_upper_lower.outputs.lowercase }}
find: '/'
replace: '-'
find: "/"
replace: "-"
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_replace_dot
with:
source: ${{ steps.gh_branch_replace_slash.outputs.value }}
find: '.'
replace: ''
find: "."
replace: ""
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_clean
with:
source: ${{ steps.gh_branch_replace_dot.outputs.value }}
find: '_'
replace: ''
find: "_"
replace: ""
- name: Uploading Proxy Source
uses: actions/upload-artifact@v3
with:
@@ -77,7 +76,6 @@ jobs:
!./nginx
!./deploy
!./space
- name: Uploading Space Source
uses: actions/upload-artifact@v3
with:
@@ -89,11 +87,11 @@ jobs:
!./deploy
!./web
outputs:
gh_branch_name: ${{ steps.gh_branch_clean.outputs.value }}
gh_branch_name: ${{ steps.gh_branch_clean.outputs.value }}
branch_build_push_frontend:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
needs: [branch_build_and_push]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -123,7 +121,7 @@ jobs:
branch_build_push_space:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
needs: [branch_build_and_push]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -153,7 +151,7 @@ jobs:
branch_build_push_backend:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
needs: [branch_build_and_push]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -183,7 +181,7 @@ jobs:
branch_build_push_proxy:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
needs: [branch_build_and_push]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
+68 -101
View File
@@ -35,6 +35,7 @@ from plane.settings.redis import redis_instance
from plane.bgtasks.magic_link_code_task import magic_link
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.event_tracking_task import auth_events
def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user)
@@ -152,7 +153,8 @@ class SignUpEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
@@ -160,30 +162,17 @@ class SignUpEndpoint(BaseAPIView):
workspace_member_invites.delete()
project_member_invites.delete()
try:
# Send Analytics
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "email",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_UP",
},
)
except RequestException as e:
capture_exception(e)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
first_time=True
)
access_token, refresh_token = get_tokens_for_user(user)
@@ -294,7 +283,8 @@ class SignInEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
@@ -302,30 +292,17 @@ class SignInEndpoint(BaseAPIView):
# Delete all the invites
workspace_member_invites.delete()
project_member_invites.delete()
try:
# Send Analytics
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "email",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_IN",
},
)
except RequestException as e:
capture_exception(e)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
first_time=False
)
access_token, refresh_token = get_tokens_for_user(user)
data = {
@@ -373,6 +350,13 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
"ENABLE_MAGIC_LINK_LOGIN",
os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
)
and not (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
@@ -434,8 +418,7 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
ri.set(key, json.dumps(value), ex=expiry)
current_site = request.META.get('HTTP_ORIGIN')
current_site = request.META.get("HTTP_ORIGIN")
magic_link.delay(email, key, token, current_site)
return Response({"key": key}, status=status.HTTP_200_OK)
@@ -467,30 +450,25 @@ class MagicSignInEndpoint(BaseAPIView):
if str(token) == str(user_token):
if User.objects.filter(email=email).exists():
user = User.objects.get(email=email)
try:
# Send event to Jitsu for tracking
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "code",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_IN",
},
)
except RequestException as e:
capture_exception(e)
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=False
)
else:
user = User.objects.create(
email=email,
@@ -498,30 +476,18 @@ class MagicSignInEndpoint(BaseAPIView):
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
try:
# Send event to Jitsu for tracking
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "code",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_UP",
},
)
except RequestException as e:
capture_exception(e)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=True
)
user.is_active = True
user.last_active = timezone.now()
@@ -579,7 +545,8 @@ class MagicSignInEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
+62 -57
View File
@@ -29,6 +29,7 @@ from plane.db.models import (
ProjectMemberInvite,
ProjectMember,
)
from plane.bgtasks.event_tracking_task import auth_events
from .base import BaseAPIView
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@@ -143,19 +144,29 @@ class OauthEndpoint(BaseAPIView):
"key", "value"
)
if (
not get_configuration_value(
instance_configuration,
"GOOGLE_CLIENT_ID",
os.environ.get("GOOGLE_CLIENT_ID"),
(
not get_configuration_value(
instance_configuration,
"GOOGLE_CLIENT_ID",
os.environ.get("GOOGLE_CLIENT_ID"),
)
or not get_configuration_value(
instance_configuration,
"GITHUB_CLIENT_ID",
os.environ.get("GITHUB_CLIENT_ID"),
)
)
or not get_configuration_value(
instance_configuration,
"GITHUB_CLIENT_ID",
os.environ.get("GITHUB_CLIENT_ID"),
and not (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
)
) and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists():
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
@@ -275,29 +286,18 @@ class OauthEndpoint(BaseAPIView):
"last_login_at": timezone.now(),
},
)
try:
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": f"oauth-{medium}",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_IN",
},
)
except RequestException as e:
capture_exception(e)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium=medium.upper(),
first_time=False
)
access_token, refresh_token = get_tokens_for_user(user)
@@ -310,6 +310,23 @@ class OauthEndpoint(BaseAPIView):
except User.DoesNotExist:
## Signup Case
if (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
username = uuid.uuid4().hex
if "@" in email:
@@ -401,29 +418,17 @@ class OauthEndpoint(BaseAPIView):
workspace_member_invites.delete()
project_member_invites.delete()
try:
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": f"oauth-{medium}",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_UP",
},
)
except RequestException as e:
capture_exception(e)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium=medium.upper(),
first_time=True
)
SocialLoginConnection.objects.update_or_create(
medium=medium,
+1 -1
View File
@@ -183,7 +183,7 @@ class PageViewSet(BaseViewSet):
unarchive_archive_page_and_descendants(page_id, datetime.now())
return Response(status=status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_204_NO_CONTENT)
def unarchive(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
@@ -0,0 +1,31 @@
import uuid
from posthog import Posthog
from django.conf import settings
#third party imports
from celery import shared_task
from sentry_sdk import capture_exception
@shared_task
def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
print(user, email, user_agent, ip, event_name, medium, first_time)
try:
posthog = Posthog(settings.POSTHOG_API_KEY, host=settings.POSTHOG_HOST)
posthog.capture(
email,
event=event_name,
properties={
"event_id": uuid.uuid4().hex,
"user": {"email": email, "id": str(user)},
"device_ctx": {
"ip": ip,
"user_agent": user_agent,
},
"medium": medium,
"first_time": first_time
}
)
except Exception as e:
capture_exception(e)
+5 -1
View File
@@ -322,4 +322,8 @@ ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
# Use Minio settings
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
# Posthog settings
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
-16
View File
@@ -326,20 +326,6 @@ def filter_start_target_date_issues(params, filter, method):
return filter
def filter_archived_issues(params, filter, method):
archived = params.get("archived", "false")
if archived == "true":
filter["archived_at__isnull"] = False
return filter
def filter_draft_issues(params, filter, method):
draft = params.get("draft", "false")
if draft == "true":
filter["is_draft"] = True
return filter
def issue_filters(query_params, method):
filter = {}
@@ -367,8 +353,6 @@ def issue_filters(query_params, method):
"sub_issue": filter_sub_issue_toggle,
"subscriber": filter_subscribed_issues,
"start_target_date": filter_start_target_date_issues,
"archived": filter_archived_issues,
"draft": filter_draft_issues,
}
for key, value in ISSUE_FILTER.items():
+1
View File
@@ -36,3 +36,4 @@ scout-apm==2.26.1
openpyxl==3.1.2
beautifulsoup4==4.12.2
dj-database-url==2.1.0
posthog==3.0.2
@@ -44,7 +44,7 @@ export const SummaryPopover: React.FC<Props> = (props) => {
</button>
{!sidePeekVisible && (
<div
className="hidden group-hover/summary-popover:block z-10 max-h-80 w-64 shadow-custom-shadow-rg rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3"
className="hidden group-hover/summary-popover:block z-10 max-h-80 w-64 shadow-custom-shadow-rg rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 overflow-y-auto"
ref={setPopperElement}
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react";
import {
getIconStyling,
getBadgeStyling,
TBadgeVariant,
TBadgeSizes,
} from "./helper";
export interface BadgeProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: TBadgeVariant;
size?: TBadgeSizes;
className?: string;
loading?: boolean;
disabled?: boolean;
appendIcon?: any;
prependIcon?: any;
children: React.ReactNode;
}
const Badge = React.forwardRef<HTMLButtonElement, BadgeProps>((props, ref) => {
const {
variant = "primary",
size = "md",
className = "",
type = "button",
loading = false,
disabled = false,
prependIcon = null,
appendIcon = null,
children,
...rest
} = props;
const buttonStyle = getBadgeStyling(variant, size, disabled || loading);
const buttonIconStyle = getIconStyling(size);
return (
<button
ref={ref}
type={type}
className={`${buttonStyle} ${className}`}
disabled={disabled || loading}
{...rest}
>
{prependIcon && (
<div className={buttonIconStyle}>
{React.cloneElement(prependIcon, { strokeWidth: 2 })}
</div>
)}
{children}
{appendIcon && (
<div className={buttonIconStyle}>
{React.cloneElement(appendIcon, { strokeWidth: 2 })}
</div>
)}
</button>
);
});
Badge.displayName = "plane-ui-badge";
export { Badge };
+145
View File
@@ -0,0 +1,145 @@
export type TBadgeVariant =
| "primary"
| "accent-primary"
| "outline-primary"
| "neutral"
| "accent-neutral"
| "outline-neutral"
| "success"
| "accent-success"
| "outline-success"
| "warning"
| "accent-warning"
| "outline-warning"
| "destructive"
| "accent-destructive"
| "outline-destructive";
export type TBadgeSizes = "sm" | "md" | "lg" | "xl";
export interface IBadgeStyling {
[key: string]: {
default: string;
hover: string;
disabled: string;
};
}
enum badgeSizeStyling {
sm = `px-2.5 py-1 font-medium text-xs rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
md = `px-4 py-1.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
lg = `px-4 py-2 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
xl = `px-5 py-3 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
}
enum badgeIconStyling {
sm = "h-3 w-3 flex justify-center items-center overflow-hidden flex-shrink-0",
md = "h-3.5 w-3.5 flex justify-center items-center overflow-hidden flex-shrink-0",
lg = "h-4 w-4 flex justify-center items-center overflow-hidden flex-shrink-0",
xl = "h-4 w-4 flex justify-center items-center overflow-hidden flex-shrink-0",
}
export const badgeStyling: IBadgeStyling = {
primary: {
default: `text-white bg-custom-primary-100`,
hover: `hover:bg-custom-primary-200`,
disabled: `cursor-not-allowed !bg-custom-primary-60 hover:bg-custom-primary-60`,
},
"accent-primary": {
default: `bg-custom-primary-10 text-custom-primary-100`,
hover: `hover:bg-custom-primary-20 hover:text-custom-primary-200`,
disabled: `cursor-not-allowed !text-custom-primary-60`,
},
"outline-primary": {
default: `text-custom-primary-100 bg-custom-background-100 border border-custom-primary-100`,
hover: `hover:border-custom-primary-80 hover:bg-custom-primary-10`,
disabled: `cursor-not-allowed !text-custom-primary-60 !border-custom-primary-60 `,
},
neutral: {
default: `text-custom-background-100 bg-custom-text-100 border border-custom-border-200`,
hover: `hover:bg-custom-text-200`,
disabled: `cursor-not-allowed bg-custom-border-200 !text-custom-text-400`,
},
"accent-neutral": {
default: `text-custom-text-200 bg-custom-background-80`,
hover: `hover:bg-custom-border-200 hover:text-custom-text-100`,
disabled: `cursor-not-allowed !text-custom-text-400`,
},
"outline-neutral": {
default: `text-custom-text-200 bg-custom-background-100 border border-custom-border-200`,
hover: `hover:text-custom-text-100 hover:bg-custom-border-200`,
disabled: `cursor-not-allowed !text-custom-text-400`,
},
success: {
default: `text-white bg-green-500`,
hover: `hover:bg-green-600`,
disabled: `cursor-not-allowed !bg-green-300`,
},
"accent-success": {
default: `text-green-500 bg-green-50`,
hover: `hover:bg-green-100 hover:text-green-600`,
disabled: `cursor-not-allowed !text-green-300`,
},
"outline-success": {
default: `text-green-500 bg-custom-background-100 border border-green-500`,
hover: `hover:text-green-600 hover:bg-green-50`,
disabled: `cursor-not-allowed !text-green-300 border-green-300`,
},
warning: {
default: `text-white bg-amber-500`,
hover: `hover:bg-amber-600`,
disabled: `cursor-not-allowed !bg-amber-300`,
},
"accent-warning": {
default: `text-amber-500 bg-amber-50`,
hover: `hover:bg-amber-100 hover:text-amber-600`,
disabled: `cursor-not-allowed !text-amber-300`,
},
"outline-warning": {
default: `text-amber-500 bg-custom-background-100 border border-amber-500`,
hover: `hover:text-amber-600 hover:bg-amber-50`,
disabled: `cursor-not-allowed !text-amber-300 border-amber-300`,
},
destructive: {
default: `text-white bg-red-500`,
hover: `hover:bg-red-600`,
disabled: `cursor-not-allowed !bg-red-300`,
},
"accent-destructive": {
default: `text-red-500 bg-red-50`,
hover: `hover:bg-red-100 hover:text-red-600`,
disabled: `cursor-not-allowed !text-red-300`,
},
"outline-destructive": {
default: `text-red-500 bg-custom-background-100 border border-red-500`,
hover: `hover:text-red-600 hover:bg-red-50`,
disabled: `cursor-not-allowed !text-red-300 border-red-300`,
},
};
export const getBadgeStyling = (
variant: TBadgeVariant,
size: TBadgeSizes,
disabled: boolean = false
): string => {
let _variant: string = ``;
const currentVariant = badgeStyling[variant];
_variant = `${currentVariant.default} ${
disabled ? currentVariant.disabled : currentVariant.hover
}`;
let _size: string = ``;
if (size) _size = badgeSizeStyling[size];
return `${_variant} ${_size}`;
};
export const getIconStyling = (size: TBadgeSizes): string => {
let icon: string = ``;
if (size) icon = badgeIconStyling[size];
return icon;
};
+1
View File
@@ -0,0 +1 @@
export * from "./badge";
+2 -2
View File
@@ -51,11 +51,11 @@ const CustomSelect = (props: ICustomSelectProps) => {
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
className={`flex items-center justify-between gap-1 text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`}
} ${customButtonClassName}`}
>
{customButton}
</button>
+1
View File
@@ -1,5 +1,6 @@
export * from "./avatar";
export * from "./breadcrumbs";
export * from "./badge";
export * from "./button";
export * from "./dropdowns";
export * from "./form-fields";
@@ -10,16 +10,12 @@ import { useMobxStore } from "lib/mobx/store-provider";
import { Button } from "@plane/ui";
// hooks
import useToast from "hooks/use-toast";
// services
import { AuthService } from "services/auth.service";
type Props = {
isOpen: boolean;
onClose: () => void;
};
const authService = new AuthService();
export const DeactivateAccountModal: React.FC<Props> = (props) => {
const { isOpen, onClose } = props;
@@ -28,7 +24,7 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
const [isDeactivating, setIsDeactivating] = useState(false);
const {
user: { deactivateAccount },
user: { deactivateAccount, signOut },
} = useMobxStore();
const router = useRouter();
@@ -46,8 +42,7 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
const handleSwitchAccount = async () => {
setSwitchingAccount(true);
await authService
.signOut()
await signOut()
.then(() => {
mutate("CURRENT_USER_DETAILS", null);
setTheme("system");
+49 -37
View File
@@ -1,61 +1,71 @@
//react
import { useState, Fragment, FC } from "react";
//next
import { useRouter } from "next/router";
//ui
import { Button } from "@plane/ui";
//hooks
import useToast from "hooks/use-toast";
//services
import { APITokenService } from "services/api_token.service";
//headless ui
import { mutate } from "swr";
import { Dialog, Transition } from "@headlessui/react";
// services
import { APITokenService } from "services/api_token.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// types
import { IApiToken } from "types/api_token";
// fetch-keys
import { API_TOKENS_LIST } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
handleClose: () => void;
tokenId?: string;
onClose: () => void;
tokenId: string;
};
const apiTokenService = new APITokenService();
export const DeleteTokenModal: FC<Props> = (props) => {
const { isOpen, handleClose, tokenId } = props;
export const DeleteApiTokenModal: FC<Props> = (props) => {
const { isOpen, onClose, tokenId } = props;
// states
const [deleteLoading, setDeleteLoading] = useState<boolean>(false);
// hooks
const { setToastAlert } = useToast();
// router
const router = useRouter();
const { workspaceSlug, tokenId: tokenIdFromQuery } = router.query;
const { workspaceSlug } = router.query;
const handleClose = () => {
onClose();
setDeleteLoading(false);
};
const handleDeletion = () => {
if (!workspaceSlug || (!tokenIdFromQuery && !tokenId)) return;
const token = tokenId || tokenIdFromQuery;
if (!workspaceSlug) return;
setDeleteLoading(true);
apiTokenService
.deleteApiToken(workspaceSlug.toString(), token!.toString())
.deleteApiToken(workspaceSlug.toString(), tokenId)
.then(() => {
setToastAlert({
message: "Token deleted successfully",
type: "success",
title: "Success",
title: "Success!",
message: "Token deleted successfully.",
});
router.replace(`/${workspaceSlug}/settings/api-tokens/`);
mutate<IApiToken[]>(
API_TOKENS_LIST(workspaceSlug.toString()),
(prevData) => (prevData ?? []).filter((token) => token.id !== tokenId),
false
);
handleClose();
})
.catch((err) => {
.catch((err) =>
setToastAlert({
message: err?.message,
type: "error",
title: "Error",
});
})
.finally(() => {
setDeleteLoading(false);
handleClose();
});
message: err?.message ?? "Something went wrong. Please try again.",
})
)
.finally(() => setDeleteLoading(false));
};
return (
@@ -85,22 +95,24 @@ export const DeleteTokenModal: FC<Props> = (props) => {
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-3 p-6">
<div className="flex flex-col gap-3 p-4">
<div className="flex w-full items-center justify-start">
<h3 className="text-xl font-semibold 2xl:text-2xl">Are you sure you want to revoke access?</h3>
<h3 className="text-lg font-medium leading-6 text-custom-text-100">
Are you sure you want to delete the token?
</h3>
</div>
<span>
<p className="text-base font-normal text-custom-text-400">
Any applications Using this developer key will no longer have the access to Plane Data. This
Action cannot be undone.
<p className="text-sm text-custom-text-400">
Any application using this token will no longer have the access to Plane data. This action cannot
be undone.
</p>
</span>
<div className="flex justify-end mt-2 gap-2">
<Button variant="neutral-primary" onClick={handleClose} disabled={deleteLoading}>
<Button variant="neutral-primary" onClick={handleClose} size="sm">
Cancel
</Button>
<Button variant="primary" onClick={handleDeletion} loading={deleteLoading} disabled={deleteLoading}>
{deleteLoading ? "Revoking..." : "Revoke"}
<Button variant="danger" onClick={handleDeletion} loading={deleteLoading} size="sm">
{deleteLoading ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
+12 -18
View File
@@ -1,15 +1,16 @@
// react
import React from "react";
// next
import Image from "next/image";
import { useRouter } from "next/router";
// ui
import { Button } from "@plane/ui";
// assets
import emptyApiTokens from "public/empty-state/api-token.svg";
export const APITokenEmptyState = () => {
const router = useRouter();
type Props = {
onClick: () => void;
};
export const ApiTokenEmptyState: React.FC<Props> = (props) => {
const { onClick } = props;
return (
<div
@@ -17,19 +18,12 @@ export const APITokenEmptyState = () => {
>
<div className="text-center flex flex-col items-center w-full">
<Image src={emptyApiTokens} className="w-52 sm:w-60" alt="empty" />
<h6 className="text-xl font-semibold mt-6 sm:mt-8 mb-3">No API Tokens</h6>
{
<p className="text-custom-text-300 mb-7 sm:mb-8">
Create API tokens for safe and easy data sharing with external apps, maintaining control and security
</p>
}
<Button
className="flex items-center gap-1.5"
onClick={() => {
router.push(`${router.asPath}/create/`);
}}
>
Add Token
<h6 className="text-xl font-semibold mt-6 sm:mt-8 mb-3">No API tokens</h6>
<p className="text-custom-text-300 mb-7 sm:mb-8">
Create API tokens for safe and easy data sharing with external apps, maintaining control and security.
</p>
<Button className="flex items-center gap-1.5" onClick={onClick}>
Add token
</Button>
</div>
</div>
-160
View File
@@ -1,160 +0,0 @@
import { Dispatch, SetStateAction, useState, FC } from "react";
import { useForm } from "react-hook-form";
import { useRouter } from "next/router";
// helpers
import { addDays, renderDateFormat } from "helpers/date-time.helper";
import { csvDownload } from "helpers/download.helper";
// types
import { IApiToken } from "types/api_token";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
import useToast from "hooks/use-toast";
// services
import { APITokenService } from "services/api_token.service";
// components
import { APITokenTitle } from "./token-title";
import { APITokenDescription } from "./token-description";
import { APITokenExpiry, EXPIRY_OPTIONS } from "./token-expiry";
import { APITokenKeySection } from "./token-key-section";
// ui
import { Button } from "@plane/ui";
interface APITokenFormProps {
generatedToken: IApiToken | null | undefined;
setGeneratedToken: Dispatch<SetStateAction<IApiToken | null | undefined>>;
setDeleteTokenModal: Dispatch<SetStateAction<boolean>>;
}
export interface APIFormFields {
never_expires: boolean;
title: string;
description: string;
}
const apiTokenService = new APITokenService();
export const APITokenForm: FC<APITokenFormProps> = (props) => {
const { generatedToken, setGeneratedToken, setDeleteTokenModal } = props;
// states
const [loading, setLoading] = useState<boolean>(false);
const [neverExpires, setNeverExpire] = useState<boolean>(false);
const [focusTitle, setFocusTitle] = useState<boolean>(false);
const [focusDescription, setFocusDescription] = useState<boolean>(false);
const [selectedExpiry, setSelectedExpiry] = useState<number>(1);
// hooks
const { setToastAlert } = useToast();
// store
const {
theme: { sidebarCollapsed },
} = useMobxStore();
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const {
control,
handleSubmit,
formState: { errors },
} = useForm({
defaultValues: {
never_expires: false,
title: "",
description: "",
},
});
const getExpiryDate = (): string | null => {
if (neverExpires === true) return null;
return addDays({ date: new Date(), days: EXPIRY_OPTIONS[selectedExpiry].days }).toISOString();
};
function renderExpiry(): string {
return renderDateFormat(addDays({ date: new Date(), days: EXPIRY_OPTIONS[selectedExpiry].days }), true);
}
const downloadSecretKey = (token: IApiToken) => {
const csvData = {
Label: token.label,
Description: token.description,
Expiry: renderDateFormat(token.expired_at ?? null),
"Secret Key": token.token,
};
csvDownload(csvData, `Secret-key-${Date.now()}`);
};
const generateToken = async (data: any) => {
if (!workspaceSlug) return;
setLoading(true);
await apiTokenService
.createApiToken(workspaceSlug.toString(), {
label: data.title,
description: data.description,
expired_at: getExpiryDate(),
})
.then((res) => {
setGeneratedToken(res);
downloadSecretKey(res);
setLoading(false);
})
.catch((err) => {
setToastAlert({
message: err.message,
type: "error",
title: "Error",
});
});
};
return (
<form
onSubmit={handleSubmit(generateToken, (err) => {
if (err.title) {
setFocusTitle(true);
}
})}
className={`${sidebarCollapsed ? "xl:w-[50%] lg:w-[60%] " : "w-[60%]"} mx-auto py-8`}
>
<div className="border-b border-custom-border-200 pb-4">
<APITokenTitle
generatedToken={generatedToken}
control={control}
errors={errors}
focusTitle={focusTitle}
setFocusTitle={setFocusTitle}
setFocusDescription={setFocusDescription}
/>
{errors.title && focusTitle && <p className=" text-red-600">{errors.title.message}</p>}
<APITokenDescription
generatedToken={generatedToken}
control={control}
focusDescription={focusDescription}
setFocusTitle={setFocusTitle}
setFocusDescription={setFocusDescription}
/>
</div>
{!generatedToken && (
<div className="mt-12">
<>
<APITokenExpiry
neverExpires={neverExpires}
selectedExpiry={selectedExpiry}
setSelectedExpiry={setSelectedExpiry}
setNeverExpire={setNeverExpire}
renderExpiry={renderExpiry}
control={control}
/>
<Button variant="primary" type="submit">
{loading ? "generating..." : "Add Api key"}
</Button>
</>
</div>
)}
<APITokenKeySection
generatedToken={generatedToken}
renderExpiry={renderExpiry}
setDeleteTokenModal={setDeleteTokenModal}
/>
</form>
);
};
@@ -1,56 +0,0 @@
import { Dispatch, FC, SetStateAction } from "react";
import { Control, Controller } from "react-hook-form";
// ui
import { TextArea } from "@plane/ui";
// types
import { IApiToken } from "types/api_token";
import type { APIFormFields } from "./index";
interface APITokenDescriptionProps {
generatedToken: IApiToken | null | undefined;
control: Control<APIFormFields, any>;
focusDescription: boolean;
setFocusTitle: Dispatch<SetStateAction<boolean>>;
setFocusDescription: Dispatch<SetStateAction<boolean>>;
}
export const APITokenDescription: FC<APITokenDescriptionProps> = (props) => {
const { generatedToken, control, focusDescription, setFocusTitle, setFocusDescription } = props;
return (
<Controller
control={control}
name="description"
render={({ field: { value, onChange } }) =>
focusDescription ? (
<TextArea
id="description"
name="description"
autoFocus={true}
onBlur={() => {
setFocusDescription(false);
}}
value={value}
defaultValue={value}
onChange={onChange}
placeholder="Description"
className="mt-3"
rows={3}
/>
) : (
<p
onClick={() => {
if (generatedToken != null) return;
setFocusTitle(false);
setFocusDescription(true);
}}
role="button"
className={`${value.length === 0 ? "text-custom-text-400/60" : "text-custom-text-300"} text-lg pt-3`}
>
{value.length != 0 ? value : "Description"}
</p>
)
}
/>
);
};
@@ -1,111 +0,0 @@
import { Dispatch, Fragment, SetStateAction, FC } from "react";
import { Control, Controller } from "react-hook-form";
import { Menu, Transition } from "@headlessui/react";
// ui
import { ToggleSwitch } from "@plane/ui";
// types
import { APIFormFields } from "./index";
interface APITokenExpiryProps {
neverExpires: boolean;
selectedExpiry: number;
setSelectedExpiry: Dispatch<SetStateAction<number>>;
setNeverExpire: Dispatch<SetStateAction<boolean>>;
renderExpiry: () => string;
control: Control<APIFormFields, any>;
}
export const EXPIRY_OPTIONS = [
{
title: "7 Days",
days: 7,
},
{
title: "30 Days",
days: 30,
},
{
title: "1 Month",
days: 30,
},
{
title: "3 Months",
days: 90,
},
{
title: "1 Year",
days: 365,
},
];
export const APITokenExpiry: FC<APITokenExpiryProps> = (props) => {
const { neverExpires, selectedExpiry, setSelectedExpiry, setNeverExpire, renderExpiry, control } = props;
return (
<>
<Menu>
<p className="text-sm font-medium mb-2"> Expiration Date</p>
<Menu.Button className={"w-[40%]"} disabled={neverExpires}>
<div className="py-3 w-full font-medium px-3 flex border border-custom-border-200 rounded-md justify-center items-baseline">
<p className={`text-base ${neverExpires ? "text-custom-text-400/40" : ""}`}>
{EXPIRY_OPTIONS[selectedExpiry].title.toLocaleLowerCase()}
</p>
<p className={`text-sm mr-auto ml-2 text-custom-text-400${neverExpires ? "/40" : ""}`}>
({renderExpiry()})
</p>
</div>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute z-10 overflow-y-scroll whitespace-nowrap rounded-sm max-h-36 border origin-top-right mt-1 overflow-auto min-w-[10rem] border-custom-border-100 p-1 shadow-lg focus:outline-none bg-custom-background-100">
{EXPIRY_OPTIONS.map((option, index) => (
<Menu.Item key={index}>
{({ active }) => (
<div className="py-1">
<button
type="button"
onClick={() => {
setSelectedExpiry(index);
}}
className={`w-full text-sm select-none truncate rounded px-3 py-1.5 text-left text-custom-text-300 hover:bg-custom-background-80 ${
active ? "bg-custom-background-80" : ""
}`}
>
{option.title}
</button>
</div>
)}
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Menu>
<div className="mt-4 mb-6 flex items-center">
<span className="text-sm font-medium"> Never Expires</span>
<Controller
control={control}
name="never_expires"
render={({ field: { onChange, value } }) => (
<ToggleSwitch
className="ml-3"
value={value}
onChange={(val) => {
onChange(val);
setNeverExpire(val);
}}
size="sm"
/>
)}
/>
</div>
</>
);
};
@@ -1,59 +0,0 @@
import { Dispatch, SetStateAction, FC } from "react";
// icons
import { Copy } from "lucide-react";
// ui
import { Button } from "@plane/ui";
// hooks
import useToast from "hooks/use-toast";
// types
import { IApiToken } from "types/api_token";
interface APITokenKeySectionProps {
generatedToken: IApiToken | null | undefined;
renderExpiry: () => string;
setDeleteTokenModal: Dispatch<SetStateAction<boolean>>;
}
export const APITokenKeySection: FC<APITokenKeySectionProps> = (props) => {
const { generatedToken, renderExpiry, setDeleteTokenModal } = props;
// hooks
const { setToastAlert } = useToast();
return generatedToken ? (
<div className={`mt-${generatedToken ? "8" : "16"}`}>
<p className="font-medium text-base pb-2">Api key created successfully</p>
<p className="text-sm pb-4 w-[80%] text-custom-text-400/60">
Save this API key somewhere safe. You will not be able to view it again once you close this page or reload this
page.
</p>
<Button variant="neutral-primary" className="py-3 w-[85%] flex justify-between items-center">
<p className="font-medium text-base">{generatedToken.token}</p>
<Copy
size={18}
color="#B9B9B9"
onClick={() => {
navigator.clipboard.writeText(generatedToken.token);
setToastAlert({
message: "The Secret key has been successfully copied to your clipboard",
type: "success",
title: "Copied to clipboard",
});
}}
/>
</Button>
<p className="mt-2 text-sm text-custom-text-400/60">
{generatedToken.expired_at ? "Expires on " + renderExpiry() : "Never Expires"}
</p>
<button
className="border py-3 px-5 text-custom-primary-100 text-sm mt-8 rounded-md border-custom-primary-100 w-fit font-medium"
onClick={(e) => {
e.preventDefault();
setDeleteTokenModal(true);
}}
>
Revoke
</button>
</div>
) : null;
};
@@ -1,69 +0,0 @@
import { Dispatch, FC, SetStateAction } from "react";
import { Control, Controller, FieldErrors } from "react-hook-form";
// ui
import { Input } from "@plane/ui";
// types
import { IApiToken } from "types/api_token";
import type { APIFormFields } from "./index";
interface APITokenTitleProps {
generatedToken: IApiToken | null | undefined;
errors: FieldErrors<APIFormFields>;
control: Control<APIFormFields, any>;
focusTitle: boolean;
setFocusTitle: Dispatch<SetStateAction<boolean>>;
setFocusDescription: Dispatch<SetStateAction<boolean>>;
}
export const APITokenTitle: FC<APITokenTitleProps> = (props) => {
const { generatedToken, errors, control, focusTitle, setFocusTitle, setFocusDescription } = props;
return (
<Controller
control={control}
name="title"
rules={{
required: "Title is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
}}
render={({ field: { value, onChange, ref } }) =>
focusTitle ? (
<Input
id="title"
name="title"
type="text"
inputSize="md"
onBlur={() => {
setFocusTitle(false);
}}
onError={() => {
console.log("error");
}}
autoFocus
value={value}
onChange={onChange}
ref={ref}
hasError={!!errors.title}
placeholder="Title"
className="resize-none text-xl w-full"
/>
) : (
<p
onClick={() => {
if (generatedToken != null) return;
setFocusDescription(false);
setFocusTitle(true);
}}
role="button"
className={`${value.length === 0 ? "text-custom-text-400/60" : ""} font-medium text-[24px]`}
>
{value.length != 0 ? value : "Api Title"}
</p>
)
}
/>
);
};
+1 -1
View File
@@ -1,4 +1,4 @@
export * from "./modal";
export * from "./delete-token-modal";
export * from "./empty-state";
export * from "./token-list-item";
export * from "./form";
@@ -0,0 +1,133 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
import { Dialog, Transition } from "@headlessui/react";
// services
import { APITokenService } from "services/api_token.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { CreateApiTokenForm, GeneratedTokenDetails } from "components/api-token";
// helpers
import { csvDownload } from "helpers/download.helper";
import { renderFormattedDate } from "helpers/date-time.helper";
// types
import { IApiToken } from "types/api_token";
// fetch-keys
import { API_TOKENS_LIST } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
onClose: () => void;
};
// services
const apiTokenService = new APITokenService();
export const CreateApiTokenModal: React.FC<Props> = (props) => {
const { isOpen, onClose } = props;
// states
const [neverExpires, setNeverExpires] = useState<boolean>(false);
const [generatedToken, setGeneratedToken] = useState<IApiToken | null | undefined>(null);
// router
const router = useRouter();
const { workspaceSlug } = router.query;
// toast alert
const { setToastAlert } = useToast();
const handleClose = () => {
onClose();
setTimeout(() => {
setNeverExpires(false);
setGeneratedToken(null);
}, 350);
};
const downloadSecretKey = (data: IApiToken) => {
const csvData = {
Title: data.label,
Description: data.description,
Expiry: data.expired_at ? renderFormattedDate(data.expired_at) : "Never expires",
"Secret key": data.token ?? "",
};
csvDownload(csvData, `secret-key-${Date.now()}`);
};
const handleCreateToken = async (data: Partial<IApiToken>) => {
if (!workspaceSlug) return;
// make the request to generate the token
await apiTokenService
.createApiToken(workspaceSlug.toString(), data)
.then((res) => {
setGeneratedToken(res);
downloadSecretKey(res);
mutate<IApiToken[]>(
API_TOKENS_LIST(workspaceSlug.toString()),
(prevData) => {
if (!prevData) return;
return [res, ...prevData];
},
false
);
})
.catch((err) => {
setToastAlert({
message: err.message,
type: "error",
title: "Error",
});
throw err;
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={() => {}}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="h-full w-full grid place-items-center text-center p-4">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 text-left shadow-custom-shadow-md transition-all px-4 sm:w-full sm:max-w-2xl">
{generatedToken ? (
<GeneratedTokenDetails handleClose={handleClose} tokenDetails={generatedToken} />
) : (
<CreateApiTokenForm
handleClose={handleClose}
neverExpires={neverExpires}
toggleNeverExpires={() => setNeverExpires((prevData) => !prevData)}
onSubmit={handleCreateToken}
/>
)}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
+247
View File
@@ -0,0 +1,247 @@
import { useState } from "react";
import { add } from "date-fns";
import { Controller, useForm } from "react-hook-form";
import { Calendar } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
// components
import { CustomDatePicker } from "components/ui";
// ui
import { Button, CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui";
// helpers
import { renderFormattedDate, renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import { IApiToken } from "types/api_token";
type Props = {
handleClose: () => void;
neverExpires: boolean;
toggleNeverExpires: () => void;
onSubmit: (data: Partial<IApiToken>) => Promise<void>;
};
const EXPIRY_DATE_OPTIONS = [
{
key: "1_week",
label: "1 week",
value: { weeks: 1 },
},
{
key: "1_month",
label: "1 month",
value: { months: 1 },
},
{
key: "3_months",
label: "3 months",
value: { months: 3 },
},
{
key: "1_year",
label: "1 year",
value: { years: 1 },
},
];
const defaultValues: Partial<IApiToken> = {
label: "",
description: "",
expired_at: null,
};
const getExpiryDate = (val: string): string | null => {
const today = new Date();
const dateToAdd = EXPIRY_DATE_OPTIONS.find((option) => option.key === val)?.value;
if (dateToAdd) {
const expiryDate = add(today, dateToAdd);
return renderFormattedDate(expiryDate);
}
return null;
};
export const CreateApiTokenForm: React.FC<Props> = (props) => {
const { handleClose, neverExpires, toggleNeverExpires, onSubmit } = props;
// states
const [customDate, setCustomDate] = useState<Date | null>(null);
// toast alert
const { setToastAlert } = useToast();
// form
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
reset,
watch,
} = useForm<IApiToken>({ defaultValues });
const handleFormSubmit = async (data: IApiToken) => {
// if never expires is toggled off, and the user has not selected a custom date or a predefined date, show an error
if (!neverExpires && (!data.expired_at || (data.expired_at === "custom" && !customDate)))
return setToastAlert({
type: "error",
title: "Error!",
message: "Please select an expiration date.",
});
const payload: Partial<IApiToken> = {
label: data.label,
description: data.description,
};
// if never expires is toggled on, set expired_at to null
if (neverExpires) payload.expired_at = null;
// if never expires is toggled off, and the user has selected a custom date, set expired_at to the custom date
else if (data.expired_at === "custom") payload.expired_at = renderFormattedPayloadDate(customDate ?? new Date());
// if never expires is toggled off, and the user has selected a predefined date, set expired_at to the predefined date
else {
const expiryDate = getExpiryDate(data.expired_at ?? "");
if (expiryDate) payload.expired_at = renderFormattedPayloadDate(expiryDate);
}
await onSubmit(payload).then(() => {
reset(defaultValues);
setCustomDate(null);
});
};
const today = new Date();
const tomorrow = add(today, { days: 1 });
return (
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-4">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">Create token</h3>
<div className="space-y-3">
<div>
<Controller
control={control}
name="label"
rules={{
required: "Title is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
validate: (val) => val.trim() !== "" || "Title is required",
}}
render={({ field: { value, onChange } }) => (
<Input
type="text"
value={value}
onChange={onChange}
hasError={Boolean(errors.label)}
placeholder="Token title"
className="text-sm font-medium w-full"
/>
)}
/>
{errors.label && <span className="text-xs text-red-500">{errors.label.message}</span>}
</div>
<Controller
control={control}
name="description"
render={({ field: { value, onChange } }) => (
<TextArea
value={value}
onChange={onChange}
hasError={Boolean(errors.description)}
placeholder="Token description"
className="text-sm h-24 w-full"
/>
)}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Controller
control={control}
name="expired_at"
render={({ field: { onChange, value } }) => {
const selectedOption = EXPIRY_DATE_OPTIONS.find((option) => option.key === value);
return (
<CustomSelect
customButton={
<div
className={`flex items-center gap-2 border-[0.5px] border-custom-border-200 rounded py-1 px-2 ${
neverExpires ? "text-custom-text-400" : ""
}`}
>
<Calendar className="h-3 w-3" />
{value === "custom"
? "Custom date"
: selectedOption
? selectedOption.label
: "Set expiration date"}
</div>
}
value={value}
onChange={onChange}
disabled={neverExpires}
>
{EXPIRY_DATE_OPTIONS.map((option) => (
<CustomSelect.Option key={option.key} value={option.key}>
{option.label}
</CustomSelect.Option>
))}
<CustomSelect.Option value="custom">Custom</CustomSelect.Option>
</CustomSelect>
);
}}
/>
{watch("expired_at") === "custom" && (
<CustomDatePicker
value={customDate}
onChange={(date) => setCustomDate(date ? new Date(date) : null)}
minDate={tomorrow}
customInput={
<div
className={`flex items-center gap-2 py-1 px-2 text-xs cursor-pointer !rounded border-[0.5px] border-custom-border-200 !shadow-none !duration-0 ${
customDate ? "w-[7.5rem]" : ""
} ${neverExpires ? "text-custom-text-400 !cursor-not-allowed" : "hover:bg-custom-background-80"}`}
>
<Calendar className="h-3 w-3" />
{customDate ? renderFormattedDate(customDate) : "Set date"}
</div>
}
disabled={neverExpires}
/>
)}
</div>
{!neverExpires && (
<span className="text-xs text-custom-text-400">
{watch("expired_at") === "custom"
? customDate
? `Expires ${renderFormattedDate(customDate)}`
: null
: watch("expired_at")
? `Expires ${getExpiryDate(watch("expired_at") ?? "")}`
: null}
</span>
)}
</div>
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<div className="flex cursor-pointer items-center gap-1.5" onClick={toggleNeverExpires}>
<div className="flex cursor-pointer items-center justify-center">
<ToggleSwitch value={neverExpires} onChange={() => {}} size="sm" />
</div>
<span className="text-xs">Never expires</span>
</div>
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Discard
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{isSubmitting ? "Generating..." : "Generate token"}
</Button>
</div>
</div>
</form>
);
};
@@ -0,0 +1,61 @@
import { Copy } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button, Tooltip } from "@plane/ui";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IApiToken } from "types/api_token";
type Props = {
handleClose: () => void;
tokenDetails: IApiToken;
};
export const GeneratedTokenDetails: React.FC<Props> = (props) => {
const { handleClose, tokenDetails } = props;
const { setToastAlert } = useToast();
const copyApiToken = (token: string) => {
copyTextToClipboard(token).then(() =>
setToastAlert({
type: "success",
title: "Success!",
message: "Token copied to clipboard.",
})
);
};
return (
<div>
<div className="space-y-3">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">Key created</h3>
<p className="text-sm text-custom-text-400">
Copy and save this secret key in Plane Pages. You can{"'"}t see this key after you hit Close. A CSV file
containing the key has been downloaded.
</p>
</div>
<button
type="button"
onClick={() => copyApiToken(tokenDetails.token ?? "")}
className="mt-4 w-full border-[0.5px] border-custom-border-200 py-2 px-3 flex items-center justify-between font-medium rounded-md text-sm outline-none"
>
{tokenDetails.token}
<Tooltip tooltipContent="Copy secret key">
<Copy className="h-4 w-4 text-custom-text-400" />
</Tooltip>
</button>
<div className="mt-6 flex items-center justify-between">
<p className="text-custom-text-400 text-xs">
{tokenDetails.expired_at ? `Expires ${renderFormattedDate(tokenDetails.expired_at)}` : "Never expires"}
</p>
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Close
</Button>
</div>
</div>
);
};
+3
View File
@@ -0,0 +1,3 @@
export * from "./create-token-modal";
export * from "./form";
export * from "./generated-token-details";
+49 -34
View File
@@ -1,43 +1,58 @@
import Link from "next/link";
// helpers
import { formatLongDateDistance, timeAgo } from "helpers/date-time.helper";
// icons
import { useState } from "react";
import { XCircle } from "lucide-react";
// components
import { DeleteApiTokenModal } from "components/api-token";
// ui
import { Tooltip } from "@plane/ui";
// helpers
import { renderFormattedDate, timeAgo } from "helpers/date-time.helper";
// types
import { IApiToken } from "types/api_token";
interface IApiTokenListItem {
workspaceSlug: string | string[] | undefined;
type Props = {
token: IApiToken;
}
};
export const APITokenListItem = ({ token, workspaceSlug }: IApiTokenListItem) => (
<Link href={`/${workspaceSlug}/settings/api-tokens/${token.id}`} key={token.id}>
<div className="border-b flex flex-col relative justify-center items-start border-custom-border-200 py-5 hover:cursor-pointer">
<XCircle className="absolute right-5 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto justify-self-center stroke-custom-text-400 h-[15px] w-[15px]" />
<div className="flex items-center px-4">
<span className="text-sm font-medium leading-6">{token.label}</span>
<span
className={`${
token.is_active ? "bg-green-600/10 text-green-600" : "bg-custom-text-400/20 text-custom-text-400"
} flex items-center px-2 h-4 rounded-sm max-h-fit ml-2 text-xs font-medium`}
>
{token.is_active ? "Active" : "Expired"}
</span>
</div>
<div className="flex items-center px-4 w-full">
{token.description.length != 0 && (
<p className="text-sm mb-1 mr-3 font-medium leading-6 truncate max-w-[50%]">{token.description}</p>
)}
{
export const ApiTokenListItem: React.FC<Props> = (props) => {
const { token } = props;
// states
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
return (
<>
<DeleteApiTokenModal isOpen={deleteModalOpen} onClose={() => setDeleteModalOpen(false)} tokenId={token.id} />
<div className="group relative border-b border-custom-border-200 flex flex-col justify-center py-3 px-4">
<Tooltip tooltipContent="Delete token">
<button
onClick={() => setDeleteModalOpen(true)}
className="hidden group-hover:grid absolute right-4 place-items-center"
>
<XCircle className="h-4 w-4 text-red-500" />
</button>
</Tooltip>
<div className="flex items-center w-4/5">
<h5 className="text-sm font-medium truncate">{token.label}</h5>
<span
className={`${
token.is_active ? "bg-green-500/10 text-green-500" : "bg-custom-background-80 text-custom-text-400"
} flex items-center px-2 h-4 rounded-sm max-h-fit ml-2 text-xs font-medium`}
>
{token.is_active ? "Active" : "Expired"}
</span>
</div>
<div className="flex flex-col justify-center w-full mt-1">
{token.description.trim() !== "" && (
<p className="text-sm mb-1 break-words max-w-[70%]">{token.description}</p>
)}
<p className="text-xs mb-1 leading-6 text-custom-text-400">
{token.is_active
? token.expired_at === null
? "Never Expires"
: `Expires in ${formatLongDateDistance(token.expired_at!)}`
: timeAgo(token.expired_at)}
? token.expired_at
? `Expires ${renderFormattedDate(token.expired_at!)}`
: "Never expires"
: `Expired ${timeAgo(token.expired_at)}`}
</p>
}
</div>
</div>
</div>
</Link>
);
</>
);
};
@@ -14,6 +14,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
const {
commandPalette: { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal },
trackEvent: { setTrackElement }
} = useMobxStore();
return (
@@ -22,6 +23,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("COMMAND_PALETTE")
toggleCreateCycleModal(true);
}}
className="focus:outline-none"
@@ -62,6 +62,7 @@ export const CommandModal: React.FC = observer(() => {
toggleCreateIssueModal,
toggleCreateProjectModal,
},
trackEvent: { setTrackElement }
} = useMobxStore();
// router
@@ -273,6 +274,7 @@ export const CommandModal: React.FC = observer(() => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("COMMAND_PALETTE");
toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
@@ -290,6 +292,7 @@ export const CommandModal: React.FC = observer(() => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("COMMAND_PALETTE");
toggleCreateProjectModal(true);
}}
className="focus:outline-none"
@@ -33,6 +33,7 @@ export const CommandPalette: FC = observer(() => {
commandPalette,
theme: { toggleSidebar },
user: { currentUser },
trackEvent: { setTrackElement },
} = useMobxStore();
const {
toggleCommandPaletteModal,
@@ -54,8 +55,22 @@ export const CommandPalette: FC = observer(() => {
toggleBulkDeleteIssueModal,
isDeleteIssueModalOpen,
toggleDeleteIssueModal,
createIssueStoreType,
} = commandPalette;
const isAnyModalOpen = Boolean(
isCreateIssueModalOpen ||
isCreateCycleModalOpen ||
isCreatePageModalOpen ||
isCreateProjectModalOpen ||
isCreateModuleModalOpen ||
isCreateViewModalOpen ||
isShortcutModalOpen ||
isBulkDeleteIssueModalOpen ||
isDeleteIssueModalOpen
);
const { setToastAlert } = useToast();
const { data: issueDetails } = useSWR(
@@ -110,10 +125,12 @@ export const CommandPalette: FC = observer(() => {
e.preventDefault();
toggleSidebar();
}
} else {
} else if (!isAnyModalOpen) {
if (keyPressed === "c") {
setTrackElement("SHORTCUT_KEY");
toggleCreateIssueModal(true);
} else if (keyPressed === "p") {
setTrackElement("SHORTCUT_KEY");
toggleCreateProjectModal(true);
} else if (keyPressed === "h") {
toggleShortcutModal(true);
@@ -145,6 +162,7 @@ export const CommandPalette: FC = observer(() => {
toggleCreateIssueModal,
projectId,
workspaceSlug,
isAnyModalOpen,
]
);
@@ -208,6 +226,7 @@ export const CommandPalette: FC = observer(() => {
prePopulateData={
cycleId ? { cycle: cycleId.toString() } : moduleId ? { module: moduleId.toString() } : undefined
}
currentStore={createIssueStoreType}
/>
{issueId && issueDetails && (
+8 -4
View File
@@ -14,6 +14,8 @@ import { FileService } from "services/file.service";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { Button, Input, Loader } from "@plane/ui";
// constants
import { MAX_FILE_SIZE } from "constants/common";
const tabOptions = [
{
@@ -58,8 +60,10 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspace: workspaceStore } = useMobxStore();
const { currentWorkspace: workspaceDetails } = workspaceStore;
const {
workspace: { currentWorkspace },
appConfig: { envConfig },
} = useMobxStore();
const { data: unsplashImages, error: unsplashError } = useSWR(
`UNSPLASH_IMAGES_${searchParams}`,
@@ -86,7 +90,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: 5 * 1024 * 1024,
maxSize: envConfig?.file_size_limit ?? MAX_FILE_SIZE,
});
const handleSubmit = async () => {
@@ -112,7 +116,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
if (isUnsplashImage) return;
if (oldValue && workspaceDetails) fileService.deleteFile(workspaceDetails.id, oldValue);
if (oldValue && currentWorkspace) fileService.deleteFile(currentWorkspace.id, oldValue);
})
.catch((err) => {
console.log(err);
+2 -1
View File
@@ -1,5 +1,6 @@
export * from "./bulk-delete-issues-modal";
export * from "./existing-issues-list-modal";
export * from "./gpt-assistant-modal";
export * from "./image-upload-modal";
export * from "./link-modal";
export * from "./user-image-upload-modal";
export * from "./workspace-image-upload-modal";
@@ -0,0 +1,199 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import { useDropzone } from "react-dropzone";
import { Transition, Dialog } from "@headlessui/react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { FileService } from "services/file.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// icons
import { UserCircle2 } from "lucide-react";
// constants
import { MAX_FILE_SIZE } from "constants/common";
type Props = {
handleDelete?: () => void;
isOpen: boolean;
isRemoving: boolean;
onClose: () => void;
onSuccess: (url: string) => void;
value: string | null;
};
// services
const fileService = new FileService();
export const UserImageUploadModal: React.FC<Props> = observer((props) => {
const { value, onSuccess, isOpen, onClose, isRemoving, handleDelete } = props;
// states
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
const { setToastAlert } = useToast();
const {
appConfig: { envConfig },
} = useMobxStore();
const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]);
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: envConfig?.file_size_limit ?? MAX_FILE_SIZE,
multiple: false,
});
const handleClose = () => {
setImage(null);
setIsImageUploading(false);
onClose();
};
const handleSubmit = async () => {
console.log("Submit triggered");
if (!image) return;
console.log("Inside submit");
setIsImageUploading(true);
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
fileService
.uploadUserFile(formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setImage(null);
if (value) fileService.deleteUserFile(value);
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
)
.finally(() => setIsImageUploading(false));
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-xl sm:p-6">
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Upload Image
</Dialog.Title>
<div className="space-y-3">
<div className="flex items-center justify-center gap-3">
<div
{...getRootProps()}
className={`relative grid h-80 w-80 cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-custom-primary focus:ring-offset-2 ${
(image === null && isDragActive) || !value
? "border-2 border-dashed border-custom-border-200 hover:bg-custom-background-90"
: ""
}`}
>
{image !== null || (value && value !== "") ? (
<>
<button
type="button"
className="absolute top-0 right-0 z-40 translate-x-1/2 -translate-y-1/2 rounded bg-custom-background-90 px-2 py-0.5 text-xs font-medium text-custom-text-200"
>
Edit
</button>
<img
src={image ? URL.createObjectURL(image) : value ? value : ""}
alt="image"
className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
/>
</>
) : (
<div>
<UserCircle2 className="mx-auto h-16 w-16 text-custom-text-200" />
<span className="mt-2 block text-sm font-medium text-custom-text-200">
{isDragActive ? "Drop image here to upload" : "Drag & drop image here"}
</span>
</div>
)}
<input {...getInputProps()} type="text" />
</div>
</div>
{fileRejections.length > 0 && (
<p className="text-sm text-red-500">
{fileRejections[0].errors[0].code === "file-too-large"
? "The image size cannot exceed 5 MB."
: "Please upload a file in a valid format."}
</p>
)}
</div>
</div>
<p className="my-4 text-custom-text-200 text-sm">
File formats supported- .jpeg, .jpg, .png, .webp, .svg
</p>
<div className="flex items-center justify-between">
{handleDelete && (
<Button variant="danger" size="sm" onClick={handleDelete} disabled={!value}>
{isRemoving ? "Removing..." : "Remove"}
</Button>
)}
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSubmit}
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});
@@ -1,4 +1,4 @@
import React, { useCallback, useState } from "react";
import React, { useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { useDropzone } from "react-dropzone";
@@ -7,89 +7,89 @@ import { Transition, Dialog } from "@headlessui/react";
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { FileService } from "services/file.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// icons
import { UserCircle2 } from "lucide-react";
// constants
import { MAX_FILE_SIZE } from "constants/common";
type Props = {
value?: string | null;
onClose: () => void;
handleRemove?: () => void;
isOpen: boolean;
onSuccess: (url: string) => void;
isRemoving: boolean;
handleDelete: () => void;
userImage?: boolean;
onClose: () => void;
onSuccess: (url: string) => void;
value: string | null;
};
// services
const fileService = new FileService();
export const ImageUploadModal: React.FC<Props> = observer((props) => {
const { value, onSuccess, isOpen, onClose, isRemoving, handleDelete, userImage } = props;
export const WorkspaceImageUploadModal: React.FC<Props> = observer((props) => {
const { value, onSuccess, isOpen, onClose, isRemoving, handleRemove } = props;
// states
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspace: workspaceStore } = useMobxStore();
const { currentWorkspace: workspaceDetails } = workspaceStore;
const { setToastAlert } = useToast();
const onDrop = useCallback((acceptedFiles: File[]) => {
setImage(acceptedFiles[0]);
}, []);
const {
workspace: { currentWorkspace },
appConfig: { envConfig },
} = useMobxStore();
const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]);
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: 5 * 1024 * 1024,
maxSize: envConfig?.file_size_limit ?? MAX_FILE_SIZE,
multiple: false,
});
const handleClose = () => {
setImage(null);
setIsImageUploading(false);
onClose();
};
const handleSubmit = async () => {
if (!image || (!workspaceSlug && router.pathname != "/onboarding")) return;
if (!image || (!workspaceSlug && router.pathname !== "/onboarding")) return;
setIsImageUploading(true);
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
if (userImage) {
fileService
.uploadUserFile(formData)
.then((res) => {
const imageUrl = res.asset;
if (!workspaceSlug) return;
onSuccess(imageUrl);
setIsImageUploading(false);
setImage(null);
fileService
.uploadFile(workspaceSlug.toString(), formData)
.then((res) => {
const imageUrl = res.asset;
if (value) fileService.deleteUserFile(value);
onSuccess(imageUrl);
setImage(null);
if (value && currentWorkspace) fileService.deleteFile(currentWorkspace.id, value);
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
.catch((err) => {
console.error(err);
});
} else
fileService
.uploadFile(workspaceSlug as string, formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setIsImageUploading(false);
setImage(null);
if (value && workspaceDetails) fileService.deleteFile(workspaceDetails.id, value);
})
.catch((err) => {
console.error(err);
});
};
const handleClose = () => {
setImage(null);
onClose();
)
.finally(() => setIsImageUploading(false));
};
return (
@@ -172,11 +172,11 @@ export const ImageUploadModal: React.FC<Props> = observer((props) => {
File formats supported- .jpeg, .jpg, .png, .webp, .svg
</p>
<div className="flex items-center justify-between">
<div className="flex items-center">
<Button variant="danger" size="sm" onClick={handleDelete} disabled={!value}>
{handleRemove && (
<Button variant="danger" size="sm" onClick={handleRemove} disabled={!value}>
{isRemoving ? "Removing..." : "Remove"}
</Button>
</div>
)}
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
+3 -2
View File
@@ -33,7 +33,7 @@ export interface ICyclesBoardCard {
export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
// store
const { cycle: cycleStore } = useMobxStore();
const { cycle: cycleStore, trackEvent: { setTrackElement } } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// states
@@ -119,6 +119,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
setTrackElement("CYCLE_PAGE_BOARD_LAYOUT");
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
@@ -252,7 +253,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
</>
+2 -1
View File
@@ -38,7 +38,7 @@ type TCyclesListItem = {
export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
// store
const { cycle: cycleStore } = useMobxStore();
const { cycle: cycleStore, trackEvent: { setTrackElement } } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// states
@@ -119,6 +119,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
setTrackElement("CYCLE_PAGE_LIST_LAYOUT");
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
+6 -2
View File
@@ -19,7 +19,7 @@ export interface ICyclesList {
export const CyclesList: FC<ICyclesList> = observer((props) => {
const { cycles, filter, workspaceSlug, projectId } = props;
const { commandPalette: commandPaletteStore } = useMobxStore();
const { commandPalette: commandPaletteStore, trackEvent: { setTrackElement } } = useMobxStore();
return (
<>
@@ -57,7 +57,11 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
<button
type="button"
className="text-custom-primary-100 text-sm outline-none"
onClick={() => commandPaletteStore.toggleCreateCycleModal(true)}
onClick={() => {
setTrackElement("CYCLES_PAGE_EMPTY-STATE");
commandPaletteStore.toggleCreateCycleModal(true)
}
}
>
Create a new cycle
</button>
+22 -8
View File
@@ -24,26 +24,40 @@ interface ICycleDelete {
export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
const { isOpen, handleClose, cycle, workspaceSlug, projectId } = props;
// store
const { cycle: cycleStore } = useMobxStore();
const { cycle: cycleStore, trackEvent: { postHogEventTracker } } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// states
const [loader, setLoader] = useState(false);
const router = useRouter();
const { cycleId } = router.query;
const { cycleId, peekCycle } = router.query;
const formSubmit = async () => {
setLoader(true);
if (cycle?.id)
try {
await cycleStore.removeCycle(workspaceSlug, projectId, cycle?.id);
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle deleted successfully.",
await cycleStore.removeCycle(workspaceSlug, projectId, cycle?.id).then((res) => {
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle deleted successfully.",
});
postHogEventTracker(
"CYCLE_DELETE",
{
state: "SUCCESS"
}
);
}).catch((error) => {
postHogEventTracker(
"CYCLE_DELETE",
{
state: "FAILED"
}
);
});
if (cycleId) router.replace(`/${workspaceSlug}/projects/${projectId}/cycles`);
if (cycleId || peekCycle) router.push(`/${workspaceSlug}/projects/${projectId}/cycles`);
handleClose();
} catch (error) {
+15 -2
View File
@@ -24,7 +24,7 @@ const cycleService = new CycleService();
export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
const { isOpen, handleClose, data, workspaceSlug, projectId } = props;
// store
const { cycle: cycleStore } = useMobxStore();
const { cycle: cycleStore, trackEvent: { postHogEventTracker } } = useMobxStore();
// states
const [activeProject, setActiveProject] = useState<string>(projectId);
// toast
@@ -35,12 +35,19 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
const selectedProjectId = payload.project ?? projectId.toString();
await cycleStore
.createCycle(workspaceSlug, selectedProjectId, payload)
.then(() => {
.then((res) => {
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle created successfully.",
});
postHogEventTracker(
"CYCLE_CREATE",
{
...res,
state: "SUCCESS",
}
);
})
.catch(() => {
setToastAlert({
@@ -48,6 +55,12 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
title: "Error!",
message: "Error in creating cycle. Please try again.",
});
postHogEventTracker(
"CYCLE_CREATE",
{
state: "FAILED",
}
);
});
};
+37 -23
View File
@@ -52,7 +52,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query;
const { cycle: cycleDetailsStore } = useMobxStore();
const { cycle: cycleDetailsStore, trackEvent: { setTrackElement, postHogEventTracker } } = useMobxStore();
const cycleDetails = cycleDetailsStore.cycle_details[cycleId] ?? undefined;
@@ -74,8 +74,27 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
cycleService
.patchCycle(workspaceSlug as string, projectId as string, cycleId as string, data)
.then(() => mutate(CYCLE_DETAILS(cycleId as string)))
.catch((e) => console.log(e));
.then((res) => {
mutate(CYCLE_DETAILS(cycleId as string));
postHogEventTracker(
"CYCLE_UPDATE",
{
...res,
state: "SUCCESS"
}
);
}
)
.catch((e) => {
console.log(e);
postHogEventTracker(
"CYCLE_UPDATE",
{
state: "FAILED"
}
);
}
);
};
const handleCopyText = () => {
@@ -83,7 +102,8 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
.then(() => {
setToastAlert({
type: "success",
title: "Cycle link copied to clipboard",
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
})
.catch(() => {
@@ -112,13 +132,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const handleStartDateChange = async (date: string) => {
setValue("start_date", date);
if (
watch("start_date") &&
watch("end_date") &&
watch("start_date") !== "" &&
watch("end_date") &&
watch("start_date") !== ""
) {
if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") {
if (!isDateGreaterThanToday(`${watch("end_date")}`)) {
setToastAlert({
type: "error",
@@ -186,13 +200,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const handleEndDateChange = async (date: string) => {
setValue("end_date", date);
if (
watch("start_date") &&
watch("end_date") &&
watch("start_date") !== "" &&
watch("end_date") &&
watch("start_date") !== ""
) {
if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") {
if (!isDateGreaterThanToday(`${watch("end_date")}`)) {
setToastAlert({
type: "error",
@@ -296,10 +304,10 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
cycleDetails.total_issues === 0
? "0 Issue"
: cycleDetails.total_issues === cycleDetails.completed_issues
? cycleDetails.total_issues > 1
? `${cycleDetails.total_issues}`
: `${cycleDetails.total_issues}`
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
? cycleDetails.total_issues > 1
? `${cycleDetails.total_issues}`
: `${cycleDetails.total_issues}`
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
return (
<>
@@ -329,7 +337,11 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</button>
{!isCompleted && (
<CustomMenu width="lg" placement="bottom-end" ellipsis>
<CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}>
<CustomMenu.MenuItem onClick={() => {
setTrackElement("CYCLE_PAGE_SIDEBAR");
setCycleDeleteModal(true)
}
}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
@@ -379,6 +391,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_START_DATE_BUTTON");
handleStartDateChange(val);
}
}}
@@ -414,6 +427,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_END_DATE_BUTTON");
handleEndDateChange(val);
}
}}
+10 -3
View File
@@ -20,6 +20,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
import { EFilterType } from "store/issues/types";
import { EProjectStore } from "store/command-palette.store";
export const CycleIssuesHeader: React.FC = observer(() => {
const [analyticsModal, setAnalyticsModal] = useState(false);
@@ -33,14 +34,13 @@ export const CycleIssuesHeader: React.FC = observer(() => {
const {
cycle: cycleStore,
cycleIssueFilters: cycleIssueFiltersStore,
projectIssuesFilter: projectIssueFiltersStore,
project: { currentProjectDetails },
projectMember: { projectMembers },
projectLabel: { projectLabels },
projectState: projectStateStore,
commandPalette: commandPaletteStore,
trackEvent: { setTrackElement },
cycleIssuesFilter: { issueFilters, updateFilters },
} = useMobxStore();
@@ -190,7 +190,14 @@ export const CycleIssuesHeader: React.FC = observer(() => {
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
Analytics
</Button>
<Button onClick={() => commandPaletteStore.toggleCreateIssueModal(true)} size="sm" prependIcon={<Plus />}>
<Button
onClick={() => {
setTrackElement("CYCLE_PAGE_HEADER");
commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.CYCLE);
}}
size="sm"
prependIcon={<Plus />}
>
Add Issue
</Button>
<button
+6 -2
View File
@@ -14,7 +14,7 @@ export const CyclesHeader: FC = observer(() => {
const router = useRouter();
const { workspaceSlug } = router.query;
// store
const { project: projectStore, commandPalette: commandPaletteStore } = useMobxStore();
const { project: projectStore, commandPalette: commandPaletteStore, trackEvent: { setTrackElement } } = useMobxStore();
const { currentProjectDetails } = projectStore;
return (
@@ -51,7 +51,11 @@ export const CyclesHeader: FC = observer(() => {
variant="primary"
size="sm"
prependIcon={<Plus />}
onClick={() => commandPaletteStore.toggleCreateCycleModal(true)}
onClick={() => {
setTrackElement("CYCLES_PAGE_HEADER");
commandPaletteStore.toggleCreateCycleModal(true);
}
}
>
Add Cycle
</Button>
+10 -2
View File
@@ -20,6 +20,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
import { EFilterType } from "store/issues/types";
import { EProjectStore } from "store/command-palette.store";
export const ModuleIssuesHeader: React.FC = observer(() => {
const [analyticsModal, setAnalyticsModal] = useState(false);
@@ -33,11 +34,11 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
const {
module: moduleStore,
projectIssuesFilter: projectIssueFiltersStore,
project: projectStore,
projectMember: { projectMembers },
projectState: projectStateStore,
commandPalette: commandPaletteStore,
trackEvent: { setTrackElement },
projectLabel: { projectLabels },
moduleIssuesFilter: { issueFilters, updateFilters },
} = useMobxStore();
@@ -190,7 +191,14 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
Analytics
</Button>
<Button onClick={() => commandPaletteStore.toggleCreateIssueModal(true)} size="sm" prependIcon={<Plus />}>
<Button
onClick={() => {
setTrackElement("MODULE_PAGE_HEADER");
commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.MODULE);
}}
size="sm"
prependIcon={<Plus />}
>
Add Issue
</Button>
<button
+1 -1
View File
@@ -19,7 +19,7 @@ export const ProfileSettingsHeader: FC<IProfileSettingHeader> = (props) => {
type="text"
label="My Profile"
icon={<Settings className="h-4 w-4 text-custom-text-300" />}
link={`/me/profile`}
link="/profile"
/>
<Breadcrumbs.BreadcrumbItem type="text" label={title} />
</Breadcrumbs>
@@ -1,20 +1,74 @@
import { FC } from "react";
import { FC, useCallback } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues";
// ui
import { Breadcrumbs, LayersIcon } from "@plane/ui";
// helper
import { renderEmoji } from "helpers/emoji.helper";
import { EFilterType } from "store/issues/types";
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "types";
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
export const ProjectDraftIssueHeader: FC = observer(() => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string };
const { project: projectStore } = useMobxStore();
const { currentProjectDetails } = projectStore;
const {
project: { currentProjectDetails },
projectLabel: { projectLabels },
projectMember: { projectMembers },
projectState: projectStateStore,
projectDraftIssuesFilter: { issueFilters, updateFilters },
} = useMobxStore();
const activeLayout = issueFilters?.displayFilters?.layout;
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(workspaceSlug, projectId, EFilterType.FILTERS, { [key]: newValues });
},
[workspaceSlug, projectId, issueFilters, updateFilters]
);
const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EFilterType.DISPLAY_FILTERS, { layout: layout });
},
[workspaceSlug, projectId, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EFilterType.DISPLAY_FILTERS, updatedDisplayFilter);
},
[workspaceSlug, projectId, updateFilters]
);
const handleDisplayProperties = useCallback(
(property: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EFilterType.DISPLAY_PROPERTIES, property);
},
[workspaceSlug, projectId, updateFilters]
);
return (
<div className="relative flex w-full flex-shrink-0 flex-row z-10 h-[3.75rem] items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
@@ -44,6 +98,37 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
/>
</Breadcrumbs>
</div>
<div className="ml-auto flex items-center gap-2">
<LayoutSelection
layouts={["list", "kanban"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
<FiltersDropdown title="Filters" placement="bottom-end">
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
labels={projectLabels ?? undefined}
members={projectMembers?.map((m) => m.member)}
states={projectStateStore.states?.[projectId ?? ""] ?? undefined}
/>
</FiltersDropdown>
<FiltersDropdown title="Display" placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
/>
</FiltersDropdown>
</div>
</div>
</div>
);
+10 -1
View File
@@ -17,6 +17,7 @@ import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
// helper
import { renderEmoji } from "helpers/emoji.helper";
import { EFilterType } from "store/issues/types";
import { EProjectStore } from "store/command-palette.store";
export const ProjectIssuesHeader: React.FC = observer(() => {
const [analyticsModal, setAnalyticsModal] = useState(false);
@@ -31,6 +32,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
projectState: projectStateStore,
inbox: inboxStore,
commandPalette: commandPaletteStore,
trackEvent: { setTrackElement },
// issue filters
projectIssuesFilter: { issueFilters, updateFilters },
projectIssues: {},
@@ -198,7 +200,14 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
Analytics
</Button>
<Button onClick={() => commandPaletteStore.toggleCreateIssueModal(true)} size="sm" prependIcon={<Plus />}>
<Button
onClick={() => {
setTrackElement("PROJECT_PAGE_HEADER");
commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.PROJECT);
}}
size="sm"
prependIcon={<Plus />}
>
Add Issue
</Button>
</div>
+5 -2
View File
@@ -11,7 +11,7 @@ export const ProjectsHeader = observer(() => {
const { workspaceSlug } = router.query;
// store
const { project: projectStore, commandPalette: commandPaletteStore } = useMobxStore();
const { project: projectStore, commandPalette: commandPaletteStore, trackEvent: {setTrackElement} } = useMobxStore();
const projectsList = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : [];
@@ -41,7 +41,10 @@ export const ProjectsHeader = observer(() => {
</div>
)}
<Button prependIcon={<Plus />} size="sm" onClick={() => commandPaletteStore.toggleCreateProjectModal(true)}>
<Button prependIcon={<Plus />} size="sm" onClick={() => {
setTrackElement("PROJECTS_PAGE_HEADER");
commandPaletteStore.toggleCreateProjectModal(true)
}}>
Add Project
</Button>
</div>
+1 -1
View File
@@ -6,7 +6,7 @@ export const UserProfileHeader = () => (
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem type="text" label="Activity Overview" link="/me/profile" />
<Breadcrumbs.BreadcrumbItem type="text" label="Activity Overview" link="/profile" />
</Breadcrumbs>
</div>
</div>
@@ -8,9 +8,11 @@ import githubWhiteImage from "/public/logos/github-white.png";
// components
import { ProductUpdatesModal } from "components/common";
import { Breadcrumbs } from "@plane/ui";
import { useMobxStore } from "lib/mobx/store-provider";
export const WorkspaceDashboardHeader = () => {
const [isProductUpdatesModalOpen, setIsProductUpdatesModalOpen] = useState(false);
const { trackEvent: { postHogEventTracker } } = useMobxStore();
// theme
const { resolvedTheme } = useTheme();
@@ -46,7 +46,7 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
const router = useRouter();
const { workspaceSlug, projectId, inboxId } = router.query;
const { inboxIssueDetails: inboxIssueDetailsStore } = useMobxStore();
const { inboxIssueDetails: inboxIssueDetailsStore, trackEvent: { postHogEventTracker } } = useMobxStore();
const {
control,
@@ -70,6 +70,21 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
router.push(`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${res.issue_inbox[0].id}`);
handleClose();
} else reset(defaultValues);
postHogEventTracker(
"ISSUE_CREATE",
{
...res,
state: "SUCCESS"
}
);
}).catch((error) => {
console.log(error);
postHogEventTracker(
"ISSUE_CREATE",
{
state: "FAILED"
}
);
});
};
@@ -172,7 +187,7 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
onClick={() => setCreateMore((prevData) => !prevData)}
>
<span className="text-xs">Create more</span>
<ToggleSwitch value={createMore} onChange={() => {}} size="md" />
<ToggleSwitch value={createMore} onChange={() => { }} size="md" />
</div>
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={() => handleClose()}>
@@ -9,17 +9,16 @@ import useToast from "hooks/use-toast";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
export interface IInstanceOpenAIForm {
export interface IInstanceAIForm {
config: IFormattedInstanceConfiguration;
}
export interface OpenAIFormValues {
OPENAI_API_BASE: string;
export interface AIFormValues {
OPENAI_API_KEY: string;
GPT_ENGINE: string;
}
export const InstanceOpenAIForm: FC<IInstanceOpenAIForm> = (props) => {
export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
const { config } = props;
// store
const { instance: instanceStore } = useMobxStore();
@@ -30,16 +29,15 @@ export const InstanceOpenAIForm: FC<IInstanceOpenAIForm> = (props) => {
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<OpenAIFormValues>({
} = useForm<AIFormValues>({
defaultValues: {
OPENAI_API_BASE: config["OPENAI_API_BASE"],
OPENAI_API_KEY: config["OPENAI_API_KEY"],
GPT_ENGINE: config["GPT_ENGINE"],
},
});
const onSubmit = async (formData: OpenAIFormValues) => {
const payload: Partial<OpenAIFormValues> = { ...formData };
const onSubmit = async (formData: AIFormValues) => {
const payload: Partial<AIFormValues> = { ...formData };
await instanceStore
.updateInstanceConfigurations(payload)
@@ -47,64 +45,15 @@ export const InstanceOpenAIForm: FC<IInstanceOpenAIForm> = (props) => {
setToastAlert({
title: "Success",
type: "success",
message: "Open AI Settings updated successfully",
message: "AI Settings updated successfully",
})
)
.catch((err) => console.error(err));
};
return (
<div className="flex flex-col gap-8 m-8 w-4/5">
<div className="pb-2 mb-2 border-b border-custom-border-100">
<div className="text-custom-text-100 font-medium text-lg">OpenAI</div>
<div className="text-custom-text-300 font-normal text-sm">
AI is everywhere make use it as much as you can! <a href="#" className="text-custom-primary-100">Learn more.</a>
</div>
</div>
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
<div className="flex flex-col gap-1">
<h4 className="text-sm">OpenAI API Base</h4>
<Controller
control={control}
name="OPENAI_API_BASE"
render={({ field: { value, onChange, ref } }) => (
<Input
id="OPENAI_API_BASE"
name="OPENAI_API_BASE"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.OPENAI_API_BASE)}
placeholder="OpenAI API Base"
className="rounded-md font-medium w-full"
/>
)}
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">OpenAI API Key</h4>
<Controller
control={control}
name="OPENAI_API_KEY"
render={({ field: { value, onChange, ref } }) => (
<Input
id="OPENAI_API_KEY"
name="OPENAI_API_KEY"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.OPENAI_API_KEY)}
placeholder="OpenAI API Key"
className="rounded-md font-medium w-full"
/>
)}
/>
</div>
</div>
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
<>
<div className="grid grid-col grid-cols-1 lg:grid-cols-3 items-center justify-between gap-x-16 gap-y-8 w-full">
<div className="flex flex-col gap-1">
<h4 className="text-sm">GPT Engine</h4>
<Controller
@@ -119,11 +68,54 @@ export const InstanceOpenAIForm: FC<IInstanceOpenAIForm> = (props) => {
onChange={onChange}
ref={ref}
hasError={Boolean(errors.GPT_ENGINE)}
placeholder="GPT Engine"
placeholder="gpt-3.5-turbo"
className="rounded-md font-medium w-full"
/>
)}
/>
<p className="text-xs text-custom-text-400">
Choose an OpenAI engine.{" "}
<a
href="https://platform.openai.com/docs/models/overview"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Learn more
</a>
</p>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">API Key</h4>
<Controller
control={control}
name="OPENAI_API_KEY"
render={({ field: { value, onChange, ref } }) => (
<Input
id="OPENAI_API_KEY"
name="OPENAI_API_KEY"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.OPENAI_API_KEY)}
placeholder="sk-asddassdfasdefqsdfasd23das3dasdcasd"
className="rounded-md font-medium w-full"
/>
)}
/>
<p className="text-xs text-custom-text-400">
You will find your API key{" "}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
</div>
</div>
@@ -132,6 +124,6 @@ export const InstanceOpenAIForm: FC<IInstanceOpenAIForm> = (props) => {
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</>
);
};
+54 -47
View File
@@ -31,6 +31,7 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
// form data
const {
handleSubmit,
watch,
control,
formState: { errors, isSubmitting },
} = useForm<EmailFormValues>({
@@ -60,11 +61,7 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
};
return (
<div className="flex flex-col gap-8 m-8 w-4/5">
<div className="pb-2 mb-2 border-b border-custom-border-100">
<div className="text-custom-text-100 font-medium text-lg">Email</div>
<div className="text-custom-text-300 font-normal text-sm">Email related settings.</div>
</div>
<>
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Host</h4>
@@ -80,7 +77,7 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
onChange={onChange}
ref={ref}
hasError={Boolean(errors.EMAIL_HOST)}
placeholder="Email Host"
placeholder="email.google.com"
className="rounded-md font-medium w-full"
/>
)}
@@ -101,7 +98,7 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
onChange={onChange}
ref={ref}
hasError={Boolean(errors.EMAIL_PORT)}
placeholder="Email Port"
placeholder="8080"
className="rounded-md font-medium w-full"
/>
)}
@@ -123,7 +120,7 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
onChange={onChange}
ref={ref}
hasError={Boolean(errors.EMAIL_HOST_USER)}
placeholder="Username"
placeholder="getitdone@projectplane.so"
className="rounded-md font-medium w-full"
/>
)}
@@ -139,7 +136,7 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
<Input
id="EMAIL_HOST_PASSWORD"
name="EMAIL_HOST_PASSWORD"
type="text"
type="password"
value={value}
onChange={onChange}
ref={ref}
@@ -152,45 +149,55 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
</div>
</div>
<div className="flex items-center gap-8 pt-4">
<div>
<div className="text-custom-text-100 font-medium text-sm">Enable TLS</div>
<div className="w-full lg:w-1/2 flex flex-col px-1 gap-y-8">
<div className="flex items-center gap-8 pt-4 mr-8">
<div className="grow">
<div className="text-custom-text-100 font-medium text-sm">
Turn TLS {Boolean(parseInt(watch("EMAIL_USE_TLS"))) ? "off" : "on"}
</div>
<div className="text-custom-text-300 font-normal text-xs">Use this if your email domain supports TLS.</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="EMAIL_USE_TLS"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={Boolean(parseInt(value))}
onChange={() => {
Boolean(parseInt(value)) === true ? onChange("0") : onChange("1");
}}
size="sm"
/>
)}
/>
</div>
</div>
<div>
<Controller
control={control}
name="EMAIL_USE_TLS"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={Boolean(parseInt(value))}
onChange={() => {
Boolean(parseInt(value)) === true ? onChange("0") : onChange("1");
}}
size="sm"
/>
)}
/>
</div>
</div>
<div className="flex items-center gap-8 pt-4">
<div>
<div className="text-custom-text-100 font-medium text-sm">Enable SSL</div>
</div>
<div>
<Controller
control={control}
name="EMAIL_USE_SSL"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={Boolean(parseInt(value))}
onChange={() => {
Boolean(parseInt(value)) === true ? onChange("0") : onChange("1");
}}
size="sm"
/>
)}
/>
<div className="flex items-center gap-8 pt-4 mr-8">
<div className="grow">
<div className="text-custom-text-100 font-medium text-sm">
Turn SSL {Boolean(parseInt(watch("EMAIL_USE_SSL"))) ? "off" : "on"}
</div>
<div className="text-custom-text-300 font-normal text-xs">
Most email domains support SSL. Use this to secure comms between this instance and your users.
</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="EMAIL_USE_SSL"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={Boolean(parseInt(value))}
onChange={() => {
Boolean(parseInt(value)) === true ? onChange("0") : onChange("1");
}}
size="sm"
/>
)}
/>
</div>
</div>
</div>
@@ -199,6 +206,6 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</>
);
};
+6 -12
View File
@@ -14,7 +14,7 @@ export interface IInstanceGeneralForm {
export interface GeneralFormValues {
instance_name: string;
is_telemetry_enabled: boolean;
// is_telemetry_enabled: boolean;
}
export const InstanceGeneralForm: FC<IInstanceGeneralForm> = (props) => {
@@ -31,7 +31,7 @@ export const InstanceGeneralForm: FC<IInstanceGeneralForm> = (props) => {
} = useForm<GeneralFormValues>({
defaultValues: {
instance_name: instance.instance_name,
is_telemetry_enabled: instance.is_telemetry_enabled,
// is_telemetry_enabled: instance.is_telemetry_enabled,
},
});
@@ -51,13 +51,7 @@ export const InstanceGeneralForm: FC<IInstanceGeneralForm> = (props) => {
};
return (
<div className="flex flex-col gap-8 m-8">
<div className="pb-2 mb-2 border-b border-custom-border-100">
<div className="text-custom-text-100 font-medium text-lg">General</div>
<div className="text-custom-text-300 font-normal text-sm">
The usual things like your mail, name of instance and other stuff.
</div>
</div>
<>
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 items-center justify-between gap-8 w-full">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Name of instance</h4>
@@ -106,7 +100,7 @@ export const InstanceGeneralForm: FC<IInstanceGeneralForm> = (props) => {
</div>
</div>
<div className="flex items-center gap-8 pt-4">
{/* <div className="flex items-center gap-12 pt-4">
<div>
<div className="text-custom-text-100 font-medium text-sm">Share anonymous usage instance</div>
<div className="text-custom-text-300 font-normal text-xs">
@@ -120,13 +114,13 @@ export const InstanceGeneralForm: FC<IInstanceGeneralForm> = (props) => {
render={({ field: { value, onChange } }) => <ToggleSwitch value={value} onChange={onChange} size="sm" />}
/>
</div>
</div>
</div> */}
<div className="flex items-center py-1">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</>
);
};
+45 -15
View File
@@ -56,8 +56,8 @@ export const InstanceGithubConfigForm: FC<IInstanceGithubConfigForm> = (props) =
const originURL = typeof window !== "undefined" ? window.location.origin : "";
return (
<>
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
<div className="flex flex-col gap-8">
<div className="grid grid-col grid-cols-1 lg:grid-cols-3 justify-between gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Client ID</h4>
<Controller
@@ -72,11 +72,22 @@ export const InstanceGithubConfigForm: FC<IInstanceGithubConfigForm> = (props) =
onChange={onChange}
ref={ref}
hasError={Boolean(errors.GITHUB_CLIENT_ID)}
placeholder="Github Client ID"
placeholder="70a44354520df8bd9bcd"
className="rounded-md font-medium w-full"
/>
)}
/>
<p className="text-xs text-custom-text-400">
You will get this from your{" "}
<a
href="https://github.com/settings/applications/new"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitHub OAuth application settings.
</a>
</p>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Client Secret</h4>
@@ -92,14 +103,23 @@ export const InstanceGithubConfigForm: FC<IInstanceGithubConfigForm> = (props) =
onChange={onChange}
ref={ref}
hasError={Boolean(errors.GITHUB_CLIENT_SECRET)}
placeholder="Github Client Secret"
placeholder="9b0050f94ec1b744e32ce79ea4ffacd40d4119cb"
className="rounded-md font-medium w-full"
/>
)}
/>
<p className="text-xs text-custom-text-400">
Your client secret is also found in your{" "}
<a
href="https://github.com/settings/applications/new"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitHub OAuth application settings.
</a>
</p>
</div>
</div>
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Origin URL</h4>
<Button
@@ -117,16 +137,26 @@ export const InstanceGithubConfigForm: FC<IInstanceGithubConfigForm> = (props) =
<p className="font-medium text-sm">{originURL}</p>
<Copy size={18} color="#B9B9B9" />
</Button>
<p className="text-xs text-custom-text-400/60">*paste this URL in your Github console.</p>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center p-2">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
<p className="text-xs text-custom-text-400">
We will auto-generate this. Paste this into the Authorization callback URL field{" "}
<a
href="https://github.com/settings/applications/new"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
</div>
</div>
</>
<div className="flex flex-col gap-1">
<div className="flex items-center">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</div>
);
};
+45 -15
View File
@@ -56,8 +56,8 @@ export const InstanceGoogleConfigForm: FC<IInstanceGoogleConfigForm> = (props) =
const originURL = typeof window !== "undefined" ? window.location.origin : "";
return (
<>
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
<div className="flex flex-col gap-8">
<div className="grid grid-col grid-cols-1 lg:grid-cols-3 justify-between gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Client ID</h4>
<Controller
@@ -72,11 +72,22 @@ export const InstanceGoogleConfigForm: FC<IInstanceGoogleConfigForm> = (props) =
onChange={onChange}
ref={ref}
hasError={Boolean(errors.GOOGLE_CLIENT_ID)}
placeholder="Google Client ID"
placeholder="840195096245-0p2tstej9j5nc4l8o1ah2dqondscqc1g.apps.googleusercontent.com"
className="rounded-md font-medium w-full"
/>
)}
/>
<p className="text-xs text-custom-text-400">
Your client ID lives in your Google API Console.{" "}
<a
href="https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow#creatingcred"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Learn more
</a>
</p>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Client Secret</h4>
@@ -92,14 +103,23 @@ export const InstanceGoogleConfigForm: FC<IInstanceGoogleConfigForm> = (props) =
onChange={onChange}
ref={ref}
hasError={Boolean(errors.GOOGLE_CLIENT_SECRET)}
placeholder="Google Client Secret"
placeholder="GOCShX-ADp4cI0kPqav1gGCBg5bE02E"
className="rounded-md font-medium w-full"
/>
)}
/>
<p className="text-xs text-custom-text-400">
Your client secret should also be in your Google API Console.{" "}
<a
href="https://developers.google.com/identity/oauth2/web/guides/get-google-api-clientid"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Learn more
</a>
</p>
</div>
</div>
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Origin URL</h4>
<Button
@@ -117,16 +137,26 @@ export const InstanceGoogleConfigForm: FC<IInstanceGoogleConfigForm> = (props) =
<p className="font-medium text-sm">{originURL}</p>
<Copy size={18} color="#B9B9B9" />
</Button>
<p className="text-xs text-custom-text-400/60">*paste this URL in your Google developer console.</p>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center p-2">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
<p className="text-xs text-custom-text-400">
We will auto-generate this. Paste this into your Authorized JavaScript origins field. For this OAuth client{" "}
<a
href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
</div>
</div>
</>
<div className="flex flex-col gap-1">
<div className="flex items-center">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,95 @@
import { FC } from "react";
import { Controller, useForm } from "react-hook-form";
// ui
import { Button, Input } from "@plane/ui";
// types
import { IFormattedInstanceConfiguration } from "types/instance";
// hooks
import useToast from "hooks/use-toast";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
export interface IInstanceImageConfigForm {
config: IFormattedInstanceConfiguration;
}
export interface ImageConfigFormValues {
UNSPLASH_ACCESS_KEY: string;
}
export const InstanceImageConfigForm: FC<IInstanceImageConfigForm> = (props) => {
const { config } = props;
// store
const { instance: instanceStore } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// form data
const {
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<ImageConfigFormValues>({
defaultValues: {
UNSPLASH_ACCESS_KEY: config["UNSPLASH_ACCESS_KEY"],
},
});
const onSubmit = async (formData: ImageConfigFormValues) => {
const payload: Partial<ImageConfigFormValues> = { ...formData };
await instanceStore
.updateInstanceConfigurations(payload)
.then(() =>
setToastAlert({
title: "Success",
type: "success",
message: "Image Configuration Settings updated successfully",
})
)
.catch((err) => console.error(err));
};
return (
<>
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Access key from your Unsplash account</h4>
<Controller
control={control}
name="UNSPLASH_ACCESS_KEY"
render={({ field: { value, onChange, ref } }) => (
<Input
id="UNSPLASH_ACCESS_KEY"
name="UNSPLASH_ACCESS_KEY"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.UNSPLASH_ACCESS_KEY)}
placeholder="oXgq-sdfadsaeweqasdfasdf3234234rassd"
className="rounded-md font-medium w-full"
/>
)}
/>
<p className="text-xs text-custom-text-400">
You will find your access key in your Unsplash developer console.{" "}
<a
href="https://unsplash.com/documentation#creating-a-developer-account"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Learn more.
</a>
</p>
</div>
</div>
<div className="flex items-center py-1">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</>
);
};
+30 -37
View File
@@ -3,41 +3,35 @@ import { useRouter } from "next/router";
import { useTheme } from "next-themes";
import { observer } from "mobx-react-lite";
import Link from "next/link";
import { Menu, Transition } from "@headlessui/react";
import { Cog, LogIn, LogOut, Settings, UserCircle2 } from "lucide-react";
import { mutate } from "swr";
// components
import { Menu, Transition } from "@headlessui/react";
// icons
import { LogIn, LogOut, Settings, UserCog2 } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// services
import { AuthService } from "services/auth.service";
// ui
import { Avatar, Tooltip } from "@plane/ui";
// Static Data
const profileLinks = (workspaceSlug: string, userId: string) => [
{
name: "View profile",
icon: UserCircle2,
link: `/${workspaceSlug}/profile/${userId}`,
},
const PROFILE_LINKS = [
{
key: "settings",
name: "Settings",
icon: Settings,
link: `/me/profile`,
link: `/profile`,
},
];
const authService = new AuthService();
export const InstanceSidebarDropdown = observer(() => {
const router = useRouter();
// store
const {
theme: { sidebarCollapsed },
workspace: { workspaceSlug },
user: { currentUser, currentUserSettings },
user: { signOut, currentUser, currentUserSettings },
} = useMobxStore();
// hooks
const { setToastAlert } = useToast();
@@ -51,8 +45,7 @@ export const InstanceSidebarDropdown = observer(() => {
"";
const handleSignOut = async () => {
await authService
.signOut()
await signOut()
.then(() => {
mutate("CURRENT_USER_DETAILS", null);
setTheme("system");
@@ -68,28 +61,21 @@ export const InstanceSidebarDropdown = observer(() => {
};
return (
<div className="flex items-center gap-x-3 gap-y-2 px-4 py-4">
<div className="flex items-center gap-x-2 gap-y-2 px-4 pt-3 pb-2 mb-2 border border-custom-sidebar-border-200">
<div className="w-full h-full truncate">
<div
className={`flex flex-grow items-center gap-x-2 rounded p-1 truncate ${
sidebarCollapsed ? "justify-center" : ""
}`}
>
<div
className={`flex-shrink-0 flex items-center justify-center h-6 w-6 bg-custom-sidebar-background-80 rounded`}
>
<Cog className="h-5 w-5 text-custom-text-200" />
<div className={`flex-shrink-0 flex items-center justify-center h-7 w-7 rounded bg-custom-sidebar-background-80`}>
<UserCog2 className="h-6 w-6 text-custom-text-200" />
</div>
{!sidebarCollapsed && <h4 className="text-custom-text-200 font-medium text-base truncate">Instance Admin</h4>}
</div>
</div>
{!sidebarCollapsed && (
<Menu as="div" className="relative flex-shrink-0">
<Menu.Button className="flex gap-4 place-items-center outline-none">
{!sidebarCollapsed && (
<Tooltip position="bottom-left" tooltipContent="Go back to your workspace">
{!sidebarCollapsed && (
<div className="flex w-full gap-2">
<h4 className="grow text-custom-text-200 font-medium text-base truncate">God Mode</h4>
<Tooltip position="bottom-left" tooltipContent="Exit God Mode">
<div className="flex-shrink-0">
<Link href={`/${redirectWorkspaceSlug}`}>
<a>
@@ -98,7 +84,14 @@ export const InstanceSidebarDropdown = observer(() => {
</Link>
</div>
</Tooltip>
)}
</div>
)}
</div>
</div>
{!sidebarCollapsed && (
<Menu as="div" className="relative flex-shrink-0">
<Menu.Button className="grid place-items-center outline-none">
<Avatar
name={currentUser?.display_name}
src={currentUser?.avatar}
@@ -118,13 +111,13 @@ export const InstanceSidebarDropdown = observer(() => {
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute left-0 z-20 mt-1.5 flex flex-col w-52 origin-top-left rounded-md
border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 divide-y divide-custom-sidebar-border-200 shadow-lg text-xs outline-none"
className="absolute left-0 z-20 mt-1.5 flex flex-col w-52 rounded-md
border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 divide-y divide-custom-sidebar-border-100 shadow-lg text-xs outline-none"
>
<div className="flex flex-col gap-2.5 pb-2">
<span className="px-2 text-custom-sidebar-text-200">{currentUser?.email}</span>
{profileLinks(workspaceSlug?.toString() ?? "", currentUser?.id ?? "").map((link, index) => (
<Menu.Item key={index} as="button" type="button">
{PROFILE_LINKS.map((link) => (
<Menu.Item key={link.key} as="button" type="button">
<Link href={link.link}>
<a className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
<link.icon className="h-4 w-4 stroke-[1.5]" />
@@ -149,8 +142,8 @@ export const InstanceSidebarDropdown = observer(() => {
<div className="p-2 pb-0">
<Menu.Item as="button" type="button" className="w-full">
<Link href={`/${redirectWorkspaceSlug}`}>
<a className="flex w-full items-center justify-center rounded px-2 py-1 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 bg-custom-primary-10 hover:bg-custom-primary-20">
Normal Mode
<a className="flex w-full items-center justify-center rounded px-2 py-1 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 bg-custom-primary-100/20 hover:bg-custom-primary-100/30">
Exit God Mode
</a>
</Link>
</Menu.Item>
+20 -12
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { useRouter } from "next/router";
// icons
import { BrainCog, Cog, Lock, Mail } from "lucide-react";
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
@@ -11,26 +11,32 @@ const INSTANCE_ADMIN_LINKS = [
{
Icon: Cog,
name: "General",
description: "General settings here",
href: `/admin`,
description: "Identify your instances and get key details",
href: `/god-mode`,
},
{
Icon: Mail,
name: "Email",
description: "Email related settings will go here",
href: `/admin/email`,
description: "Set up emails to your users",
href: `/god-mode/email`,
},
{
Icon: Lock,
name: "Authorization",
description: "Autorization",
href: `/admin/authorization`,
name: "SSO and OAuth",
description: "Configure your Google and GitHub SSOs",
href: `/god-mode/authorization`,
},
{
Icon: BrainCog,
name: "OpenAI",
description: "OpenAI configurations",
href: `/admin/openai`,
name: "Artificial intelligence",
description: "Configure your OpenAI creds",
href: `/god-mode/ai`,
},
{
Icon: Image,
name: "Images in Plane",
description: "Allow third-party image libraries",
href: `/god-mode/image`,
},
];
@@ -68,7 +74,9 @@ export const InstanceAdminSidebarMenu = () => {
{item.name}
</span>
<span
className={`text-xs ${isActive ? "text-custom-primary-100" : "text-custom-sidebar-text-300"}`}
className={`text-[10px] ${
isActive ? "text-custom-primary-100" : "text-custom-sidebar-text-300"
}`}
>
{item.description}
</span>
@@ -16,7 +16,7 @@ export const JiraGetImportDetail: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { project: projectStore, commandPalette: commandPaletteStore } = useMobxStore();
const { project: projectStore, commandPalette: commandPaletteStore, trackEvent: { setTrackElement } } = useMobxStore();
const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : undefined;
const {
@@ -190,7 +190,10 @@ export const JiraGetImportDetail: React.FC = observer(() => {
<div>
<button
type="button"
onClick={() => commandPaletteStore.toggleCreateProjectModal(true)}
onClick={() => {
setTrackElement("JIRA_IMPORT_DETAIL");
commandPaletteStore.toggleCreateProjectModal(true)
}}
className="flex cursor-pointer select-none items-center space-x-2 truncate rounded px-1 py-1.5 text-custom-text-200"
>
<Plus className="h-4 w-4 text-custom-text-200" />
@@ -1,7 +1,10 @@
import { useCallback, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
import { useDropzone } from "react-dropzone";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { IssueAttachmentService } from "services/issue";
// hooks
@@ -10,8 +13,8 @@ import useToast from "hooks/use-toast";
import { IIssueAttachment } from "types";
// fetch-keys
import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
const maxFileSize = 5 * 1024 * 1024; // 5 MB
// constants
import { MAX_FILE_SIZE } from "constants/common";
type Props = {
disabled?: boolean;
@@ -19,14 +22,20 @@ type Props = {
const issueAttachmentService = new IssueAttachmentService();
export const IssueAttachmentUpload: React.FC<Props> = ({ disabled = false }) => {
export const IssueAttachmentUpload: React.FC<Props> = observer((props) => {
const { disabled = false } = props;
// states
const [isLoading, setIsLoading] = useState(false);
// router
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { setToastAlert } = useToast();
const {
appConfig: { envConfig },
} = useMobxStore();
const onDrop = useCallback((acceptedFiles: File[]) => {
if (!acceptedFiles[0] || !workspaceSlug) return;
@@ -70,13 +79,15 @@ export const IssueAttachmentUpload: React.FC<Props> = ({ disabled = false }) =>
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
onDrop,
maxSize: maxFileSize,
maxSize: envConfig?.file_size_limit ?? MAX_FILE_SIZE,
multiple: false,
disabled: isLoading || disabled,
});
const fileError =
fileRejections.length > 0 ? `Invalid file type or size (max ${maxFileSize / 1024 / 1024} MB)` : null;
fileRejections.length > 0
? `Invalid file type or size (max ${envConfig?.file_size_limit ?? MAX_FILE_SIZE / 1024 / 1024} MB)`
: null;
return (
<div
@@ -99,4 +110,4 @@ export const IssueAttachmentUpload: React.FC<Props> = ({ disabled = false }) =>
</span>
</div>
);
};
});
+1 -1
View File
@@ -72,7 +72,7 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
<p className="text-sm text-custom-text-200">
Are you sure you want to delete issue{" "}
<span className="break-words font-medium text-custom-text-100">
{data?.project_detail.identifier}-{data?.sequence_id}
{data?.project_detail?.identifier}-{data?.sequence_id}
</span>
{""}? All of the data related to the issue will be permanently removed. This action cannot be
undone.
+1 -2
View File
@@ -112,7 +112,6 @@ export const IssueForm: FC<IssueFormProps> = observer((props) => {
appConfig: { envConfig },
} = useMobxStore();
const user = userStore.currentUser;
console.log("envConfig", envConfig);
// hooks
const editorSuggestion = useEditorSuggestions();
const { setToastAlert } = useToast();
@@ -229,7 +228,7 @@ export const IssueForm: FC<IssueFormProps> = observer((props) => {
...defaultValues,
...initialData,
});
}, [setFocus, initialData, reset]);
}, [setFocus, reset]);
// update projectId in form when projectId changes
useEffect(() => {
@@ -7,14 +7,28 @@ import { useMobxStore } from "lib/mobx/store-provider";
import { CalendarChart } from "components/issues";
// types
import { IIssue } from "types";
import { ICycleIssuesStore, IModuleIssuesStore, IProjectIssuesStore, IViewIssuesStore } from "store/issues";
import { IIssueCalendarViewStore, IssueStore } from "store/issue";
import {
ICycleIssuesFilterStore,
ICycleIssuesStore,
IModuleIssuesFilterStore,
IModuleIssuesStore,
IProjectIssuesFilterStore,
IProjectIssuesStore,
IViewIssuesFilterStore,
IViewIssuesStore,
} from "store/issues";
import { IIssueCalendarViewStore } from "store/issue";
import { IQuickActionProps } from "../list/list-view-types";
import { EIssueActions } from "../types";
import { IGroupedIssues } from "store/issues/types";
interface IBaseCalendarRoot {
issueStore: IProjectIssuesStore | IModuleIssuesStore | ICycleIssuesStore | IViewIssuesStore;
issuesFilterStore:
| IProjectIssuesFilterStore
| IModuleIssuesFilterStore
| ICycleIssuesFilterStore
| IViewIssuesFilterStore;
calendarViewStore: IIssueCalendarViewStore;
QuickActions: FC<IQuickActionProps>;
issueActions: {
@@ -26,10 +40,9 @@ interface IBaseCalendarRoot {
}
export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
const { issueStore, calendarViewStore, QuickActions, issueActions, viewId } = props;
const { projectIssuesFilter: issueFilterStore } = useMobxStore();
const { issueStore, issuesFilterStore, calendarViewStore, QuickActions, issueActions, viewId } = props;
const displayFilters = issueFilterStore.issueFilters?.displayFilters;
const displayFilters = issuesFilterStore.issueFilters?.displayFilters;
const issues = issueStore.getIssues;
const groupedIssueIds = (issueStore.getIssuesIds ?? {}) as IGroupedIssues;
@@ -75,7 +88,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
}
handleRemoveFromView={
issueActions[EIssueActions.REMOVE]
? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.UPDATE)
? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.REMOVE)
: undefined
}
/>
@@ -10,7 +10,11 @@ import { EIssueActions } from "../../types";
import { BaseCalendarRoot } from "../base-calendar-root";
export const CycleCalendarLayout: React.FC = observer(() => {
const { cycleIssues: cycleIssueStore, cycleIssueCalendarView: cycleIssueCalendarViewStore } = useMobxStore();
const {
cycleIssues: cycleIssueStore,
cycleIssuesFilter: cycleIssueFilterStore,
cycleIssueCalendarView: cycleIssueCalendarViewStore,
} = useMobxStore();
const router = useRouter();
const { workspaceSlug, cycleId } = router.query as { workspaceSlug: string; cycleId: string };
@@ -34,6 +38,7 @@ export const CycleCalendarLayout: React.FC = observer(() => {
return (
<BaseCalendarRoot
issueStore={cycleIssueStore}
issuesFilterStore={cycleIssueFilterStore}
calendarViewStore={cycleIssueCalendarViewStore}
QuickActions={CycleIssueQuickActions}
issueActions={issueActions}
@@ -10,7 +10,11 @@ import { EIssueActions } from "../../types";
import { BaseCalendarRoot } from "../base-calendar-root";
export const ModuleCalendarLayout: React.FC = observer(() => {
const { moduleIssues: moduleIssueStore, moduleIssueCalendarView: moduleIssueCalendarViewStore } = useMobxStore();
const {
moduleIssues: moduleIssueStore,
moduleIssuesFilter: moduleIssueFilterStore,
moduleIssueCalendarView: moduleIssueCalendarViewStore,
} = useMobxStore();
const router = useRouter();
const { workspaceSlug, moduleId } = router.query as { workspaceSlug: string; moduleId: string };
@@ -33,6 +37,7 @@ export const ModuleCalendarLayout: React.FC = observer(() => {
return (
<BaseCalendarRoot
issueStore={moduleIssueStore}
issuesFilterStore={moduleIssueFilterStore}
calendarViewStore={moduleIssueCalendarViewStore}
QuickActions={ModuleIssueQuickActions}
issueActions={issueActions}
@@ -15,25 +15,26 @@ export const CalendarLayout: React.FC = observer(() => {
const {
projectIssues: issueStore,
issueCalendarView: issueCalendarViewStore,
issueDetail: issueDetailStore,
projectIssuesFilter: projectIssueFiltersStore,
} = useMobxStore();
const issueActions = {
[EIssueActions.UPDATE]: async (issue: IIssue) => {
if (!workspaceSlug) return;
issueDetailStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue);
issueStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue);
},
[EIssueActions.DELETE]: async (issue: IIssue) => {
if (!workspaceSlug) return;
issueDetailStore.deleteIssue(workspaceSlug.toString(), issue.project, issue.id);
issueStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id);
},
};
return (
<BaseCalendarRoot
issueStore={issueStore}
issuesFilterStore={projectIssueFiltersStore}
calendarViewStore={issueCalendarViewStore}
QuickActions={ProjectIssueQuickActions}
issueActions={issueActions}
@@ -1,11 +1,9 @@
import { useCallback } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { DragDropContext, DropResult } from "@hello-pangea/dnd";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CalendarChart, ProjectIssueQuickActions } from "components/issues";
import { ProjectIssueQuickActions } from "components/issues";
// types
import { IIssue } from "types";
import { EIssueActions } from "../../types";
@@ -14,7 +12,7 @@ import { BaseCalendarRoot } from "../base-calendar-root";
export const ProjectViewCalendarLayout: React.FC = observer(() => {
const {
viewIssues: projectViewIssuesStore,
issueDetail: issueDetailStore,
viewIssuesFilter: projectIssueViewFiltersStore,
projectViewIssueCalendarView: projectViewIssueCalendarViewStore,
} = useMobxStore();
@@ -25,18 +23,19 @@ export const ProjectViewCalendarLayout: React.FC = observer(() => {
[EIssueActions.UPDATE]: async (issue: IIssue) => {
if (!workspaceSlug) return;
issueDetailStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue);
projectViewIssuesStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue);
},
[EIssueActions.DELETE]: async (issue: IIssue) => {
if (!workspaceSlug) return;
issueDetailStore.deleteIssue(workspaceSlug.toString(), issue.project, issue.id);
projectViewIssuesStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id);
},
};
return (
<BaseCalendarRoot
issueStore={projectViewIssuesStore}
issuesFilterStore={projectIssueViewFiltersStore}
calendarViewStore={projectViewIssueCalendarViewStore}
QuickActions={ProjectIssueQuickActions}
issueActions={issueActions}
@@ -14,6 +14,7 @@ import { Button } from "@plane/ui";
import emptyIssue from "public/empty-state/issue.svg";
// types
import { ISearchIssueResponse } from "types";
import { EProjectStore } from "store/command-palette.store";
type Props = {
workspaceSlug: string | undefined;
@@ -26,7 +27,11 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
// states
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
const { cycleIssue: cycleIssueStore, commandPalette: commandPaletteStore } = useMobxStore();
const {
cycleIssue: cycleIssueStore,
commandPalette: commandPaletteStore,
trackEvent: { setTrackElement },
} = useMobxStore();
const { setToastAlert } = useToast();
@@ -62,7 +67,10 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => commandPaletteStore.toggleCreateIssueModal(true),
onClick: () => {
setTrackElement("CYCLE_EMPTY_STATE");
commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.CYCLE);
},
}}
secondaryButton={
<Button
@@ -15,7 +15,7 @@ export const GlobalViewEmptyState: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { commandPalette: commandPaletteStore, project: projectStore } = useMobxStore();
const { commandPalette: commandPaletteStore, project: projectStore, trackEvent: { setTrackElement } } = useMobxStore();
const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : null;
@@ -29,7 +29,10 @@ export const GlobalViewEmptyState: React.FC = observer(() => {
primaryButton={{
icon: <Plus className="h-4 w-4" />,
text: "New Project",
onClick: () => commandPaletteStore.toggleCreateProjectModal(true),
onClick: () => {
setTrackElement("ALL_ISSUES_EMPTY_STATE")
commandPaletteStore.toggleCreateProjectModal(true)
},
}}
/>
) : (
@@ -40,7 +43,10 @@ export const GlobalViewEmptyState: React.FC = observer(() => {
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => commandPaletteStore.toggleCreateIssueModal(true),
onClick: () => {
setTrackElement("ALL_ISSUES_EMPTY_STATE")
commandPaletteStore.toggleCreateIssueModal(true)
}
}}
/>
)}
@@ -22,7 +22,7 @@ export const ModuleEmptyState: React.FC<Props> = observer((props) => {
// states
const [moduleIssuesListModal, setModuleIssuesListModal] = useState(false);
const { moduleIssue: moduleIssueStore, commandPalette: commandPaletteStore } = useMobxStore();
const { moduleIssue: moduleIssueStore, commandPalette: commandPaletteStore, trackEvent: { setTrackElement } } = useMobxStore();
const { setToastAlert } = useToast();
@@ -58,7 +58,10 @@ export const ModuleEmptyState: React.FC<Props> = observer((props) => {
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => commandPaletteStore.toggleCreateIssueModal(true),
onClick: () => {
setTrackElement("MODULE_EMPTY_STATE");
commandPaletteStore.toggleCreateIssueModal(true)
}
}}
secondaryButton={
<Button
@@ -6,9 +6,17 @@ import { useMobxStore } from "lib/mobx/store-provider";
import { EmptyState } from "components/common";
// assets
import emptyIssue from "public/empty-state/issue.svg";
import { EProjectStore } from "store/command-palette.store";
import { useRouter } from "next/router";
export const ProjectViewEmptyState: React.FC = observer(() => {
const { commandPalette: commandPaletteStore } = useMobxStore();
const router = useRouter();
const { viewId } = router.query as { viewId: string };
const {
commandPalette: commandPaletteStore,
trackEvent: { setTrackElement },
} = useMobxStore();
return (
<div className="h-full w-full grid place-items-center">
@@ -19,7 +27,10 @@ export const ProjectViewEmptyState: React.FC = observer(() => {
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => commandPaletteStore.toggleCreateIssueModal(true),
onClick: () => {
setTrackElement("VIEW_EMPTY_STATE");
commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.PROJECT_VIEW);
},
}}
/>
</div>
@@ -6,9 +6,13 @@ import { useMobxStore } from "lib/mobx/store-provider";
import { EmptyState } from "components/common";
// assets
import emptyIssue from "public/empty-state/issue.svg";
import { EProjectStore } from "store/command-palette.store";
export const ProjectEmptyState: React.FC = observer(() => {
const { commandPalette: commandPaletteStore } = useMobxStore();
const {
commandPalette: commandPaletteStore,
trackEvent: { setTrackElement },
} = useMobxStore();
return (
<div className="h-full w-full grid place-items-center">
@@ -19,7 +23,10 @@ export const ProjectEmptyState: React.FC = observer(() => {
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => commandPaletteStore.toggleCreateIssueModal(true),
onClick: () => {
setTrackElement("PROJECT_EMPTY_STATE");
commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.PROJECT);
},
}}
/>
</div>
@@ -7,23 +7,24 @@ import { useMobxStore } from "lib/mobx/store-provider";
import { AppliedFiltersList } from "components/issues";
// types
import { IIssueFilterOptions } from "types";
import { EFilterType } from "store/issues/types";
export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
archivedIssueFilters: archivedIssueFiltersStore,
projectArchivedIssuesFilter: { issueFilters, updateFilters },
projectLabel: { projectLabels },
projectMember: { projectMembers },
projectState: projectStateStore,
} = useMobxStore();
const userFilters = archivedIssueFiltersStore.userFilters;
const userFilters = issueFilters?.filters;
// filters whose value not null or empty array
const appliedFilters: IIssueFilterOptions = {};
Object.entries(userFilters).forEach(([key, value]) => {
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
@@ -36,22 +37,18 @@ export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => {
// remove all values of the key if value is null
if (!value) {
archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), {
filters: {
[key]: null,
},
updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, {
[key]: null,
});
return;
}
// remove the passed value from the key
let newValues = archivedIssueFiltersStore.userFilters?.[key] ?? [];
let newValues = issueFilters?.filters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), {
filters: {
[key]: newValues,
},
updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, {
[key]: newValues,
});
};
@@ -59,12 +56,12 @@ export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => {
if (!workspaceSlug || !projectId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters).forEach((key) => {
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = null;
});
archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), {
filters: { ...newFilters },
updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, {
...newFilters,
});
};
@@ -0,0 +1,80 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { AppliedFiltersList } from "components/issues";
// types
import { IIssueFilterOptions } from "types";
import { EFilterType } from "store/issues/types";
export const DraftIssueAppliedFiltersRoot: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
projectDraftIssuesFilter: { issueFilters, updateFilters },
projectLabel: { projectLabels },
projectMember: { projectMembers },
projectState: projectStateStore,
} = useMobxStore();
const userFilters = issueFilters?.filters;
// filters whose value not null or empty array
const appliedFilters: IIssueFilterOptions = {};
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
appliedFilters[key as keyof IIssueFilterOptions] = value;
});
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
if (!workspaceSlug || !projectId) return;
// remove all values of the key if value is null
if (!value) {
updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, {
[key]: null,
});
return;
}
// remove the passed value from the key
let newValues = issueFilters?.filters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, {
[key]: newValues,
});
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = null;
});
updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { ...newFilters });
};
// return if no filters are applied
if (Object.keys(appliedFilters).length === 0) return null;
return (
<div className="p-4">
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectLabels ?? []}
members={projectMembers?.map((m) => m.member)}
states={projectStateStore.states?.[projectId?.toString() ?? ""]}
/>
</div>
);
});
@@ -4,3 +4,4 @@ export * from "./module-root";
export * from "./project-view-root";
export * from "./project-root";
export * from "./archived-issue";
export * from "./profile-issues-root";
@@ -0,0 +1,73 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { AppliedFiltersList } from "components/issues";
// types
import { IIssueFilterOptions } from "types";
import { EFilterType } from "store/issues/types";
export const ProfileIssuesAppliedFiltersRoot: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug } = router.query as {
workspaceSlug: string;
};
const {
workspace: { workspaceLabels },
workspaceProfileIssuesFilter: { issueFilters, updateFilters },
projectMember: { projectMembers },
} = useMobxStore();
const userFilters = issueFilters?.filters;
// filters whose value not null or empty array
const appliedFilters: IIssueFilterOptions = {};
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
appliedFilters[key as keyof IIssueFilterOptions] = value;
});
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
if (!workspaceSlug) return;
if (!value) {
updateFilters(workspaceSlug, EFilterType.FILTERS, { [key]: null });
return;
}
let newValues = issueFilters?.filters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
updateFilters(workspaceSlug, EFilterType.FILTERS, {
[key]: newValues,
});
};
const handleClearAllFilters = () => {
if (!workspaceSlug) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = null;
});
updateFilters(workspaceSlug, EFilterType.FILTERS, { ...newFilters });
};
// return if no filters are applied
if (Object.keys(appliedFilters).length === 0) return null;
return (
<div className="p-4">
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={workspaceLabels ?? []}
members={projectMembers?.map((m) => m.member)}
states={[]}
/>
</div>
);
});
@@ -57,10 +57,16 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
if (!workspaceSlug) return;
//Todo fix sort order in the structure
issueStore.updateIssue(workspaceSlug, issue.project, issue.id, {
start_date: payload.start_date,
target_date: payload.target_date,
});
issueStore.updateIssue(
workspaceSlug,
issue.project,
issue.id,
{
start_date: payload.start_date,
target_date: payload.target_date,
},
viewId
);
};
const isAllowed = (projectDetails?.member_role || 0) >= EUserWorkspaceRoles.MEMBER;
@@ -1,5 +1,4 @@
import { FC, useCallback, useState } from "react";
import { useRouter } from "next/router";
import { DragDropContext } from "@hello-pangea/dnd";
import { observer } from "mobx-react-lite";
// mobx store
@@ -9,7 +8,19 @@ import { Spinner } from "@plane/ui";
// types
import { IIssue } from "types";
import { EIssueActions } from "../types";
import { ICycleIssuesStore, IModuleIssuesStore, IProjectIssuesStore, IViewIssuesStore } from "store/issues";
import {
ICycleIssuesFilterStore,
ICycleIssuesStore,
IModuleIssuesFilterStore,
IModuleIssuesStore,
IProfileIssuesFilterStore,
IProfileIssuesStore,
IProjectDraftIssuesStore,
IProjectIssuesFilterStore,
IProjectIssuesStore,
IViewIssuesFilterStore,
IViewIssuesStore,
} from "store/issues";
import { IQuickActionProps } from "../list/list-view-types";
import { IIssueKanBanViewStore } from "store/issue";
// constants
@@ -17,9 +28,22 @@ import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES } from "constants/issue";
//components
import { KanBan } from "./default";
import { KanBanSwimLanes } from "./swimlanes";
import { EProjectStore } from "store/command-palette.store";
export interface IBaseKanBanLayout {
issueStore: IProjectIssuesStore | IModuleIssuesStore | ICycleIssuesStore | IViewIssuesStore;
issueStore:
| IProjectIssuesStore
| IModuleIssuesStore
| ICycleIssuesStore
| IViewIssuesStore
| IProjectDraftIssuesStore
| IProfileIssuesStore;
issuesFilterStore:
| IProjectIssuesFilterStore
| IModuleIssuesFilterStore
| ICycleIssuesFilterStore
| IViewIssuesFilterStore
| IProfileIssuesFilterStore;
kanbanViewStore: IIssueKanBanViewStore;
QuickActions: FC<IQuickActionProps>;
issueActions: {
@@ -29,24 +53,33 @@ export interface IBaseKanBanLayout {
};
showLoader?: boolean;
viewId?: string;
currentStore?: EProjectStore;
}
export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBaseKanBanLayout) => {
const { issueStore, kanbanViewStore, QuickActions, issueActions, showLoader, viewId } = props;
const {
issueStore,
issuesFilterStore,
kanbanViewStore,
QuickActions,
issueActions,
showLoader,
viewId,
currentStore,
} = props;
const {
project: { workspaceProjects },
projectLabel: { projectLabels },
projectMember: { projectMembers },
projectState: projectStateStore,
projectIssuesFilter: issueFilterStore,
} = useMobxStore();
const issues = issueStore?.getIssues || {};
const issueIds = issueStore?.getIssuesIds || [];
const displayFilters = issueFilterStore?.issueFilters?.displayFilters;
const displayProperties = issueFilterStore?.issueFilters?.displayProperties || null;
const displayFilters = issuesFilterStore?.issueFilters?.displayFilters;
const displayProperties = issuesFilterStore?.issueFilters?.displayProperties || null;
const sub_group_by: string | null = displayFilters?.sub_group_by || null;
@@ -60,6 +93,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
const [isDragStarted, setIsDragStarted] = useState<boolean>(false);
const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issueStore?.viewFlags || {};
const onDragStart = () => {
setIsDragStarted(true);
};
@@ -103,7 +137,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
return (
<>
{showLoader && issueStore?.loader === "mutation" && (
{showLoader && issueStore?.loader === "init-loader" && (
<div className="fixed top-16 right-2 z-30 bg-custom-background-80 shadow-custom-shadow-sm w-10 h-10 rounded flex justify-center items-center">
<Spinner className="w-5 h-5" />
</div>
@@ -144,11 +178,14 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
labels={projectLabels}
members={projectMembers?.map((m) => m.member) ?? null}
projects={workspaceProjects}
enableQuickIssueCreate
enableQuickIssueCreate={enableQuickAdd}
showEmptyGroup={userDisplayFilters?.show_empty_groups || true}
isDragStarted={isDragStarted}
quickAddCallback={issueStore.quickAddIssue}
quickAddCallback={issueStore?.quickAddIssue}
viewId={viewId}
disableIssueCreation={!enableIssueCreation}
isReadOnly={!enableInlineEditing}
currentStore={currentStore}
/>
) : (
<KanBanSwimLanes
@@ -185,6 +222,10 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
projects={workspaceProjects}
showEmptyGroup={userDisplayFilters?.show_empty_groups || true}
isDragStarted={isDragStarted}
disableIssueCreation={true}
enableQuickIssueCreate={enableQuickAdd}
isReadOnly={!enableInlineEditing}
currentStore={currentStore}
/>
)}
</DragDropContext>
@@ -17,6 +17,7 @@ interface IssueBlockProps {
handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue, action: EIssueActions) => void;
quickActions: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => React.ReactNode;
displayProperties: IIssueDisplayProperties | null;
isReadOnly: boolean;
}
export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
@@ -30,6 +31,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
handleIssues,
quickActions,
displayProperties,
isReadOnly,
} = props;
const updateIssue = (sub_group_by: string | null, group_by: string | null, issueToUpdate: IIssue) => {
@@ -91,6 +93,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
handleIssues={updateIssue}
displayProperties={displayProperties}
showEmptyGroup={showEmptyGroup}
isReadOnly={isReadOnly}
/>
</div>
</div>
@@ -14,6 +14,7 @@ interface IssueBlocksListProps {
handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue, action: EIssueActions) => void;
quickActions: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => React.ReactNode;
displayProperties: IIssueDisplayProperties | null;
isReadOnly: boolean;
}
export const KanbanIssueBlocksList: React.FC<IssueBlocksListProps> = (props) => {
@@ -27,6 +28,7 @@ export const KanbanIssueBlocksList: React.FC<IssueBlocksListProps> = (props) =>
handleIssues,
quickActions,
displayProperties,
isReadOnly,
} = props;
return (
@@ -50,6 +52,7 @@ export const KanbanIssueBlocksList: React.FC<IssueBlocksListProps> = (props) =>
columnId={columnId}
sub_group_id={sub_group_id}
isDragDisabled={isDragDisabled}
isReadOnly={isReadOnly}
/>
);
})}
@@ -13,6 +13,7 @@ import { getValueFromObject } from "constants/issue";
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
import { EIssueActions } from "../types";
import { IIssueResponse, IGroupedIssues, ISubGroupedIssues, TUnGroupedIssues } from "store/issues/types";
import { EProjectStore } from "store/command-palette.store";
export interface IGroupByKanBan {
issues: IIssueResponse;
@@ -40,6 +41,9 @@ export interface IGroupByKanBan {
viewId?: string
) => Promise<IIssue | undefined>;
viewId?: string;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
isReadOnly: boolean;
}
const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
@@ -63,6 +67,9 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
isDragStarted,
quickAddCallback,
viewId,
disableIssueCreation,
isReadOnly,
currentStore,
} = props;
const verticalAlignPosition = (_list: any) =>
@@ -86,6 +93,8 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
issues_count={issueIds?.[getValueFromObject(_list, listKey) as string]?.length || 0}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
</div>
)}
@@ -95,10 +104,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
verticalAlignPosition(_list) ? `w-[0px] overflow-hidden` : `w-full transition-all`
}`}
>
<Droppable
droppableId={`${getValueFromObject(_list, listKey) as string}__${sub_group_id}`}
isDropDisabled={isDragDisabled}
>
<Droppable droppableId={`${getValueFromObject(_list, listKey) as string}__${sub_group_id}`}>
{(provided: any, snapshot: any) => (
<div
className={`w-full h-full relative transition-all ${
@@ -118,6 +124,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
handleIssues={handleIssues}
quickActions={quickActions}
displayProperties={displayProperties}
isReadOnly={isReadOnly}
/>
) : (
isDragDisabled && (
@@ -149,7 +156,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
)}
</div>
{isDragStarted && isDragDisabled && (
{/* {isDragStarted && isDragDisabled && (
<div className="invisible group-hover:visible transition-all text-sm absolute top-12 bottom-10 left-0 right-0 bg-custom-background-100/40 text-center">
<div className="rounded inline-flex mt-80 h-8 px-3 justify-center items-center bg-custom-background-80 text-custom-text-100 font-medium">
{`This board is ordered by "${replaceUnderscoreIfSnakeCase(
@@ -157,7 +164,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
)}"`}
</div>
</div>
)}
)} */}
</div>
))}
</div>
@@ -192,6 +199,9 @@ export interface IKanBan {
viewId?: string
) => Promise<IIssue | undefined>;
viewId?: string;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
isReadOnly: boolean;
}
export const KanBan: React.FC<IKanBan> = observer((props) => {
@@ -218,6 +228,9 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
isDragStarted,
quickAddCallback,
viewId,
disableIssueCreation,
isReadOnly,
currentStore,
} = props;
const { issueKanBanView: issueKanBanViewStore } = useMobxStore();
@@ -246,6 +259,9 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
isDragStarted={isDragStarted}
quickAddCallback={quickAddCallback}
viewId={viewId}
disableIssueCreation={disableIssueCreation}
isReadOnly={isReadOnly}
currentStore={currentStore}
/>
)}
@@ -271,6 +287,9 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
isDragStarted={isDragStarted}
quickAddCallback={quickAddCallback}
viewId={viewId}
disableIssueCreation={disableIssueCreation}
isReadOnly={isReadOnly}
currentStore={currentStore}
/>
)}
@@ -296,6 +315,9 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
isDragStarted={isDragStarted}
quickAddCallback={quickAddCallback}
viewId={viewId}
disableIssueCreation={disableIssueCreation}
isReadOnly={isReadOnly}
currentStore={currentStore}
/>
)}
@@ -321,6 +343,9 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
isDragStarted={isDragStarted}
quickAddCallback={quickAddCallback}
viewId={viewId}
disableIssueCreation={disableIssueCreation}
isReadOnly={isReadOnly}
currentStore={currentStore}
/>
)}
@@ -346,6 +371,9 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
isDragStarted={isDragStarted}
quickAddCallback={quickAddCallback}
viewId={viewId}
disableIssueCreation={disableIssueCreation}
isReadOnly={isReadOnly}
currentStore={currentStore}
/>
)}
@@ -371,6 +399,9 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
isDragStarted={isDragStarted}
quickAddCallback={quickAddCallback}
viewId={viewId}
disableIssueCreation={disableIssueCreation}
isReadOnly={isReadOnly}
currentStore={currentStore}
/>
)}
@@ -396,6 +427,9 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
isDragStarted={isDragStarted}
quickAddCallback={quickAddCallback}
viewId={viewId}
disableIssueCreation={disableIssueCreation}
isReadOnly={isReadOnly}
currentStore={currentStore}
/>
)}
</div>
@@ -5,6 +5,7 @@ import { HeaderGroupByCard } from "./group-by-card";
import { HeaderSubGroupByCard } from "./sub-group-by-card";
// ui
import { Avatar } from "@plane/ui";
import { EProjectStore } from "store/command-palette.store";
export interface IAssigneesHeader {
column_id: string;
@@ -15,6 +16,8 @@ export interface IAssigneesHeader {
issues_count: number;
kanBanToggle: any;
handleKanBanToggle: any;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
}
export const Icon = ({ user }: any) => <Avatar name={user.display_name} src={user.avatar} size="base" />;
@@ -29,6 +32,8 @@ export const AssigneesHeader: FC<IAssigneesHeader> = observer((props) => {
issues_count,
kanBanToggle,
handleKanBanToggle,
disableIssueCreation,
currentStore,
} = props;
const assignee = column_value ?? null;
@@ -56,6 +61,8 @@ export const AssigneesHeader: FC<IAssigneesHeader> = observer((props) => {
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
issuePayload={{ assignees: [assignee?.id] }}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
))}
</>
@@ -4,6 +4,7 @@ import { observer } from "mobx-react-lite";
import { HeaderGroupByCard } from "./group-by-card";
import { HeaderSubGroupByCard } from "./sub-group-by-card";
import { Icon } from "./assignee";
import { EProjectStore } from "store/command-palette.store";
export interface ICreatedByHeader {
column_id: string;
@@ -14,6 +15,8 @@ export interface ICreatedByHeader {
issues_count: number;
kanBanToggle: any;
handleKanBanToggle: any;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
}
export const CreatedByHeader: FC<ICreatedByHeader> = observer((props) => {
@@ -26,6 +29,8 @@ export const CreatedByHeader: FC<ICreatedByHeader> = observer((props) => {
issues_count,
kanBanToggle,
handleKanBanToggle,
disableIssueCreation,
currentStore,
} = props;
const createdBy = column_value ?? null;
@@ -53,6 +58,8 @@ export const CreatedByHeader: FC<ICreatedByHeader> = observer((props) => {
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
issuePayload={{ created_by: createdBy?.id }}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
))}
</>
@@ -15,6 +15,7 @@ import useToast from "hooks/use-toast";
import { observer } from "mobx-react-lite";
// types
import { IIssue, ISearchIssueResponse } from "types";
import { EProjectStore } from "store/command-palette.store";
interface IHeaderGroupByCard {
sub_group_by: string | null;
@@ -26,13 +27,26 @@ interface IHeaderGroupByCard {
kanBanToggle: any;
handleKanBanToggle: any;
issuePayload: Partial<IIssue>;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
}
const moduleService = new ModuleService();
const issueService = new IssueService();
export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
const { sub_group_by, column_id, icon, title, count, kanBanToggle, handleKanBanToggle, issuePayload } = props;
const {
sub_group_by,
column_id,
icon,
title,
count,
kanBanToggle,
handleKanBanToggle,
issuePayload,
disableIssueCreation,
currentStore,
} = props;
const verticalAlignPosition = kanBanToggle?.groupByHeaderMinMax.includes(column_id);
const [isOpen, setIsOpen] = React.useState(false);
@@ -84,7 +98,12 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
return (
<>
<CreateUpdateIssueModal isOpen={isOpen} handleClose={() => setIsOpen(false)} prePopulateData={issuePayload} />
<CreateUpdateIssueModal
isOpen={isOpen}
handleClose={() => setIsOpen(false)}
prePopulateData={issuePayload}
currentStore={currentStore}
/>
{renderExistingIssueModal && (
<ExistingIssuesListModal
isOpen={openExistingIssueListModal}
@@ -126,30 +145,31 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
</div>
)}
{renderExistingIssueModal ? (
<CustomMenu
width="auto"
customButton={
<span className="flex-shrink-0 w-[20px] h-[20px] rounded-sm overflow-hidden flex justify-center items-center hover:bg-custom-background-80 cursor-pointer transition-all">
<Plus height={14} width={14} strokeWidth={2} />
</span>
}
>
<CustomMenu.MenuItem onClick={() => setIsOpen(true)}>
<span className="flex items-center justify-start gap-2">Create issue</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => setOpenExistingIssueListModal(true)}>
<span className="flex items-center justify-start gap-2">Add an existing issue</span>
</CustomMenu.MenuItem>
</CustomMenu>
) : (
<div
className="flex-shrink-0 w-[20px] h-[20px] rounded-sm overflow-hidden flex justify-center items-center hover:bg-custom-background-80 cursor-pointer transition-all"
onClick={() => setIsOpen(true)}
>
<Plus width={14} strokeWidth={2} />
</div>
)}
{!disableIssueCreation &&
(renderExistingIssueModal ? (
<CustomMenu
width="auto"
customButton={
<span className="flex-shrink-0 w-[20px] h-[20px] rounded-sm overflow-hidden flex justify-center items-center hover:bg-custom-background-80 cursor-pointer transition-all">
<Plus height={14} width={14} strokeWidth={2} />
</span>
}
>
<CustomMenu.MenuItem onClick={() => setIsOpen(true)}>
<span className="flex items-center justify-start gap-2">Create issue</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => setOpenExistingIssueListModal(true)}>
<span className="flex items-center justify-start gap-2">Add an existing issue</span>
</CustomMenu.MenuItem>
</CustomMenu>
) : (
<div
className="flex-shrink-0 w-[20px] h-[20px] rounded-sm overflow-hidden flex justify-center items-center hover:bg-custom-background-80 cursor-pointer transition-all"
onClick={() => setIsOpen(true)}
>
<Plus width={14} strokeWidth={2} />
</div>
))}
</div>
</>
);
@@ -8,6 +8,7 @@ import { LabelHeader } from "./label";
import { CreatedByHeader } from "./created_by";
// mobx
import { observer } from "mobx-react-lite";
import { EProjectStore } from "store/command-palette.store";
export interface IKanBanGroupByHeaderRoot {
column_id: string;
@@ -17,10 +18,22 @@ export interface IKanBanGroupByHeaderRoot {
issues_count: number;
kanBanToggle: any;
handleKanBanToggle: any;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
}
export const KanBanGroupByHeaderRoot: React.FC<IKanBanGroupByHeaderRoot> = observer(
({ column_id, column_value, sub_group_by, group_by, issues_count, kanBanToggle, handleKanBanToggle }) => (
({
column_id,
column_value,
sub_group_by,
group_by,
issues_count,
kanBanToggle,
disableIssueCreation,
handleKanBanToggle,
currentStore,
}) => (
<>
{group_by && group_by === "project" && (
<ProjectHeader
@@ -32,6 +45,8 @@ export const KanBanGroupByHeaderRoot: React.FC<IKanBanGroupByHeaderRoot> = obser
issues_count={issues_count}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
@@ -45,6 +60,8 @@ export const KanBanGroupByHeaderRoot: React.FC<IKanBanGroupByHeaderRoot> = obser
issues_count={issues_count}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
{group_by && group_by === "state_detail.group" && (
@@ -57,6 +74,8 @@ export const KanBanGroupByHeaderRoot: React.FC<IKanBanGroupByHeaderRoot> = obser
issues_count={issues_count}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
{group_by && group_by === "priority" && (
@@ -69,6 +88,8 @@ export const KanBanGroupByHeaderRoot: React.FC<IKanBanGroupByHeaderRoot> = obser
issues_count={issues_count}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
{group_by && group_by === "labels" && (
@@ -81,6 +102,8 @@ export const KanBanGroupByHeaderRoot: React.FC<IKanBanGroupByHeaderRoot> = obser
issues_count={issues_count}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
{group_by && group_by === "assignees" && (
@@ -93,6 +116,8 @@ export const KanBanGroupByHeaderRoot: React.FC<IKanBanGroupByHeaderRoot> = obser
issues_count={issues_count}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
{group_by && group_by === "created_by" && (
@@ -105,6 +130,8 @@ export const KanBanGroupByHeaderRoot: React.FC<IKanBanGroupByHeaderRoot> = obser
issues_count={issues_count}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
</>
@@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite";
// components
import { HeaderGroupByCard } from "./group-by-card";
import { HeaderSubGroupByCard } from "./sub-group-by-card";
import { EProjectStore } from "store/command-palette.store";
export interface ILabelHeader {
column_id: string;
@@ -13,6 +14,8 @@ export interface ILabelHeader {
issues_count: number;
kanBanToggle: any;
handleKanBanToggle: any;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
}
const Icon = ({ color }: any) => (
@@ -29,6 +32,8 @@ export const LabelHeader: FC<ILabelHeader> = observer((props) => {
issues_count,
kanBanToggle,
handleKanBanToggle,
disableIssueCreation,
currentStore,
} = props;
const label = column_value ?? null;
@@ -56,6 +61,8 @@ export const LabelHeader: FC<ILabelHeader> = observer((props) => {
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
issuePayload={{ labels: [label?.id] }}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
))}
</>
@@ -7,6 +7,7 @@ import { HeaderSubGroupByCard } from "./sub-group-by-card";
// Icons
import { PriorityIcon } from "@plane/ui";
import { EProjectStore } from "store/command-palette.store";
export interface IPriorityHeader {
column_id: string;
@@ -17,6 +18,8 @@ export interface IPriorityHeader {
issues_count: number;
kanBanToggle: any;
handleKanBanToggle: any;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
}
export const PriorityHeader: FC<IPriorityHeader> = observer((props) => {
@@ -29,6 +32,8 @@ export const PriorityHeader: FC<IPriorityHeader> = observer((props) => {
issues_count,
kanBanToggle,
handleKanBanToggle,
disableIssueCreation,
currentStore,
} = props;
const priority = column_value || null;
@@ -56,6 +61,8 @@ export const PriorityHeader: FC<IPriorityHeader> = observer((props) => {
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
issuePayload={{ priority: priority?.key }}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
))}
</>
@@ -5,6 +5,7 @@ import { HeaderGroupByCard } from "./group-by-card";
import { HeaderSubGroupByCard } from "./sub-group-by-card";
// emoji helper
import { renderEmoji } from "helpers/emoji.helper";
import { EProjectStore } from "store/command-palette.store";
export interface IProjectHeader {
column_id: string;
@@ -15,6 +16,8 @@ export interface IProjectHeader {
issues_count: number;
kanBanToggle: any;
handleKanBanToggle: any;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
}
const Icon = ({ emoji }: any) => <div className="w-6 h-6">{renderEmoji(emoji)}</div>;
@@ -29,6 +32,8 @@ export const ProjectHeader: FC<IProjectHeader> = observer((props) => {
issues_count,
kanBanToggle,
handleKanBanToggle,
disableIssueCreation,
currentStore,
} = props;
const project = column_value ?? null;
@@ -56,6 +61,8 @@ export const ProjectHeader: FC<IProjectHeader> = observer((props) => {
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
issuePayload={{ project: project?.id }}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
))}
</>
@@ -4,6 +4,7 @@ import { observer } from "mobx-react-lite";
import { HeaderGroupByCard } from "./group-by-card";
import { HeaderSubGroupByCard } from "./sub-group-by-card";
import { StateGroupIcon } from "@plane/ui";
import { EProjectStore } from "store/command-palette.store";
export interface IStateGroupHeader {
column_id: string;
@@ -14,6 +15,8 @@ export interface IStateGroupHeader {
issues_count: number;
kanBanToggle: any;
handleKanBanToggle: any;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
}
export const Icon = ({ stateGroup, color }: { stateGroup: any; color?: any }) => (
@@ -32,6 +35,8 @@ export const StateGroupHeader: FC<IStateGroupHeader> = observer((props) => {
issues_count,
kanBanToggle,
handleKanBanToggle,
disableIssueCreation,
currentStore,
} = props;
const stateGroup = column_value || null;
@@ -59,6 +64,8 @@ export const StateGroupHeader: FC<IStateGroupHeader> = observer((props) => {
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
issuePayload={{}}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
))}
</>
@@ -4,6 +4,7 @@ import { observer } from "mobx-react-lite";
import { HeaderGroupByCard } from "./group-by-card";
import { HeaderSubGroupByCard } from "./sub-group-by-card";
import { Icon } from "./state-group";
import { EProjectStore } from "store/command-palette.store";
export interface IStateHeader {
column_id: string;
@@ -14,6 +15,8 @@ export interface IStateHeader {
issues_count: number;
kanBanToggle: any;
handleKanBanToggle: any;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
}
export const StateHeader: FC<IStateHeader> = observer((props) => {
@@ -26,6 +29,8 @@ export const StateHeader: FC<IStateHeader> = observer((props) => {
issues_count,
kanBanToggle,
handleKanBanToggle,
disableIssueCreation,
currentStore,
} = props;
const state = column_value ?? null;
@@ -53,6 +58,8 @@ export const StateHeader: FC<IStateHeader> = observer((props) => {
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
issuePayload={{ state: state?.id }}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
))}
</>
@@ -7,6 +7,7 @@ import { AssigneesHeader } from "./assignee";
import { PriorityHeader } from "./priority";
import { LabelHeader } from "./label";
import { CreatedByHeader } from "./created_by";
import { EProjectStore } from "store/command-palette.store";
export interface IKanBanSubGroupByHeaderRoot {
column_id: string;
@@ -16,10 +17,22 @@ export interface IKanBanSubGroupByHeaderRoot {
issues_count: number;
kanBanToggle: any;
handleKanBanToggle: any;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
}
export const KanBanSubGroupByHeaderRoot: React.FC<IKanBanSubGroupByHeaderRoot> = observer((props) => {
const { column_id, column_value, sub_group_by, group_by, issues_count, kanBanToggle, handleKanBanToggle } = props;
const {
column_id,
column_value,
sub_group_by,
group_by,
issues_count,
kanBanToggle,
handleKanBanToggle,
disableIssueCreation,
currentStore,
} = props;
return (
<>
@@ -33,6 +46,8 @@ export const KanBanSubGroupByHeaderRoot: React.FC<IKanBanSubGroupByHeaderRoot> =
issues_count={issues_count}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
{sub_group_by && sub_group_by === "state_detail.group" && (
@@ -45,6 +60,8 @@ export const KanBanSubGroupByHeaderRoot: React.FC<IKanBanSubGroupByHeaderRoot> =
issues_count={issues_count}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
{sub_group_by && sub_group_by === "priority" && (
@@ -57,6 +74,8 @@ export const KanBanSubGroupByHeaderRoot: React.FC<IKanBanSubGroupByHeaderRoot> =
issues_count={issues_count}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
{sub_group_by && sub_group_by === "labels" && (
@@ -69,6 +88,8 @@ export const KanBanSubGroupByHeaderRoot: React.FC<IKanBanSubGroupByHeaderRoot> =
issues_count={issues_count}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
{sub_group_by && sub_group_by === "assignees" && (
@@ -81,6 +102,8 @@ export const KanBanSubGroupByHeaderRoot: React.FC<IKanBanSubGroupByHeaderRoot> =
issues_count={issues_count}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
{sub_group_by && sub_group_by === "created_by" && (
@@ -93,6 +116,8 @@ export const KanBanSubGroupByHeaderRoot: React.FC<IKanBanSubGroupByHeaderRoot> =
issues_count={issues_count}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
</>
@@ -19,10 +19,11 @@ export interface IKanBanProperties {
handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => void;
displayProperties: IIssueDisplayProperties | null;
showEmptyGroup: boolean;
isReadOnly: boolean;
}
export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) => {
const { sub_group_id, columnId: group_id, issue, handleIssues, displayProperties, showEmptyGroup } = props;
const { sub_group_id, columnId: group_id, issue, handleIssues, displayProperties, isReadOnly } = props;
const handleState = (state: IState) => {
handleIssues(
@@ -89,7 +90,7 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
projectId={issue?.project_detail?.id || null}
value={issue?.state || null}
onChange={handleState}
disabled={false}
disabled={isReadOnly}
hideDropdownArrow
/>
)}
@@ -99,7 +100,7 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
<IssuePropertyPriority
value={issue?.priority || null}
onChange={handlePriority}
disabled={false}
disabled={isReadOnly}
hideDropdownArrow
/>
)}
@@ -110,7 +111,7 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
projectId={issue?.project_detail?.id || null}
value={issue?.labels || null}
onChange={handleLabel}
disabled={false}
disabled={isReadOnly}
hideDropdownArrow
/>
)}
@@ -120,7 +121,7 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
<IssuePropertyDate
value={issue?.start_date || null}
onChange={(date: string) => handleStartDate(date)}
disabled={false}
disabled={isReadOnly}
placeHolder="Start date"
/>
)}
@@ -130,7 +131,7 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
<IssuePropertyDate
value={issue?.target_date || null}
onChange={(date: string) => handleTargetDate(date)}
disabled={false}
disabled={isReadOnly}
placeHolder="Target date"
/>
)}
@@ -142,7 +143,7 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
value={issue?.assignees || null}
hideDropdownArrow
onChange={handleAssignee}
disabled={false}
disabled={isReadOnly}
multiple
/>
)}
@@ -153,7 +154,7 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
projectId={issue?.project_detail?.id || null}
value={issue?.estimate_point || null}
onChange={handleEstimate}
disabled={false}
disabled={isReadOnly}
hideDropdownArrow
/>
)}
@@ -10,6 +10,7 @@ import { IIssue } from "types";
import { EIssueActions } from "../../types";
// components
import { BaseKanBanRoot } from "../base-kanban-root";
import { EProjectStore } from "store/command-palette.store";
export interface ICycleKanBanLayout {}
@@ -18,7 +19,11 @@ export const CycleKanBanLayout: React.FC = observer(() => {
const { workspaceSlug, cycleId } = router.query as { workspaceSlug: string; cycleId: string };
// store
const { cycleIssues: cycleIssueStore, cycleIssueKanBanView: cycleIssueKanBanViewStore } = useMobxStore();
const {
cycleIssues: cycleIssueStore,
cycleIssuesFilter: cycleIssueFilterStore,
cycleIssueKanBanView: cycleIssueKanBanViewStore,
} = useMobxStore();
const issueActions = {
[EIssueActions.UPDATE]: async (issue: IIssue) => {
@@ -39,10 +44,12 @@ export const CycleKanBanLayout: React.FC = observer(() => {
<BaseKanBanRoot
issueActions={issueActions}
issueStore={cycleIssueStore}
issuesFilterStore={cycleIssueFilterStore}
kanbanViewStore={cycleIssueKanBanViewStore}
showLoader={true}
QuickActions={CycleIssueQuickActions}
viewId={cycleId}
currentStore={EProjectStore.CYCLE}
/>
);
});
@@ -0,0 +1,48 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { ProjectIssueQuickActions } from "components/issues";
// types
import { IIssue } from "types";
// constants
import { EIssueActions } from "../../types";
import { BaseKanBanRoot } from "../base-kanban-root";
export interface IKanBanLayout {}
export const DraftKanBanLayout: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug } = router.query as { workspaceSlug: string };
const {
projectDraftIssues: issueStore,
projectDraftIssuesFilter: projectIssuesFilterStore,
issueKanBanView: issueKanBanViewStore,
} = useMobxStore();
const issueActions = {
[EIssueActions.UPDATE]: async (issue: IIssue) => {
if (!workspaceSlug) return;
await issueStore.updateIssue(workspaceSlug, issue.project, issue.id, issue);
},
[EIssueActions.DELETE]: async (issue: IIssue) => {
if (!workspaceSlug) return;
await issueStore.removeIssue(workspaceSlug, issue.project, issue.id);
},
};
return (
<BaseKanBanRoot
issueActions={issueActions}
issuesFilterStore={projectIssuesFilterStore}
issueStore={issueStore}
kanbanViewStore={issueKanBanViewStore}
showLoader={true}
QuickActions={ProjectIssueQuickActions}
/>
);
});
@@ -10,6 +10,7 @@ import { IIssue } from "types";
// constants
import { EIssueActions } from "../../types";
import { BaseKanBanRoot } from "../base-kanban-root";
import { EProjectStore } from "store/command-palette.store";
export interface IModuleKanBanLayout {}
@@ -20,8 +21,8 @@ export const ModuleKanBanLayout: React.FC = observer(() => {
// store
const {
moduleIssues: moduleIssueStore,
moduleIssuesFilter: moduleIssueFilterStore,
moduleIssueKanBanView: moduleIssueKanBanViewStore,
issueDetail: issueDetailStore,
} = useMobxStore();
// const handleIssues = useCallback(
@@ -58,17 +59,19 @@ export const ModuleKanBanLayout: React.FC = observer(() => {
},
[EIssueActions.REMOVE]: async (issue: IIssue) => {
if (!workspaceSlug || !moduleId || !issue.bridge_id) return;
moduleIssueStore.removeIssueFromModule(workspaceSlug, issue.project, issue.id, moduleId, issue.bridge_id);
moduleIssueStore.removeIssueFromModule(workspaceSlug, issue.project, moduleId, issue.id, issue.bridge_id);
},
};
return (
<BaseKanBanRoot
issueActions={issueActions}
issueStore={moduleIssueStore}
issuesFilterStore={moduleIssueFilterStore}
kanbanViewStore={moduleIssueKanBanViewStore}
showLoader={true}
QuickActions={ModuleIssueQuickActions}
viewId={moduleId}
currentStore={EProjectStore.MODULE}
/>
);
});
@@ -1,166 +1,48 @@
import { FC, useCallback, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { DragDropContext } from "@hello-pangea/dnd";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { KanBanSwimLanes } from "../swimlanes";
import { KanBan } from "../default";
import { ProjectIssueQuickActions } from "components/issues";
import { Spinner } from "@plane/ui";
// constants
import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES } from "constants/issue";
// types
import { IIssue } from "types";
// constants
import { EIssueActions } from "../../types";
import { BaseKanBanRoot } from "../base-kanban-root";
import { EProjectStore } from "store/command-palette.store";
export interface IProfileIssuesKanBanLayout {}
export const ProfileIssuesKanBanLayout: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug, userId } = router.query as { workspaceSlug: string; userId: string };
export const ProfileIssuesKanBanLayout: FC = observer(() => {
const {
workspace: workspaceStore,
project: projectStore,
projectMember: { projectMembers },
projectState: projectStateStore,
profileIssues: profileIssuesStore,
profileIssueFilters: profileIssueFiltersStore,
workspaceProfileIssues: profileIssuesStore,
workspaceProfileIssuesFilter: profileIssueFiltersStore,
issueKanBanView: issueKanBanViewStore,
issueDetail: issueDetailStore,
} = useMobxStore();
const router = useRouter();
const { workspaceSlug } = router.query;
const issueActions = {
[EIssueActions.UPDATE]: async (issue: IIssue) => {
if (!workspaceSlug || !userId) return;
const issues = profileIssuesStore?.getIssues;
const sub_group_by: string | null = profileIssueFiltersStore?.userDisplayFilters?.sub_group_by || null;
const group_by: string | null = profileIssueFiltersStore?.userDisplayFilters?.group_by || null;
const order_by: string | null = profileIssueFiltersStore?.userDisplayFilters?.order_by || null;
const userDisplayFilters = profileIssueFiltersStore?.userDisplayFilters || null;
const displayProperties = profileIssueFiltersStore?.userDisplayProperties || null;
const currentKanBanView: "swimlanes" | "default" = profileIssueFiltersStore?.userDisplayFilters?.sub_group_by
? "swimlanes"
: "default";
const [isDragStarted, setIsDragStarted] = useState<boolean>(false);
// const onDragStart = () => {
// setIsDragStarted(true);
// };
const onDragEnd = (result: any) => {
setIsDragStarted(false);
if (!result) return;
if (
result.destination &&
result.source &&
result.destination.droppableId === result.source.droppableId &&
result.destination.index === result.source.index
)
return;
currentKanBanView === "default"
? issueKanBanViewStore?.handleDragDrop(result.source, result.destination)
: issueKanBanViewStore?.handleSwimlaneDragDrop(result.source, result.destination);
};
const handleIssues = useCallback(
(sub_group_by: string | null, group_by: string | null, issue: IIssue, action: EIssueActions) => {
if (!workspaceSlug) return;
if (action === EIssueActions.UPDATE) {
profileIssuesStore.updateIssueStructure(group_by, sub_group_by, issue);
issueDetailStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue);
}
if (action === EIssueActions.DELETE) profileIssuesStore.deleteIssue(group_by, sub_group_by, issue);
await profileIssuesStore.updateIssue(workspaceSlug, userId, issue.id, issue);
},
[profileIssuesStore, issueDetailStore, workspaceSlug]
);
[EIssueActions.DELETE]: async (issue: IIssue) => {
if (!workspaceSlug || !userId) return;
const handleKanBanToggle = (toggle: "groupByHeaderMinMax" | "subgroupByIssuesVisibility", value: string) => {
issueKanBanViewStore.handleKanBanToggle(toggle, value);
await profileIssuesStore.removeIssue(workspaceSlug, userId, issue.project, issue.id);
},
};
const states = projectStateStore?.projectStates || null;
const priorities = ISSUE_PRIORITIES || null;
const labels = workspaceStore.workspaceLabels || null;
const stateGroups = ISSUE_STATE_GROUPS || null;
const projects = projectStore?.workspaceProjects || null;
return (
<>
{profileIssuesStore.loader ? (
<div className="w-full h-full flex justify-center items-center">
<Spinner />
</div>
) : (
<div className={`relative min-w-full w-max min-h-full h-max bg-custom-background-90 px-3`}>
<DragDropContext onDragEnd={onDragEnd}>
{currentKanBanView === "default" ? (
<KanBan
issues={{}}
issueIds={[]}
sub_group_by={sub_group_by}
group_by={group_by}
order_by={order_by}
handleIssues={handleIssues}
quickActions={(sub_group_by, group_by, issue) => (
<ProjectIssueQuickActions
issue={issue}
handleDelete={async () => handleIssues(sub_group_by, group_by, issue, EIssueActions.DELETE)}
handleUpdate={async (data) => handleIssues(sub_group_by, group_by, data, EIssueActions.UPDATE)}
/>
)}
displayProperties={displayProperties}
kanBanToggle={issueKanBanViewStore?.kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
states={states}
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={projectMembers?.map((m) => m.member) ?? null}
projects={projects}
showEmptyGroup={userDisplayFilters?.show_empty_groups || true}
isDragStarted={isDragStarted}
/>
) : (
<KanBanSwimLanes
issues={{}}
issueIds={[]}
sub_group_by={sub_group_by}
group_by={group_by}
order_by={order_by}
handleIssues={handleIssues}
quickActions={(sub_group_by, group_by, issue) => (
<ProjectIssueQuickActions
issue={issue}
handleDelete={async () => handleIssues(sub_group_by, group_by, issue, EIssueActions.DELETE)}
handleUpdate={async (data) => handleIssues(sub_group_by, group_by, data, EIssueActions.UPDATE)}
/>
)}
displayProperties={displayProperties}
kanBanToggle={issueKanBanViewStore?.kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
states={states}
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={projectMembers?.map((m) => m.member) ?? null}
projects={projects}
showEmptyGroup={userDisplayFilters?.show_empty_groups || true}
isDragStarted={isDragStarted}
/>
)}
</DragDropContext>
</div>
)}
</>
<BaseKanBanRoot
issueActions={issueActions}
issuesFilterStore={profileIssueFiltersStore}
issueStore={profileIssuesStore}
kanbanViewStore={issueKanBanViewStore}
showLoader={true}
QuickActions={ProjectIssueQuickActions}
currentStore={EProjectStore.PROFILE}
/>
);
});
@@ -9,6 +9,7 @@ import { IIssue } from "types";
// constants
import { EIssueActions } from "../../types";
import { BaseKanBanRoot } from "../base-kanban-root";
import { EProjectStore } from "store/command-palette.store";
export interface IKanBanLayout {}
@@ -18,30 +19,32 @@ export const KanBanLayout: React.FC = observer(() => {
const {
projectIssues: issueStore,
projectIssuesFilter: issuesFilterStore,
issueKanBanView: issueKanBanViewStore,
issueDetail: issueDetailStore,
} = useMobxStore();
const issueActions = {
[EIssueActions.UPDATE]: async (issue: IIssue) => {
if (!workspaceSlug) return;
await issueDetailStore.updateIssue(workspaceSlug, issue.project, issue.id, issue);
await issueStore.updateIssue(workspaceSlug, issue.project, issue.id, issue);
},
[EIssueActions.DELETE]: async (issue: IIssue) => {
if (!workspaceSlug) return;
await issueDetailStore.deleteIssue(workspaceSlug, issue.project, issue.id);
await issueStore.removeIssue(workspaceSlug, issue.project, issue.id);
},
};
return (
<BaseKanBanRoot
issueActions={issueActions}
issuesFilterStore={issuesFilterStore}
issueStore={issueStore}
kanbanViewStore={issueKanBanViewStore}
showLoader={true}
QuickActions={ProjectIssueQuickActions}
currentStore={EProjectStore.PROJECT}
/>
);
});
@@ -9,6 +9,7 @@ import { EIssueActions } from "../../types";
import { ProjectIssueQuickActions } from "../../quick-action-dropdowns";
// components
import { BaseKanBanRoot } from "../base-kanban-root";
import { EProjectStore } from "store/command-palette.store";
export interface IViewKanBanLayout {}
@@ -18,30 +19,32 @@ export const ProjectViewKanBanLayout: React.FC = observer(() => {
const {
viewIssues: projectViewIssuesStore,
viewIssuesFilter: projectIssueViewFiltersStore,
issueKanBanView: projectViewIssueKanBanViewStore,
issueDetail: issueDetailStore,
} = useMobxStore();
const issueActions = {
[EIssueActions.UPDATE]: async (issue: IIssue) => {
if (!workspaceSlug) return;
await issueDetailStore.updateIssue(workspaceSlug, issue.project, issue.id, issue);
await projectViewIssuesStore.updateIssue(workspaceSlug, issue.project, issue.id, issue);
},
[EIssueActions.DELETE]: async (issue: IIssue) => {
if (!workspaceSlug) return;
await issueDetailStore.deleteIssue(workspaceSlug, issue.project, issue.id);
await projectViewIssuesStore.removeIssue(workspaceSlug, issue.project, issue.id);
},
};
return (
<BaseKanBanRoot
issueActions={issueActions}
issuesFilterStore={projectIssueViewFiltersStore}
issueStore={projectViewIssuesStore}
kanbanViewStore={projectViewIssueKanBanViewStore}
showLoader={true}
QuickActions={ProjectIssueQuickActions}
currentStore={EProjectStore.PROJECT_VIEW}
/>
);
});
@@ -10,6 +10,7 @@ import { IIssueResponse, IGroupedIssues, ISubGroupedIssues, TUnGroupedIssues } f
// constants
import { getValueFromObject } from "constants/issue";
import { EIssueActions } from "../types";
import { EProjectStore } from "store/command-palette.store";
interface ISubGroupSwimlaneHeader {
issues: IIssueResponse;
@@ -20,9 +21,10 @@ interface ISubGroupSwimlaneHeader {
listKey: string;
kanBanToggle: any;
handleKanBanToggle: any;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
}
const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = ({
issues,
issueIds,
sub_group_by,
group_by,
@@ -30,6 +32,8 @@ const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = ({
listKey,
kanBanToggle,
handleKanBanToggle,
disableIssueCreation,
currentStore,
}) => {
const calculateIssueCount = (column_id: string) => {
let issueCount = 0;
@@ -54,6 +58,8 @@ const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = ({
issues_count={calculateIssueCount(getValueFromObject(_list, listKey) as string)}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
</div>
))}
@@ -78,6 +84,10 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
kanBanToggle: any;
handleKanBanToggle: any;
isDragStarted?: boolean;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
enableQuickIssueCreate: boolean;
isReadOnly: boolean;
}
const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
const {
@@ -101,6 +111,9 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
members,
projects,
isDragStarted,
disableIssueCreation,
enableQuickIssueCreate,
isReadOnly,
} = props;
const calculateIssueCount = (column_id: string) => {
@@ -128,6 +141,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
issues_count={calculateIssueCount(getValueFromObject(_list, listKey) as string)}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
/>
</div>
<div className="w-full border-b border-custom-border-400 border-dashed" />
@@ -153,8 +167,9 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
labels={labels}
members={members}
projects={projects}
enableQuickIssueCreate
enableQuickIssueCreate={enableQuickIssueCreate}
isDragStarted={isDragStarted}
isReadOnly={isReadOnly}
/>
</div>
)}
@@ -183,6 +198,10 @@ export interface IKanBanSwimLanes {
members: IUserLite[] | null;
projects: IProject[] | null;
isDragStarted?: boolean;
disableIssueCreation?: boolean;
currentStore?: EProjectStore;
enableQuickIssueCreate: boolean;
isReadOnly: boolean;
}
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
@@ -205,6 +224,10 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
members,
projects,
isDragStarted,
disableIssueCreation,
enableQuickIssueCreate,
isReadOnly,
currentStore,
} = props;
return (
@@ -220,6 +243,8 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
listKey={`id`}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
@@ -233,6 +258,8 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
listKey={`id`}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
@@ -246,6 +273,8 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
listKey={`key`}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
@@ -259,6 +288,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
listKey={`key`}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
currentStore={currentStore}
/>
)}
@@ -272,6 +302,8 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
listKey={`id`}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
@@ -285,6 +317,8 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
listKey={`id`}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
@@ -298,6 +332,8 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
listKey={`id`}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
disableIssueCreation={disableIssueCreation}
currentStore={currentStore}
/>
)}
</div>
@@ -324,6 +360,9 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
members={members}
projects={projects}
isDragStarted={isDragStarted}
disableIssueCreation={disableIssueCreation}
enableQuickIssueCreate={enableQuickIssueCreate}
isReadOnly={isReadOnly}
/>
)}
@@ -349,6 +388,9 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
members={members}
projects={projects}
isDragStarted={isDragStarted}
disableIssueCreation={disableIssueCreation}
enableQuickIssueCreate={enableQuickIssueCreate}
isReadOnly={isReadOnly}
/>
)}
@@ -374,6 +416,9 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
members={members}
projects={projects}
isDragStarted={isDragStarted}
disableIssueCreation={disableIssueCreation}
enableQuickIssueCreate={enableQuickIssueCreate}
isReadOnly={isReadOnly}
/>
)}
@@ -399,6 +444,9 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
members={members}
projects={projects}
isDragStarted={isDragStarted}
disableIssueCreation={disableIssueCreation}
enableQuickIssueCreate={enableQuickIssueCreate}
isReadOnly={isReadOnly}
/>
)}
@@ -424,6 +472,9 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
members={members}
projects={projects}
isDragStarted={isDragStarted}
disableIssueCreation={disableIssueCreation}
enableQuickIssueCreate={enableQuickIssueCreate}
isReadOnly={isReadOnly}
/>
)}
@@ -449,6 +500,9 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
members={members}
projects={projects}
isDragStarted={isDragStarted}
disableIssueCreation={disableIssueCreation}
enableQuickIssueCreate={enableQuickIssueCreate}
isReadOnly={isReadOnly}
/>
)}
@@ -474,6 +528,9 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
members={members}
projects={projects}
isDragStarted={isDragStarted}
disableIssueCreation={disableIssueCreation}
enableQuickIssueCreate={enableQuickIssueCreate}
isReadOnly={isReadOnly}
/>
)}
@@ -499,6 +556,9 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
members={members}
projects={projects}
isDragStarted={isDragStarted}
disableIssueCreation={disableIssueCreation}
enableQuickIssueCreate={enableQuickIssueCreate}
isReadOnly={isReadOnly}
/>
)}
</div>

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