Merge branch 'preview' of https://github.com/makeplane/plane into chore/issues-events

This commit is contained in:
LAKHAN BAHETI 2024-05-28 13:52:05 +05:30
commit 7cf6603e22
139 changed files with 3038 additions and 1336 deletions

View File

@ -19,14 +19,14 @@ const InstanceAIPage = observer(() => {
return ( return (
<> <>
<PageHeader title="Artificial Intelligence - God Mode" /> <PageHeader title="Artificial Intelligence - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col"> <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0"> <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">AI features for all your workspaces</div> <div className="text-xl font-medium text-custom-text-100">AI features for all your workspaces</div>
<div className="text-sm font-normal text-custom-text-300"> <div className="text-sm font-normal text-custom-text-300">
Configure your AI API credentials so Plane AI features are turned on for all your workspaces. Configure your AI API credentials so Plane AI features are turned on for all your workspaces.
</div> </div>
</div> </div>
<div className="flex-grow overflow-hidden overflow-y-auto"> <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? ( {formattedConfig ? (
<InstanceAIForm config={formattedConfig} /> <InstanceAIForm config={formattedConfig} />
) : ( ) : (

View File

@ -64,8 +64,8 @@ const InstanceGithubAuthenticationPage = observer(() => {
return ( return (
<> <>
<PageHeader title="Authentication - God Mode" /> <PageHeader title="Authentication - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col"> <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0"> <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<AuthenticationMethodCard <AuthenticationMethodCard
name="Github" name="Github"
description="Allow members to login or sign up to plane with their Github accounts." description="Allow members to login or sign up to plane with their Github accounts."
@ -93,7 +93,7 @@ const InstanceGithubAuthenticationPage = observer(() => {
withBorder={false} withBorder={false}
/> />
</div> </div>
<div className="flex-grow overflow-hidden overflow-y-auto"> <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md p-4">
{formattedConfig ? ( {formattedConfig ? (
<InstanceGithubConfigForm config={formattedConfig} /> <InstanceGithubConfigForm config={formattedConfig} />
) : ( ) : (

View File

@ -58,8 +58,8 @@ const InstanceGoogleAuthenticationPage = observer(() => {
return ( return (
<> <>
<PageHeader title="Authentication - God Mode" /> <PageHeader title="Authentication - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col"> <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0"> <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<AuthenticationMethodCard <AuthenticationMethodCard
name="Google" name="Google"
description="Allow members to login or sign up to plane with their Google description="Allow members to login or sign up to plane with their Google
@ -81,7 +81,7 @@ const InstanceGoogleAuthenticationPage = observer(() => {
withBorder={false} withBorder={false}
/> />
</div> </div>
<div className="flex-grow overflow-hidden overflow-y-auto"> <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md p-4">
{formattedConfig ? ( {formattedConfig ? (
<InstanceGoogleConfigForm config={formattedConfig} /> <InstanceGoogleConfigForm config={formattedConfig} />
) : ( ) : (

View File

@ -119,14 +119,14 @@ const InstanceAuthenticationPage = observer(() => {
return ( return (
<> <>
<PageHeader title="Authentication - God Mode" /> <PageHeader title="Authentication - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col"> <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0"> <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Manage authentication for your instance</div> <div className="text-xl font-medium text-custom-text-100">Manage authentication for your instance</div>
<div className="text-sm font-normal text-custom-text-300"> <div className="text-sm font-normal text-custom-text-300">
Configure authentication modes for your team and restrict sign ups to be invite only. Configure authentication modes for your team and restrict sign ups to be invite only.
</div> </div>
</div> </div>
<div className="flex-grow overflow-hidden overflow-y-auto"> <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? ( {formattedConfig ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-lg font-medium">Authentication modes</div> <div className="text-lg font-medium">Authentication modes</div>

View File

@ -19,8 +19,8 @@ const InstanceEmailPage = observer(() => {
return ( return (
<> <>
<PageHeader title="Email - God Mode" /> <PageHeader title="Email - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col"> <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0"> <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Secure emails from your own instance</div> <div className="text-xl font-medium text-custom-text-100">Secure emails from your own instance</div>
<div className="text-sm font-normal text-custom-text-300"> <div className="text-sm font-normal text-custom-text-300">
Plane can send useful emails to you and your users from your own instance without talking to the Internet. Plane can send useful emails to you and your users from your own instance without talking to the Internet.
@ -30,7 +30,7 @@ const InstanceEmailPage = observer(() => {
</div> </div>
</div> </div>
</div> </div>
<div className="flex-grow overflow-hidden overflow-y-auto"> <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? ( {formattedConfig ? (
<InstanceEmailForm config={formattedConfig} /> <InstanceEmailForm config={formattedConfig} />
) : ( ) : (

View File

@ -51,7 +51,7 @@ export const SendTestEmailModal: FC<Props> = (props) => {
setSendEmailStep(ESendEmailSteps.SUCCESS); setSendEmailStep(ESendEmailSteps.SUCCESS);
}) })
.catch((error) => { .catch((error) => {
setError(error?.message || "Failed to send email"); setError(error?.error || "Failed to send email");
setSendEmailStep(ESendEmailSteps.FAILED); setSendEmailStep(ESendEmailSteps.FAILED);
}) })
.finally(() => { .finally(() => {

View File

@ -10,15 +10,15 @@ function GeneralPage() {
console.log("instance", instance); console.log("instance", instance);
return ( return (
<> <>
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col"> <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0"> <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">General settings</div> <div className="text-xl font-medium text-custom-text-100">General settings</div>
<div className="text-sm font-normal text-custom-text-300"> <div className="text-sm font-normal text-custom-text-300">
Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your
instance. instance.
</div> </div>
</div> </div>
<div className="flex-grow overflow-hidden overflow-y-auto"> <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{instance && instanceAdmins && ( {instance && instanceAdmins && (
<GeneralConfigurationForm instance={instance} instanceAdmins={instanceAdmins} /> <GeneralConfigurationForm instance={instance} instanceAdmins={instanceAdmins} />
)} )}

View File

@ -332,42 +332,90 @@ body {
} }
/* scrollbar style */ /* scrollbar style */
::-webkit-scrollbar { @-moz-document url-prefix() {
display: none; * {
scrollbar-width: none;
}
.vertical-scrollbar,
.horizontal-scrollbar {
scrollbar-width: initial;
scrollbar-color: rgba(96, 100, 108, 0.1) transparent;
}
.vertical-scrollbar:hover,
.horizontal-scrollbar:hover {
scrollbar-color: rgba(96, 100, 108, 0.25) transparent;
}
.vertical-scrollbar:active,
.horizontal-scrollbar:active {
scrollbar-color: rgba(96, 100, 108, 0.7) transparent;
}
} }
.horizontal-scroll-enable { .vertical-scrollbar {
overflow-x: scroll; overflow-y: auto;
} }
.horizontal-scrollbar {
.horizontal-scroll-enable::-webkit-scrollbar { overflow-x: auto;
}
.vertical-scrollbar::-webkit-scrollbar,
.horizontal-scrollbar::-webkit-scrollbar {
display: block; display: block;
height: 7px; }
width: 0; .vertical-scrollbar::-webkit-scrollbar-track,
.horizontal-scrollbar::-webkit-scrollbar-track {
background-color: transparent;
border-radius: 9999px;
}
.vertical-scrollbar::-webkit-scrollbar-thumb,
.horizontal-scrollbar::-webkit-scrollbar-thumb {
background-clip: padding-box;
background-color: rgba(96, 100, 108, 0.1);
border-radius: 9999px;
}
.vertical-scrollbar:hover::-webkit-scrollbar-thumb,
.horizontal-scrollbar:hover::-webkit-scrollbar-thumb {
background-color: rgba(96, 100, 108, 0.25);
}
.vertical-scrollbar::-webkit-scrollbar-thumb:hover,
.horizontal-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(96, 100, 108, 0.5);
}
.vertical-scrollbar::-webkit-scrollbar-thumb:active,
.horizontal-scrollbar::-webkit-scrollbar-thumb:active {
background-color: rgba(96, 100, 108, 0.7);
}
.vertical-scrollbar::-webkit-scrollbar-corner,
.horizontal-scrollbar::-webkit-scrollbar-corner {
background-color: transparent;
}
.vertical-scrollbar-margin-top-md::-webkit-scrollbar-track {
margin-top: 44px;
} }
.horizontal-scroll-enable::-webkit-scrollbar-track { /* scrollbar sm size */
height: 7px; .scrollbar-sm::-webkit-scrollbar {
background-color: rgba(var(--color-background-100)); height: 12px;
width: 12px;
} }
.scrollbar-sm::-webkit-scrollbar-thumb {
.horizontal-scroll-enable::-webkit-scrollbar-thumb { border: 3px solid rgba(0, 0, 0, 0);
border-radius: 5px;
background-color: rgba(var(--color-scrollbar));
} }
/* scrollbar md size */
.vertical-scroll-enable::-webkit-scrollbar { .scrollbar-md::-webkit-scrollbar {
display: block; height: 14px;
width: 5px; width: 14px;
} }
.scrollbar-md::-webkit-scrollbar-thumb {
.vertical-scroll-enable::-webkit-scrollbar-track { border: 3px solid rgba(0, 0, 0, 0);
width: 5px;
} }
/* scrollbar lg size */
.vertical-scroll-enable::-webkit-scrollbar-thumb { .scrollbar-lg::-webkit-scrollbar {
border-radius: 5px; height: 16px;
background-color: rgba(var(--color-background-90)); width: 16px;
}
.scrollbar-lg::-webkit-scrollbar-thumb {
border: 4px solid rgba(0, 0, 0, 0);
} }
/* end scrollbar style */ /* end scrollbar style */

View File

@ -19,14 +19,14 @@ const InstanceImagePage = observer(() => {
return ( return (
<> <>
<PageHeader title="Image - God Mode" /> <PageHeader title="Image - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col"> <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0"> <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Third-party image libraries</div> <div className="text-xl font-medium text-custom-text-100">Third-party image libraries</div>
<div className="text-sm font-normal text-custom-text-300"> <div className="text-sm font-normal text-custom-text-300">
Let your users search and choose images from third-party libraries Let your users search and choose images from third-party libraries
</div> </div>
</div> </div>
<div className="flex-grow overflow-hidden overflow-y-auto"> <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? ( {formattedConfig ? (
<InstanceImageConfigForm config={formattedConfig} /> <InstanceImageConfigForm config={formattedConfig} />
) : ( ) : (

View File

@ -38,7 +38,7 @@ export const HelpSection: FC = observer(() => {
// refs // refs
const helpOptionsRef = useRef<HTMLDivElement | null>(null); const helpOptionsRef = useRef<HTMLDivElement | null>(null);
const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace"); const redirectionLink = encodeURI(WEB_BASE_URL + "/");
return ( return (
<div <div

View File

@ -56,7 +56,7 @@ export const SidebarMenu = observer(() => {
}; };
return ( return (
<div className="flex h-full w-full flex-col gap-2.5 overflow-y-auto px-4 py-4"> <div className="flex h-full w-full flex-col gap-2.5 overflow-y-scroll vertical-scrollbar scrollbar-sm px-4 py-4">
{INSTANCE_ADMIN_LINKS.map((item, index) => { {INSTANCE_ADMIN_LINKS.map((item, index) => {
const isActive = item.href === pathName || pathName.includes(item.href); const isActive = item.href === pathName || pathName.includes(item.href);
return ( return (

View File

@ -158,6 +158,7 @@ export const InstanceSetupForm: FC = (props) => {
onError={() => setIsSubmitting(false)} onError={() => setIsSubmitting(false)}
> >
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} /> <input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<input type="hidden" name="is_telemetry_enabled" value={formData.is_telemetry_enabled ? "True" : "False"} />
<div className="flex flex-col sm:flex-row items-center gap-4"> <div className="flex flex-col sm:flex-row items-center gap-4">
<div className="w-full space-y-1"> <div className="w-full space-y-1">
@ -319,8 +320,6 @@ export const InstanceSetupForm: FC = (props) => {
<div> <div>
<Checkbox <Checkbox
id="is_telemetry_enabled" id="is_telemetry_enabled"
name="is_telemetry_enabled"
value={formData.is_telemetry_enabled ? "True" : "False"}
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)} onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
checked={formData.is_telemetry_enabled} checked={formData.is_telemetry_enabled}
/> />

View File

@ -7,9 +7,9 @@ import { useTheme as nextUseTheme } from "next-themes";
// ui // ui
import { Button, getButtonStyling } from "@plane/ui"; import { Button, getButtonStyling } from "@plane/ui";
// helpers // helpers
import { resolveGeneralTheme } from "helpers/common.helper"; import { WEB_BASE_URL, resolveGeneralTheme } from "helpers/common.helper";
// hooks // hooks
import { useInstance, useTheme } from "@/hooks/store"; import { useTheme } from "@/hooks/store";
// icons // icons
import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg"; import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg";
import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg"; import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg";
@ -17,11 +17,10 @@ import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg";
export const NewUserPopup: React.FC = observer(() => { export const NewUserPopup: React.FC = observer(() => {
// hooks // hooks
const { isNewUserPopup, toggleNewUserPopup } = useTheme(); const { isNewUserPopup, toggleNewUserPopup } = useTheme();
const { config } = useInstance();
// theme // theme
const { resolvedTheme } = nextUseTheme(); const { resolvedTheme } = nextUseTheme();
const redirectionLink = `${config?.app_base_url ? `${config?.app_base_url}/create-workspace` : `/god-mode/`}`; const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace");
if (!isNewUserPopup) return <></>; if (!isNewUserPopup) return <></>;
return ( return (

View File

@ -1,4 +1,5 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
trailingSlash: true, trailingSlash: true,
reactStrictMode: false, reactStrictMode: false,

View File

@ -106,7 +106,9 @@ class PageDetailSerializer(PageSerializer):
description_html = serializers.CharField() description_html = serializers.CharField()
class Meta(PageSerializer.Meta): class Meta(PageSerializer.Meta):
fields = PageSerializer.Meta.fields + ["description_html"] fields = PageSerializer.Meta.fields + [
"description_html",
]
class SubPageSerializer(BaseSerializer): class SubPageSerializer(BaseSerializer):

View File

@ -6,6 +6,7 @@ from plane.app.views import (
PageFavoriteViewSet, PageFavoriteViewSet,
PageLogEndpoint, PageLogEndpoint,
SubPagesEndpoint, SubPagesEndpoint,
PagesDescriptionViewSet,
) )
@ -79,4 +80,14 @@ urlpatterns = [
SubPagesEndpoint.as_view(), SubPagesEndpoint.as_view(),
name="sub-page", name="sub-page",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/description/",
PagesDescriptionViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
}
),
name="page-description",
),
] ]

View File

@ -177,6 +177,7 @@ from .page.base import (
PageFavoriteViewSet, PageFavoriteViewSet,
PageLogEndpoint, PageLogEndpoint,
SubPagesEndpoint, SubPagesEndpoint,
PagesDescriptionViewSet,
) )
from .search import GlobalSearchEndpoint, IssueSearchEndpoint from .search import GlobalSearchEndpoint, IssueSearchEndpoint

View File

@ -1,5 +1,6 @@
# Python imports # Python imports
import json import json
import base64
from datetime import datetime from datetime import datetime
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
@ -8,6 +9,7 @@ from django.db import connection
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.http import StreamingHttpResponse
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -388,3 +390,48 @@ class SubPagesEndpoint(BaseAPIView):
return Response( return Response(
SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK
) )
class PagesDescriptionViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
def retrieve(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
binary_data = page.description_binary
def stream_data():
if binary_data:
yield binary_data
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="page_description.bin"'
)
return response
def partial_update(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
base64_data = request.data.get("description_binary")
if base64_data:
# Decode the base64 data to bytes
new_binary_data = base64.b64decode(base64_data)
# Store the updated binary data
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.save()
return Response({"message": "Updated successfully"})
else:
return Response({"error": "No binary data provided"})

View File

@ -1,5 +1,5 @@
# Python imports # Python imports
# import uuid import uuid
# Django imports # Django imports
from django.db.models import Case, Count, IntegerField, Q, When from django.db.models import Case, Count, IntegerField, Q, When
@ -183,8 +183,8 @@ class UserEndpoint(BaseViewSet):
profile.save() profile.save()
# Reset password # Reset password
# user.is_password_autoset = True user.is_password_autoset = True
# user.set_password(uuid.uuid4().hex) user.set_password(uuid.uuid4().hex)
# Deactivate the user # Deactivate the user
user.is_active = False user.is_active = False

View File

@ -17,6 +17,7 @@ AUTHENTICATION_ERROR_CODES = {
"INVALID_EMAIL_SIGN_UP": 5045, "INVALID_EMAIL_SIGN_UP": 5045,
"INVALID_EMAIL_MAGIC_SIGN_UP": 5050, "INVALID_EMAIL_MAGIC_SIGN_UP": 5050,
"MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED": 5055, "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED": 5055,
"EMAIL_PASSWORD_AUTHENTICATION_DISABLED": 5056,
# Sign In # Sign In
"USER_DOES_NOT_EXIST": 5060, "USER_DOES_NOT_EXIST": 5060,
"AUTHENTICATION_FAILED_SIGN_IN": 5065, "AUTHENTICATION_FAILED_SIGN_IN": 5065,

View File

@ -8,6 +8,10 @@ from django.utils import timezone
from plane.db.models import Account from plane.db.models import Account
from .base import Adapter from .base import Adapter
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class OauthAdapter(Adapter): class OauthAdapter(Adapter):
@ -50,20 +54,42 @@ class OauthAdapter(Adapter):
return self.complete_login_or_signup() return self.complete_login_or_signup()
def get_user_token(self, data, headers=None): def get_user_token(self, data, headers=None):
headers = headers or {} try:
response = requests.post( headers = headers or {}
self.get_token_url(), data=data, headers=headers response = requests.post(
) self.get_token_url(), data=data, headers=headers
response.raise_for_status() )
return response.json() response.raise_for_status()
return response.json()
except requests.RequestException:
code = (
"GOOGLE_OAUTH_PROVIDER_ERROR"
if self.provider == "google"
else "GITHUB_OAUTH_PROVIDER_ERROR"
)
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[code],
error_message=str(code),
)
def get_user_response(self): def get_user_response(self):
headers = { try:
"Authorization": f"Bearer {self.token_data.get('access_token')}" headers = {
} "Authorization": f"Bearer {self.token_data.get('access_token')}"
response = requests.get(self.get_user_info_url(), headers=headers) }
response.raise_for_status() response = requests.get(self.get_user_info_url(), headers=headers)
return response.json() response.raise_for_status()
return response.json()
except requests.RequestException:
code = (
"GOOGLE_OAUTH_PROVIDER_ERROR"
if self.provider == "google"
else "GITHUB_OAUTH_PROVIDER_ERROR"
)
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[code],
error_message=str(code),
)
def set_user_data(self, data): def set_user_data(self, data):
self.user_data = data self.user_data = data

View File

@ -41,8 +41,10 @@ class EmailProvider(CredentialAdapter):
if ENABLE_EMAIL_PASSWORD == "0": if ENABLE_EMAIL_PASSWORD == "0":
raise AuthenticationException( raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ENABLE_EMAIL_PASSWORD"], error_code=AUTHENTICATION_ERROR_CODES[
error_message="ENABLE_EMAIL_PASSWORD", "EMAIL_PASSWORD_AUTHENTICATION_DISABLED"
],
error_message="EMAIL_PASSWORD_AUTHENTICATION_DISABLED",
) )
def set_user_data(self): def set_user_data(self):

View File

@ -105,14 +105,26 @@ class GitHubOAuthProvider(OauthAdapter):
) )
def __get_email(self, headers): def __get_email(self, headers):
# Github does not provide email in user response try:
emails_url = "https://api.github.com/user/emails" # Github does not provide email in user response
emails_response = requests.get(emails_url, headers=headers).json() emails_url = "https://api.github.com/user/emails"
email = next( emails_response = requests.get(emails_url, headers=headers).json()
(email["email"] for email in emails_response if email["primary"]), email = next(
None, (
) email["email"]
return email for email in emails_response
if email["primary"]
),
None,
)
return email
except requests.RequestException:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GITHUB_OAUTH_PROVIDER_ERROR"
],
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
def set_user_data(self): def set_user_data(self):
user_info_response = self.get_user_response() user_info_response = self.get_user_response()

View File

@ -18,6 +18,7 @@ def get_view_props():
class Page(ProjectBaseModel): class Page(ProjectBaseModel):
name = models.CharField(max_length=255, blank=True) name = models.CharField(max_length=255, blank=True)
description = models.JSONField(default=dict, blank=True) description = models.JSONField(default=dict, blank=True)
description_binary = models.BinaryField(null=True)
description_html = models.TextField(blank=True, default="<p></p>") description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True) description_stripped = models.TextField(blank=True, null=True)
owned_by = models.ForeignKey( owned_by = models.ForeignKey(
@ -43,7 +44,6 @@ class Page(ProjectBaseModel):
is_locked = models.BooleanField(default=False) is_locked = models.BooleanField(default=False)
view_props = models.JSONField(default=get_view_props) view_props = models.JSONField(default=get_view_props)
logo_props = models.JSONField(default=dict) logo_props = models.JSONField(default=dict)
description_binary = models.BinaryField(null=True)
class Meta: class Meta:
verbose_name = "Page" verbose_name = "Page"

View File

@ -148,7 +148,7 @@ class InstanceEndpoint(BaseAPIView):
data["app_base_url"] = settings.APP_BASE_URL data["app_base_url"] = settings.APP_BASE_URL
instance_data = serializer.data instance_data = serializer.data
instance_data["workspaces_exist"] = Workspace.objects.count() > 1 instance_data["workspaces_exist"] = Workspace.objects.count() >= 1
response_data = {"config": data, "instance": instance_data} response_data = {"config": data, "instance": instance_data}
return Response(response_data, status=status.HTTP_200_OK) return Response(response_data, status=status.HTTP_200_OK)

View File

@ -128,7 +128,7 @@ services:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-} platform: ${DOCKER_PLATFORM:-}
pull_policy: ${PULL_POLICY:-always} pull_policy: ${PULL_POLICY:-always}
restart: no restart: "no"
command: ./bin/docker-entrypoint-migrator.sh command: ./bin/docker-entrypoint-migrator.sh
volumes: volumes:
- logs_migrator:/code/plane/logs - logs_migrator:/code/plane/logs

View File

@ -13,17 +13,21 @@ import { EditorMenuItemNames, getEditorMenuItems } from "src/ui/menus/menu-items
import { EditorRefApi } from "src/types/editor-ref-api"; import { EditorRefApi } from "src/types/editor-ref-api";
import { IMarking, scrollSummary } from "src/helpers/scroll-to-node"; import { IMarking, scrollSummary } from "src/helpers/scroll-to-node";
interface CustomEditorProps { export type TFileHandler = {
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
export interface CustomEditorProps {
id?: string; id?: string;
uploadFile: UploadImage; fileHandler: TFileHandler;
restoreFile: RestoreImage; initialValue?: string;
deleteFile: DeleteImage;
cancelUploadImage?: () => void;
initialValue: string;
editorClassName: string; editorClassName: string;
// undefined when prop is not passed, null if intentionally passed to stop // undefined when prop is not passed, null if intentionally passed to stop
// swr syncing // swr syncing
value: string | null | undefined; value?: string | null | undefined;
onChange?: (json: object, html: string) => void; onChange?: (json: object, html: string) => void;
extensions?: any; extensions?: any;
editorProps?: EditorProps; editorProps?: EditorProps;
@ -38,19 +42,16 @@ interface CustomEditorProps {
} }
export const useEditor = ({ export const useEditor = ({
uploadFile,
id = "", id = "",
deleteFile,
cancelUploadImage,
editorProps = {}, editorProps = {},
initialValue, initialValue,
editorClassName, editorClassName,
value, value,
extensions = [], extensions = [],
fileHandler,
onChange, onChange,
forwardedRef, forwardedRef,
tabIndex, tabIndex,
restoreFile,
handleEditorReady, handleEditorReady,
mentionHandler, mentionHandler,
placeholder, placeholder,
@ -67,10 +68,10 @@ export const useEditor = ({
mentionHighlights: mentionHandler.highlights ?? [], mentionHighlights: mentionHandler.highlights ?? [],
}, },
fileConfig: { fileConfig: {
deleteFile, uploadFile: fileHandler.upload,
restoreFile, deleteFile: fileHandler.delete,
cancelUploadImage, restoreFile: fileHandler.restore,
uploadFile, cancelUploadImage: fileHandler.cancel,
}, },
placeholder, placeholder,
tabIndex, tabIndex,
@ -139,7 +140,7 @@ export const useEditor = ({
} }
}, },
executeMenuItemCommand: (itemName: EditorMenuItemNames) => { executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
const editorItems = getEditorMenuItems(editorRef.current, uploadFile); const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName); const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
@ -155,7 +156,7 @@ export const useEditor = ({
} }
}, },
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => { isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
const editorItems = getEditorMenuItems(editorRef.current, uploadFile); const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName); const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemName); const item = getEditorMenuItem(itemName);
@ -177,6 +178,10 @@ export const useEditor = ({
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput; return markdownOutput;
}, },
getHTML: (): string => {
const htmlOutput = editorRef.current?.getHTML() ?? "<p></p>";
return htmlOutput;
},
scrollSummary: (marking: IMarking): void => { scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return; if (!editorRef.current) return;
scrollSummary(editorRef.current, marking); scrollSummary(editorRef.current, marking);
@ -199,7 +204,7 @@ export const useEditor = ({
} }
}, },
}), }),
[editorRef, savedSelection, uploadFile] [editorRef, savedSelection, fileHandler.upload]
); );
if (!editor) { if (!editor) {

View File

@ -68,6 +68,10 @@ export const useReadOnlyEditor = ({
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput; return markdownOutput;
}, },
getHTML: (): string => {
const htmlOutput = editorRef.current?.getHTML() ?? "<p></p>";
return htmlOutput;
},
scrollSummary: (marking: IMarking): void => { scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return; if (!editorRef.current) return;
scrollSummary(editorRef.current, marking); scrollSummary(editorRef.current, marking);

View File

@ -24,6 +24,7 @@ export * from "src/ui/menus/menu-items";
export * from "src/lib/editor-commands"; export * from "src/lib/editor-commands";
// types // types
export type { CustomEditorProps, TFileHandler } from "src/hooks/use-editor";
export type { DeleteImage } from "src/types/delete-image"; export type { DeleteImage } from "src/types/delete-image";
export type { UploadImage } from "src/types/upload-image"; export type { UploadImage } from "src/types/upload-image";
export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api"; export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api";

View File

@ -1,5 +1,7 @@
import { Extensions, generateJSON, getSchema } from "@tiptap/core";
import { Selection } from "@tiptap/pm/state"; import { Selection } from "@tiptap/pm/state";
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { CoreEditorExtensionsWithoutProps } from "src/ui/extensions/core-without-props";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
interface EditorClassNames { interface EditorClassNames {
noBorder?: boolean; noBorder?: boolean;
@ -58,3 +60,20 @@ export const isValidHttpUrl = (string: string): boolean => {
return url.protocol === "http:" || url.protocol === "https:"; return url.protocol === "http:" || url.protocol === "https:";
}; };
/**
* @description return an object with contentJSON and editorSchema
* @description contentJSON- ProseMirror JSON from HTML content
* @description editorSchema- editor schema from extensions
* @param {string} html
* @returns {object} {contentJSON, editorSchema}
*/
export const generateJSONfromHTML = (html: string) => {
const extensions = CoreEditorExtensionsWithoutProps();
const contentJSON = generateJSON(html ?? "<p></p>", extensions as Extensions);
const editorSchema = getSchema(extensions as Extensions);
return {
contentJSON,
editorSchema,
};
};

View File

@ -3,6 +3,7 @@ import { EditorMenuItemNames } from "src/ui/menus/menu-items";
export type EditorReadOnlyRefApi = { export type EditorReadOnlyRefApi = {
getMarkDown: () => string; getMarkDown: () => string;
getHTML: () => string;
clearEditor: () => void; clearEditor: () => void;
setEditorValue: (content: string) => void; setEditorValue: (content: string) => void;
scrollSummary: (marking: IMarking) => void; scrollSummary: (marking: IMarking) => void;

View File

@ -0,0 +1,121 @@
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
import TiptapUnderline from "@tiptap/extension-underline";
import Placeholder from "@tiptap/extension-placeholder";
import { Markdown } from "tiptap-markdown";
import { Table } from "src/ui/extensions/table/table";
import { TableCell } from "src/ui/extensions/table/table-cell/table-cell";
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
import { TableRow } from "src/ui/extensions/table/table-row/table-row";
import { isValidHttpUrl } from "src/lib/utils";
import { CustomCodeBlockExtension } from "src/ui/extensions/code";
import { CustomKeymap } from "src/ui/extensions/keymap";
import { CustomQuoteExtension } from "src/ui/extensions/quote";
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
import { CustomTypographyExtension } from "src/ui/extensions/typography";
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
import { CustomCodeMarkPlugin } from "src/ui/extensions/custom-code-inline/inline-code-plugin";
import { MentionsWithoutProps } from "src/ui/mentions/mention-without-props";
import { ImageExtensionWithoutProps } from "src/ui/extensions/image/image-extension-without-props";
import StarterKit from "@tiptap/starter-kit";
export const CoreEditorExtensionsWithoutProps = () => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
},
},
listItem: {
HTMLAttributes: {
class: "not-prose space-y-2",
},
},
code: false,
codeBlock: false,
horizontalRule: false,
blockquote: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 1,
},
}),
CustomQuoteExtension,
CustomHorizontalRule.configure({
HTMLAttributes: {
class: "my-4 border-custom-border-400",
},
}),
CustomKeymap,
// ListKeymap,
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
linkOnPaste: true,
protocols: ["http", "https"],
validate: (url: string) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
CustomTypographyExtension,
ImageExtensionWithoutProps().configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
TiptapUnderline,
TextStyle,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2 space-y-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex",
},
nested: true,
}),
CustomCodeBlockExtension.configure({
HTMLAttributes: {
class: "",
},
}),
CustomCodeMarkPlugin,
CustomCodeInlineExtension,
Markdown.configure({
html: true,
transformPastedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
MentionsWithoutProps(),
Placeholder.configure({
placeholder: ({ editor, node }) => {
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
const shouldHidePlaceholder =
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
if (shouldHidePlaceholder) return "";
return "Press '/' for commands...";
},
includeChildren: true,
}),
];

View File

@ -0,0 +1,33 @@
import ImageExt from "@tiptap/extension-image";
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
export const ImageExtensionWithoutProps = () =>
ImageExt.extend({
addKeyboardShortcuts() {
return {
ArrowDown: insertLineBelowImageAction,
ArrowUp: insertLineAboveImageAction,
};
},
// storage to keep track of image states Map<src, isDeleted>
addStorage() {
return {
images: new Map<string, boolean>(),
uploadInProgress: false,
};
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
};
},
});

View File

@ -0,0 +1,79 @@
import { CustomMention } from "./custom";
import { ReactRenderer } from "@tiptap/react";
import { Editor } from "@tiptap/core";
import tippy from "tippy.js";
import { MentionList } from "./mention-list";
export const MentionsWithoutProps = () =>
CustomMention.configure({
HTMLAttributes: {
class: "mention",
},
// mentionHighlights: mentionHighlights,
suggestion: {
// @ts-expect-error - Tiptap types are incorrect
render: () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
if (!props.clientRect) {
return;
}
component = new ReactRenderer(MentionList, {
props: { ...props },
editor: props.editor,
});
props.editor.storage.mentionsOpen = true;
// @ts-expect-error - Tippy types are incorrect
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"),
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
if (!props.clientRect) {
return;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
if (navigationKeys.includes(props.event.key)) {
// @ts-expect-error - Tippy types are incorrect
component?.ref?.onKeyDown(props);
event?.stopPropagation();
return true;
}
return false;
},
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
props.editor.storage.mentionsOpen = false;
popup?.[0].destroy();
component?.destroy();
},
};
},
},
});

View File

@ -34,12 +34,17 @@
"@plane/ui": "*", "@plane/ui": "*",
"@tippyjs/react": "^4.2.6", "@tippyjs/react": "^4.2.6",
"@tiptap/core": "^2.1.13", "@tiptap/core": "^2.1.13",
"@tiptap/extension-collaboration": "^2.3.2",
"@tiptap/pm": "^2.1.13", "@tiptap/pm": "^2.1.13",
"@tiptap/suggestion": "^2.1.13", "@tiptap/suggestion": "^2.1.13",
"lucide-react": "^0.378.0", "lucide-react": "^0.378.0",
"react-popper": "^2.3.0", "react-popper": "^2.3.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"uuid": "^9.0.1" "uuid": "^9.0.1",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.5",
"y-protocols": "^1.0.6",
"yjs": "^13.6.15"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "18.15.3", "@types/node": "18.15.3",

View File

@ -0,0 +1,85 @@
import { useEffect, useLayoutEffect, useMemo } from "react";
import { EditorProps } from "@tiptap/pm/view";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
// editor-core
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TFileHandler, useEditor } from "@plane/editor-core";
// custom provider
import { CollaborationProvider } from "src/providers/collaboration-provider";
// extensions
import { DocumentEditorExtensions } from "src/ui/extensions";
type DocumentEditorProps = {
id: string;
fileHandler: TFileHandler;
value: Uint8Array;
editorClassName: string;
onChange: (updates: Uint8Array) => void;
editorProps?: EditorProps;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions?: () => Promise<IMentionSuggestion[]>;
};
handleEditorReady?: (value: boolean) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
setHideDragHandleFunction: (hideDragHandlerFromDragDrop: () => void) => void;
tabIndex?: number;
};
export const useDocumentEditor = ({
id,
editorProps = {},
value,
editorClassName,
fileHandler,
onChange,
forwardedRef,
tabIndex,
handleEditorReady,
mentionHandler,
placeholder,
setHideDragHandleFunction,
}: DocumentEditorProps) => {
const provider = useMemo(
() =>
new CollaborationProvider({
name: id,
onChange,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[id]
);
// update document on value change
useEffect(() => {
if (value.byteLength > 0) Y.applyUpdate(provider.document, value);
}, [value, provider.document]);
// indexedDB provider
useLayoutEffect(() => {
const localProvider = new IndexeddbPersistence(id, provider.document);
return () => {
localProvider?.destroy();
};
}, [provider, id]);
const editor = useEditor({
id,
editorProps,
editorClassName,
fileHandler,
handleEditorReady,
forwardedRef,
mentionHandler,
extensions: DocumentEditorExtensions({
uploadFile: fileHandler.upload,
setHideDragHandle: setHideDragHandleFunction,
provider,
}),
placeholder,
tabIndex,
});
return editor;
};

View File

@ -3,6 +3,8 @@ export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "src/ui/re
// hooks // hooks
export { useEditorMarkings } from "src/hooks/use-editor-markings"; export { useEditorMarkings } from "src/hooks/use-editor-markings";
// utils
export { proseMirrorJSONToBinaryString, applyUpdates, mergeUpdates } from "src/utils/yjs";
export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core"; export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core";

View File

@ -0,0 +1,60 @@
import * as Y from "yjs";
export interface CompleteCollaboratorProviderConfiguration {
/**
* The identifier/name of your document
*/
name: string;
/**
* The actual Y.js document
*/
document: Y.Doc;
/**
* onChange callback
*/
onChange: (updates: Uint8Array) => void;
}
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
Partial<CompleteCollaboratorProviderConfiguration>;
export class CollaborationProvider {
public configuration: CompleteCollaboratorProviderConfiguration = {
name: "",
// @ts-expect-error cannot be undefined
document: undefined,
onChange: () => {},
};
constructor(configuration: CollaborationProviderConfiguration) {
this.setConfiguration(configuration);
this.configuration.document = configuration.document ?? new Y.Doc();
this.document.on("update", this.documentUpdateHandler.bind(this));
this.document.on("destroy", this.documentDestroyHandler.bind(this));
}
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
this.configuration = {
...this.configuration,
...configuration,
};
}
get document() {
return this.configuration.document;
}
documentUpdateHandler(update: Uint8Array, origin: any) {
// return if the update is from the provider itself
if (origin === this) return;
// call onChange with the update
this.configuration.onChange?.(update);
}
documentDestroyHandler() {
this.document.off("update", this.documentUpdateHandler);
this.document.off("destroy", this.documentDestroyHandler);
}
}

View File

@ -2,14 +2,20 @@ import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-wi
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions"; import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
import { UploadImage } from "@plane/editor-core"; import { UploadImage } from "@plane/editor-core";
import { CollaborationProvider } from "src/providers/collaboration-provider";
import Collaboration from "@tiptap/extension-collaboration";
type TArguments = { type TArguments = {
uploadFile: UploadImage; uploadFile: UploadImage;
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
provider: CollaborationProvider;
}; };
export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle }: TArguments) => [ export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle, provider }: TArguments) => [
SlashCommand(uploadFile), SlashCommand(uploadFile),
DragAndDrop(setHideDragHandle), DragAndDrop(setHideDragHandle),
IssueWidgetPlaceholder(), IssueWidgetPlaceholder(),
Collaboration.configure({
document: provider.document,
}),
]; ];

View File

@ -1,30 +1,25 @@
import React, { useState } from "react"; import React, { useState } from "react";
// editor-core
import { import {
UploadImage,
DeleteImage,
RestoreImage,
getEditorClassNames, getEditorClassNames,
useEditor,
EditorRefApi, EditorRefApi,
IMentionHighlight, IMentionHighlight,
IMentionSuggestion, IMentionSuggestion,
TFileHandler,
} from "@plane/editor-core"; } from "@plane/editor-core";
import { DocumentEditorExtensions } from "src/ui/extensions"; // components
import { PageRenderer } from "src/ui/components/page-renderer"; import { PageRenderer } from "src/ui/components/page-renderer";
// hooks
import { useDocumentEditor } from "src/hooks/use-document-editor";
interface IDocumentEditor { interface IDocumentEditor {
initialValue: string; id: string;
value?: string; value: Uint8Array;
fileHandler: { fileHandler: TFileHandler;
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
handleEditorReady?: (value: boolean) => void; handleEditorReady?: (value: boolean) => void;
containerClassName?: string; containerClassName?: string;
editorClassName?: string; editorClassName?: string;
onChange: (json: object, html: string) => void; onChange: (updates: Uint8Array) => void;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>; forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
mentionHandler: { mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>; highlights: () => Promise<IMentionHighlight[]>;
@ -37,7 +32,7 @@ interface IDocumentEditor {
const DocumentEditor = (props: IDocumentEditor) => { const DocumentEditor = (props: IDocumentEditor) => {
const { const {
onChange, onChange,
initialValue, id,
value, value,
fileHandler, fileHandler,
containerClassName, containerClassName,
@ -50,32 +45,24 @@ const DocumentEditor = (props: IDocumentEditor) => {
} = props; } = props;
// states // states
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {}); const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin // this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
// loads such that we can invoke it from react when the cursor leaves the container // loads such that we can invoke it from react when the cursor leaves the container
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => { const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop); setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
}; };
// use editor
const editor = useEditor({ // use document editor
onChange(json, html) { const editor = useDocumentEditor({
onChange(json, html); id,
},
editorClassName, editorClassName,
restoreFile: fileHandler.restore, fileHandler,
uploadFile: fileHandler.upload,
deleteFile: fileHandler.delete,
cancelUploadImage: fileHandler.cancel,
initialValue,
value, value,
onChange,
handleEditorReady, handleEditorReady,
forwardedRef, forwardedRef,
mentionHandler, mentionHandler,
extensions: DocumentEditorExtensions({
uploadFile: fileHandler.upload,
setHideDragHandle: setHideDragHandleFunction,
}),
placeholder, placeholder,
setHideDragHandleFunction,
tabIndex, tabIndex,
}); });

View File

@ -0,0 +1,76 @@
import { Schema } from "@tiptap/pm/model";
import { prosemirrorJSONToYDoc } from "y-prosemirror";
import * as Y from "yjs";
const defaultSchema: Schema = new Schema({
nodes: {
text: {},
doc: { content: "text*" },
},
});
/**
* @description converts ProseMirror JSON to Yjs document
* @param document prosemirror JSON
* @param fieldName
* @param schema
* @returns {Y.Doc} Yjs document
*/
export const proseMirrorJSONToBinaryString = (
document: any,
fieldName: string | Array<string> = "default",
schema?: Schema
): string => {
if (!document) {
throw new Error(
`You've passed an empty or invalid document to the Transformer. Make sure to pass ProseMirror-compatible JSON. Actually passed JSON: ${document}`
);
}
// allow a single field name
if (typeof fieldName === "string") {
const yDoc = prosemirrorJSONToYDoc(schema ?? defaultSchema, document, fieldName);
const docAsUint8Array = Y.encodeStateAsUpdate(yDoc);
const base64Doc = Buffer.from(docAsUint8Array).toString("base64");
return base64Doc;
}
const yDoc = new Y.Doc();
fieldName.forEach((field) => {
const update = Y.encodeStateAsUpdate(prosemirrorJSONToYDoc(schema ?? defaultSchema, document, field));
Y.applyUpdate(yDoc, update);
});
const docAsUint8Array = Y.encodeStateAsUpdate(yDoc);
const base64Doc = Buffer.from(docAsUint8Array).toString("base64");
return base64Doc;
};
/**
* @description apply updates to a doc and return the updated doc in base64(binary) format
* @param {Uint8Array} document
* @param {Uint8Array} updates
* @returns {string} base64(binary) form of the updated doc
*/
export const applyUpdates = (document: Uint8Array, updates: Uint8Array): string => {
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, document);
Y.applyUpdate(yDoc, updates);
const encodedDoc = Y.encodeStateAsUpdate(yDoc);
const base64Updates = Buffer.from(encodedDoc).toString("base64");
return base64Updates;
};
/**
* @description merge multiple updates into one single update
* @param {Uint8Array[]} updates
* @returns {Uint8Array} merged updates
*/
export const mergeUpdates = (updates: Uint8Array[]): Uint8Array => {
const mergedUpdates = Y.mergeUpdates(updates);
return mergedUpdates;
};

View File

@ -1,27 +1,22 @@
import * as React from "react"; import * as React from "react";
// editor-core
import { import {
UploadImage,
DeleteImage,
IMentionSuggestion, IMentionSuggestion,
RestoreImage,
EditorContainer, EditorContainer,
EditorContentWrapper, EditorContentWrapper,
getEditorClassNames, getEditorClassNames,
useEditor, useEditor,
IMentionHighlight, IMentionHighlight,
EditorRefApi, EditorRefApi,
TFileHandler,
} from "@plane/editor-core"; } from "@plane/editor-core";
// extensions
import { LiteTextEditorExtensions } from "src/ui/extensions"; import { LiteTextEditorExtensions } from "src/ui/extensions";
export interface ILiteTextEditor { export interface ILiteTextEditor {
initialValue: string; initialValue: string;
value?: string | null; value?: string | null;
fileHandler: { fileHandler: TFileHandler;
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
containerClassName?: string; containerClassName?: string;
editorClassName?: string; editorClassName?: string;
onChange?: (json: object, html: string) => void; onChange?: (json: object, html: string) => void;
@ -58,10 +53,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
value, value,
id, id,
editorClassName, editorClassName,
restoreFile: fileHandler.restore, fileHandler,
uploadFile: fileHandler.upload,
deleteFile: fileHandler.delete,
cancelUploadImage: fileHandler.cancel,
forwardedRef, forwardedRef,
extensions: LiteTextEditorExtensions(onEnterKeyPress), extensions: LiteTextEditorExtensions(onEnterKeyPress),
mentionHandler, mentionHandler,

View File

@ -0,0 +1,25 @@
import { Extension } from "@tiptap/core";
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
Extension.create({
name: "enterKey",
addKeyboardShortcuts(this) {
return {
Enter: () => {
if (onEnterKeyPress) {
onEnterKeyPress();
}
return true;
},
"Shift-Enter": ({ editor }) =>
editor.commands.first(({ commands }) => [
() => commands.newlineInCode(),
() => commands.splitListItem("listItem"),
() => commands.createParagraphNear(),
() => commands.liftEmptyBlock(),
() => commands.splitBlock(),
]),
};
},
});

View File

@ -1,13 +1,21 @@
import { UploadImage } from "@plane/editor-core"; import { UploadImage } from "@plane/editor-core";
import { DragAndDrop, SlashCommand } from "@plane/editor-extensions"; import { DragAndDrop, SlashCommand } from "@plane/editor-extensions";
import { EnterKeyExtension } from "./enter-key-extension";
type TArguments = { type TArguments = {
uploadFile: UploadImage; uploadFile: UploadImage;
dragDropEnabled?: boolean; dragDropEnabled?: boolean;
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
onEnterKeyPress?: () => void;
}; };
export const RichTextEditorExtensions = ({ uploadFile, dragDropEnabled, setHideDragHandle }: TArguments) => [ export const RichTextEditorExtensions = ({
uploadFile,
dragDropEnabled,
setHideDragHandle,
onEnterKeyPress,
}: TArguments) => [
SlashCommand(uploadFile), SlashCommand(uploadFile),
dragDropEnabled === true && DragAndDrop(setHideDragHandle), dragDropEnabled === true && DragAndDrop(setHideDragHandle),
EnterKeyExtension(onEnterKeyPress),
]; ];

View File

@ -1,30 +1,26 @@
"use client"; "use client";
import * as React from "react";
// editor-core
import { import {
DeleteImage,
EditorContainer, EditorContainer,
EditorContentWrapper, EditorContentWrapper,
getEditorClassNames, getEditorClassNames,
IMentionHighlight, IMentionHighlight,
IMentionSuggestion, IMentionSuggestion,
RestoreImage,
UploadImage,
useEditor, useEditor,
EditorRefApi, EditorRefApi,
TFileHandler,
} from "@plane/editor-core"; } from "@plane/editor-core";
import * as React from "react"; // extensions
import { RichTextEditorExtensions } from "src/ui/extensions"; import { RichTextEditorExtensions } from "src/ui/extensions";
// components
import { EditorBubbleMenu } from "src/ui/menus/bubble-menu"; import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
export type IRichTextEditor = { export type IRichTextEditor = {
initialValue: string; initialValue: string;
value?: string | null; value?: string | null;
dragDropEnabled?: boolean; dragDropEnabled?: boolean;
fileHandler: { fileHandler: TFileHandler;
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
id?: string; id?: string;
containerClassName?: string; containerClassName?: string;
editorClassName?: string; editorClassName?: string;
@ -37,6 +33,7 @@ export type IRichTextEditor = {
}; };
placeholder?: string | ((isFocused: boolean, value: string) => string); placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number; tabIndex?: number;
onEnterKeyPress?: (e?: any) => void;
}; };
const RichTextEditor = (props: IRichTextEditor) => { const RichTextEditor = (props: IRichTextEditor) => {
@ -54,6 +51,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
placeholder, placeholder,
tabIndex, tabIndex,
mentionHandler, mentionHandler,
onEnterKeyPress,
} = props; } = props;
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {}); const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
@ -67,10 +65,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
const editor = useEditor({ const editor = useEditor({
id, id,
editorClassName, editorClassName,
restoreFile: fileHandler.restore, fileHandler,
uploadFile: fileHandler.upload,
deleteFile: fileHandler.delete,
cancelUploadImage: fileHandler.cancel,
onChange, onChange,
initialValue, initialValue,
value, value,
@ -80,6 +75,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
uploadFile: fileHandler.upload, uploadFile: fileHandler.upload,
dragDropEnabled, dragDropEnabled,
setHideDragHandle: setHideDragHandleFunction, setHideDragHandle: setHideDragHandleFunction,
onEnterKeyPress,
}), }),
tabIndex, tabIndex,
mentionHandler, mentionHandler,

View File

@ -19,4 +19,3 @@ export * from "./priority-icon";
export * from "./related-icon"; export * from "./related-icon";
export * from "./side-panel-icon"; export * from "./side-panel-icon";
export * from "./transfer-icon"; export * from "./transfer-icon";
export * from "./user-group-icon";

View File

@ -1,35 +0,0 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const UserGroupIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg
viewBox="0 0 24 24"
className={`${className} stroke-2`}
stroke="currentColor"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<path
d="M18 19C18 17.4087 17.3679 15.8826 16.2426 14.7574C15.1174 13.6321 13.5913 13 12 13C10.4087 13 8.88258 13.6321 7.75736 14.7574C6.63214 15.8826 6 17.4087 6 19"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 13C14.2091 13 16 11.2091 16 9C16 6.79086 14.2091 5 12 5C9.79086 5 8 6.79086 8 9C8 11.2091 9.79086 13 12 13Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M23 18C23 16.636 22.4732 15.3279 21.5355 14.3635C20.5979 13.399 19.3261 12.8571 18 12.8571C18.8841 12.8571 19.7319 12.4959 20.357 11.8529C20.9821 11.21 21.3333 10.3379 21.3333 9.42857C21.3333 8.51926 20.9821 7.64719 20.357 7.00421C19.7319 6.36122 18.8841 6 18 6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M1 18C1 16.636 1.52678 15.3279 2.46447 14.3635C3.40215 13.399 4.67392 12.8571 6 12.8571C5.11595 12.8571 4.2681 12.4959 3.64298 11.8529C3.01786 11.21 2.66667 10.3379 2.66667 9.42857C2.66667 8.51926 3.01786 7.64719 3.64298 7.00421C4.2681 6.36122 5.11595 6 6 6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);

3
space/.gitignore vendored
View File

@ -37,3 +37,6 @@ next-env.d.ts
# env # env
.env .env
# Sentry Config File
.env.sentry-build-plugin

9
space/instrumentation.ts Normal file
View File

@ -0,0 +1,9 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}

View File

@ -28,12 +28,46 @@ const nextConfig = {
}, },
}; };
if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0", 10)) {
module.exports = withSentryConfig( const sentryConfig = {
nextConfig, // For all available options, see:
{ silent: true, authToken: process.env.SENTRY_AUTH_TOKEN }, // https://github.com/getsentry/sentry-webpack-plugin#options
{ hideSourceMaps: true }
); org: process.env.SENTRY_ORG_ID || "plane-hq",
project: process.env.SENTRY_PROJECT_ID || "plane-space",
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: true,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: "/monitoring",
// Hides source maps from generated client bundles
hideSourceMaps: true,
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
// See the following for more information:
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
automaticVercelMonitors: true,
}
if (parseInt(process.env.SENTRY_MONITORING_ENABLED || "0", 10)) {
module.exports = withSentryConfig(nextConfig, sentryConfig);
} else { } else {
module.exports = nextConfig; module.exports = nextConfig;
} }

View File

@ -23,7 +23,7 @@
"@plane/rich-text-editor": "*", "@plane/rich-text-editor": "*",
"@plane/types": "*", "@plane/types": "*",
"@plane/ui": "*", "@plane/ui": "*",
"@sentry/nextjs": "^7.108.0", "@sentry/nextjs": "^8",
"axios": "^1.3.4", "axios": "^1.3.4",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"dompurify": "^3.0.11", "dompurify": "^3.0.11",

View File

@ -1,18 +0,0 @@
// This file configures the initialization of Sentry on the browser.
// The config you add here will be used whenever a page is visited.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development",
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});

View File

@ -0,0 +1,31 @@
// This file configures the initialization of Sentry on the client.
// The config you add here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development",
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
replaysOnErrorSampleRate: 1.0,
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
integrations: [
Sentry.replayIntegration({
// Additional Replay configuration goes in here, for example:
maskAllText: true,
blockAllMedia: true,
}),
],
});

View File

@ -1,18 +0,0 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever middleware or an Edge route handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development",
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});

View File

@ -0,0 +1,17 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development",
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View File

@ -4,15 +4,16 @@
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({ Sentry.init({
dsn: SENTRY_DSN, dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development", environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development",
// Adjust this value in production, or use tracesSampler for greater control // Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0, tracesSampleRate: 1,
// ...
// Note: if you want to override the automatic release value, do not set a // Setting this option to true will print useful information to the console while you're setting up Sentry.
// `release` value here - use the environment variable `SENTRY_RELEASE`, so debug: false,
// that it will also get attached to your source maps
// Uncomment the line below to enable Spotlight (https://spotlightjs.com)
// spotlight: process.env.NODE_ENV === 'development',
}); });

View File

@ -8,10 +8,6 @@
"NEXT_PUBLIC_SPACE_BASE_URL", "NEXT_PUBLIC_SPACE_BASE_URL",
"NEXT_PUBLIC_SPACE_BASE_PATH", "NEXT_PUBLIC_SPACE_BASE_PATH",
"NEXT_PUBLIC_WEB_BASE_URL", "NEXT_PUBLIC_WEB_BASE_URL",
"NEXT_PUBLIC_SENTRY_DSN",
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
"NEXT_PUBLIC_ENABLE_SENTRY",
"NEXT_PUBLIC_TRACK_EVENTS",
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN", "NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
"NEXT_PUBLIC_CRISP_ID", "NEXT_PUBLIC_CRISP_ID",
"NEXT_PUBLIC_ENABLE_SESSION_RECORDER", "NEXT_PUBLIC_ENABLE_SESSION_RECORDER",
@ -21,7 +17,12 @@
"NEXT_PUBLIC_POSTHOG_HOST", "NEXT_PUBLIC_POSTHOG_HOST",
"NEXT_PUBLIC_POSTHOG_DEBUG", "NEXT_PUBLIC_POSTHOG_DEBUG",
"NEXT_PUBLIC_SUPPORT_EMAIL", "NEXT_PUBLIC_SUPPORT_EMAIL",
"SENTRY_AUTH_TOKEN" "SENTRY_AUTH_TOKEN",
"SENTRY_ORG_ID",
"SENTRY_PROJECT_ID",
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
"NEXT_PUBLIC_SENTRY_DSN",
"SENTRY_MONITORING_ENABLED"
], ],
"pipeline": { "pipeline": {
"build": { "build": {

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { Fragment } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
import { ICycle, IModule, IProject } from "@plane/types"; import { ICycle, IModule, IProject } from "@plane/types";
@ -20,20 +20,21 @@ export const ProjectAnalyticsModalMainContent: React.FC<Props> = observer((props
return ( return (
<Tab.Group as={React.Fragment}> <Tab.Group as={React.Fragment}>
<Tab.List as="div" className="flex space-x-2 border-b border-custom-border-200 px-0 md:px-5 py-0 md:py-3"> <Tab.List as="div" className="flex space-x-2 border-b h-[50px] border-custom-border-200 px-0 md:px-3">
{ANALYTICS_TABS.map((tab) => ( {ANALYTICS_TABS.map((tab) => (
<Tab <Tab key={tab.key} as={Fragment}>
key={tab.key} {({ selected }) => (
className={({ selected }) => <button
`rounded-0 w-full md:w-max md:rounded-3xl border-b md:border border-custom-border-200 focus:outline-none px-0 md:px-4 py-2 text-xs hover:bg-custom-background-80 ${ className={`text-sm group relative flex items-center gap-1 h-[50px] px-3 cursor-pointer transition-all font-medium outline-none ${
selected selected ? "text-custom-primary-100 " : "hover:text-custom-text-200"
? "border-custom-primary-100 text-custom-primary-100 md:bg-custom-background-80 md:text-custom-text-200 md:border-custom-border-200" }`}
: "border-transparent" >
}` {tab.title}
} <div
onClick={() => {}} className={`border absolute bottom-0 right-0 left-0 rounded-t-md ${selected ? "border-custom-primary-100" : "border-transparent group-hover:border-custom-border-200"}`}
> />
{tab.title} </button>
)}
</Tab> </Tab>
))} ))}
</Tab.List> </Tab.List>

View File

@ -1,10 +1,10 @@
import { Command } from "cmdk"; import { Command } from "cmdk";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2 } from "lucide-react"; import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users } from "lucide-react";
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
// hooks // hooks
import { DoubleCircleIcon, UserGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; import { DoubleCircleIcon, TOAST_TYPE, setToast } from "@plane/ui";
// constants // constants
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
// helpers // helpers
@ -115,7 +115,7 @@ export const CommandPaletteIssueActions: React.FC<Props> = observer((props) => {
className="focus:outline-none" className="focus:outline-none"
> >
<div className="flex items-center gap-2 text-custom-text-200"> <div className="flex items-center gap-2 text-custom-text-200">
<UserGroupIcon className="h-3.5 w-3.5" /> <Users className="h-3.5 w-3.5" />
Assign to... Assign to...
</div> </div>
</Command.Item> </Command.Item>

View File

@ -514,7 +514,7 @@ const activityDetails: {
name: { name: {
message: (activity, showIssue) => ( message: (activity, showIssue) => (
<> <>
set the name to <span className="break-all">{activity.new_value}</span> set the title to <span className="break-all">{activity.new_value}</span>
{showIssue && ( {showIssue && (
<> <>
{" "} {" "}

View File

@ -2,6 +2,8 @@ import React, { FC } from "react";
import Link from "next/link"; import Link from "next/link";
// ui // ui
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
interface IListItemProps { interface IListItemProps {
title: string; title: string;
@ -12,6 +14,7 @@ interface IListItemProps {
actionableItems?: JSX.Element; actionableItems?: JSX.Element;
isMobile?: boolean; isMobile?: boolean;
parentRef: React.RefObject<HTMLDivElement>; parentRef: React.RefObject<HTMLDivElement>;
className?: string;
} }
export const ListItem: FC<IListItemProps> = (props) => { export const ListItem: FC<IListItemProps> = (props) => {
@ -24,12 +27,18 @@ export const ListItem: FC<IListItemProps> = (props) => {
onItemClick, onItemClick,
isMobile = false, isMobile = false,
parentRef, parentRef,
className = "",
} = props; } = props;
return ( return (
<div ref={parentRef} className="relative"> <div ref={parentRef} className="relative">
<Link href={itemLink} onClick={onItemClick}> <Link href={itemLink} onClick={onItemClick}>
<div className="group h-24 sm:h-[52px] flex w-full flex-col items-center justify-between gap-3 sm:gap-5 px-6 py-4 sm:py-0 text-sm border-b border-custom-border-200 bg-custom-background-100 hover:bg-custom-background-90 sm:flex-row"> <div
className={cn(
"group h-24 sm:h-[52px] flex w-full flex-col items-center justify-between gap-3 sm:gap-5 px-6 py-4 sm:py-0 text-sm border-b border-custom-border-200 bg-custom-background-100 hover:bg-custom-background-90 sm:flex-row",
className
)}
>
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden"> <div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative flex w-full items-center gap-3 overflow-hidden"> <div className="relative flex w-full items-center gap-3 overflow-hidden">
<div className="flex items-center gap-4 truncate"> <div className="flex items-center gap-4 truncate">

View File

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react";
import Image from "next/image"; import Image from "next/image";
// headless ui // headless ui
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
@ -15,6 +15,7 @@ import {
// hooks // hooks
import { Avatar, StateGroupIcon } from "@plane/ui"; import { Avatar, StateGroupIcon } from "@plane/ui";
import { SingleProgressStats } from "@/components/core"; import { SingleProgressStats } from "@/components/core";
import { useProjectState } from "@/hooks/store";
import useLocalStorage from "@/hooks/use-local-storage"; import useLocalStorage from "@/hooks/use-local-storage";
// images // images
import emptyLabel from "public/empty-state/empty_label.svg"; import emptyLabel from "public/empty-state/empty_label.svg";
@ -44,20 +45,23 @@ type Props = {
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
}; };
export const SidebarProgressStats: React.FC<Props> = ({ export const SidebarProgressStats: React.FC<Props> = observer((props) => {
distribution, const {
groupedIssues, distribution,
totalIssues, groupedIssues,
module, totalIssues,
roundedTab, module,
noBackground, roundedTab,
isPeekView = false, noBackground,
isCompleted = false, isPeekView = false,
filters, isCompleted = false,
handleFiltersUpdate, filters,
}) => { handleFiltersUpdate,
} = props;
const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees"); const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees");
const { groupedProjectStates } = useProjectState();
const currentValue = (tab: string | null) => { const currentValue = (tab: string | null) => {
switch (tab) { switch (tab) {
case "Assignees": case "Assignees":
@ -71,6 +75,12 @@ export const SidebarProgressStats: React.FC<Props> = ({
} }
}; };
const getStateGroupState = (stateGroup: string) => {
const stateGroupStates = groupedProjectStates?.[stateGroup];
const stateGroupStatesId = stateGroupStates?.map((state) => state.id);
return stateGroupStatesId;
};
return ( return (
<Tab.Group <Tab.Group
defaultIndex={currentValue(tab)} defaultIndex={currentValue(tab)}
@ -261,10 +271,14 @@ export const SidebarProgressStats: React.FC<Props> = ({
} }
completed={groupedIssues[group]} completed={groupedIssues[group]}
total={totalIssues} total={totalIssues}
{...(!isPeekView &&
!isCompleted && {
onClick: () => handleFiltersUpdate("state", getStateGroupState(group) ?? []),
})}
/> />
))} ))}
</Tab.Panel> </Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
); );
}; });

View File

@ -1,4 +1,5 @@
import { FC } from "react"; import { FC } from "react";
import Link from "next/link";
// types // types
import { ICycle } from "@plane/types"; import { ICycle } from "@plane/types";
// components // components
@ -8,14 +9,19 @@ import { EmptyState } from "@/components/empty-state";
import { EmptyStateType } from "@/constants/empty-state"; import { EmptyStateType } from "@/constants/empty-state";
export type ActiveCycleProductivityProps = { export type ActiveCycleProductivityProps = {
workspaceSlug: string;
projectId: string;
cycle: ICycle; cycle: ICycle;
}; };
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props) => { export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props) => {
const { cycle } = props; const { workspaceSlug, projectId, cycle } = props;
return ( return (
<div className="flex flex-col justify-center min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg"> <Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}
className="flex flex-col justify-center min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg"
>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3> <h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3>
</div> </div>
@ -53,6 +59,6 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props)
</div> </div>
</> </>
)} )}
</div> </Link>
); );
}; };

View File

@ -1,4 +1,5 @@
import { FC } from "react"; import { FC } from "react";
import Link from "next/link";
// types // types
import { ICycle } from "@plane/types"; import { ICycle } from "@plane/types";
// ui // ui
@ -10,11 +11,13 @@ import { CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle";
import { EmptyStateType } from "@/constants/empty-state"; import { EmptyStateType } from "@/constants/empty-state";
export type ActiveCycleProgressProps = { export type ActiveCycleProgressProps = {
workspaceSlug: string;
projectId: string;
cycle: ICycle; cycle: ICycle;
}; };
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => { export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
const { cycle } = props; const { workspaceSlug, projectId, cycle } = props;
const progressIndicatorData = CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({ const progressIndicatorData = CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({
id: index, id: index,
@ -31,7 +34,10 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
}; };
return ( return (
<div className="flex flex-col min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg"> <Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}
className="flex flex-col min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg"
>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<h3 className="text-base text-custom-text-300 font-semibold">Progress</h3> <h3 className="text-base text-custom-text-300 font-semibold">Progress</h3>
@ -85,6 +91,6 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_PROGRESS_EMPTY_STATE} layout="screen-simple" size="sm" /> <EmptyState type={EmptyStateType.ACTIVE_CYCLE_PROGRESS_EMPTY_STATE} layout="screen-simple" size="sm" />
</div> </div>
)} )}
</div> </Link>
); );
}; };

View File

@ -62,13 +62,18 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
cycleId={currentProjectActiveCycleId} cycleId={currentProjectActiveCycleId}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
className="!border-b-transparent"
/> />
)} )}
<div className="bg-custom-background-90 py-6 px-8"> <div className="bg-custom-background-100 pt-3 pb-6 px-6">
<div className="grid grid-cols-1 bg-custom-background-90 gap-3 lg:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 bg-custom-background-100 gap-3 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress cycle={activeCycle} /> <ActiveCycleProgress workspaceSlug={workspaceSlug} projectId={projectId} cycle={activeCycle} />
<ActiveCycleProductivity cycle={activeCycle} /> <ActiveCycleProductivity
<ActiveCycleStats cycle={activeCycle} workspaceSlug={workspaceSlug} projectId={projectId} /> workspaceSlug={workspaceSlug}
projectId={projectId}
cycle={activeCycle}
/>
<ActiveCycleStats workspaceSlug={workspaceSlug} projectId={projectId} cycle={activeCycle} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@ import { useRef } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { User2 } from "lucide-react"; import { Users } from "lucide-react";
// ui // ui
import { Avatar, AvatarGroup, setPromiseToast } from "@plane/ui"; import { Avatar, AvatarGroup, setPromiseToast } from "@plane/ui";
// components // components
@ -112,9 +112,7 @@ export const UpcomingCycleListItem: React.FC<Props> = observer((props) => {
})} })}
</AvatarGroup> </AvatarGroup>
) : ( ) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80"> <Users className="h-4 w-4 text-custom-text-300" />
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)} )}
<FavoriteStar <FavoriteStar

View File

@ -1,6 +1,6 @@
import React, { FC, MouseEvent } from "react"; import React, { FC, MouseEvent } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { CalendarCheck2, CalendarClock, MoveRight, User2 } from "lucide-react"; import { CalendarCheck2, CalendarClock, MoveRight, Users } from "lucide-react";
// types // types
import { ICycle, TCycleGroups } from "@plane/types"; import { ICycle, TCycleGroups } from "@plane/types";
// ui // ui
@ -146,9 +146,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
})} })}
</AvatarGroup> </AvatarGroup>
) : ( ) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80"> <Users className="h-4 w-4 text-custom-text-300" />
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)} )}
</div> </div>
</Tooltip> </Tooltip>

View File

@ -22,10 +22,11 @@ type TCyclesListItem = {
handleRemoveFromFavorites?: () => void; handleRemoveFromFavorites?: () => void;
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
className?: string;
}; };
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => { export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props; const { cycleId, workspaceSlug, projectId, className = "" } = props;
// refs // refs
const parentRef = useRef(null); const parentRef = useRef(null);
// router // router
@ -83,6 +84,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
onItemClick={(e) => { onItemClick={(e) => {
if (cycleDetails.archived_at) openCycleOverview(e); if (cycleDetails.archived_at) openCycleOverview(e);
}} }}
className={className}
prependTitleElement={ prependTitleElement={
<CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}> <CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
{isCompleted ? ( {isCompleted ? (

View File

@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
import isEqual from "lodash/isEqual";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
@ -9,10 +10,10 @@ import {
ChevronDown, ChevronDown,
LinkIcon, LinkIcon,
Trash2, Trash2,
UserCircle2,
AlertCircle, AlertCircle,
ChevronRight, ChevronRight,
CalendarClock, CalendarClock,
SquareUser,
} from "lucide-react"; } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
// types // types
@ -199,14 +200,18 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const handleFiltersUpdate = useCallback( const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => { (key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
const newValues = issueFilters?.filters?.[key] ?? []; let newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom if (key === "state") {
value.forEach((val) => { if (isEqual(newValues, value)) newValues = [];
if (!newValues.includes(val)) newValues.push(val); else newValues = value;
else newValues.splice(newValues.indexOf(val), 1); } else {
}); value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
}
} else { } else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value); else newValues.push(value);
@ -427,7 +432,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="flex items-center justify-start gap-1"> <div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300"> <div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<UserCircle2 className="h-4 w-4" /> <SquareUser className="h-4 w-4" />
<span className="text-base">Lead</span> <span className="text-base">Lead</span>
</div> </div>
<div className="flex w-3/5 items-center rounded-sm"> <div className="flex w-3/5 items-center rounded-sm">

View File

@ -1,16 +1,19 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// hooks // icons
import { Avatar, AvatarGroup, UserGroupIcon } from "@plane/ui"; import { LucideIcon, Users } from "lucide-react";
import { useMember } from "@/hooks/store";
// ui // ui
import { Avatar, AvatarGroup } from "@plane/ui";
// hooks
import { useMember } from "@/hooks/store";
type AvatarProps = { type AvatarProps = {
showTooltip: boolean; showTooltip: boolean;
userIds: string | string[] | null; userIds: string | string[] | null;
icon?: LucideIcon;
}; };
export const ButtonAvatars: React.FC<AvatarProps> = observer((props) => { export const ButtonAvatars: React.FC<AvatarProps> = observer((props) => {
const { showTooltip, userIds } = props; const { showTooltip, userIds, icon: Icon } = props;
// store hooks // store hooks
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
@ -33,5 +36,9 @@ export const ButtonAvatars: React.FC<AvatarProps> = observer((props) => {
} }
} }
return <UserGroupIcon className="h-3 w-3 flex-shrink-0" />; return Icon ? (
<Icon className="h-3 w-3 flex-shrink-0" />
) : (
<Users className="h-3 w-3 flex-shrink-0" />
);
}); });

View File

@ -1,6 +1,6 @@
import { Fragment, useRef, useState } from "react"; import { Fragment, useRef, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ChevronDown } from "lucide-react"; import { ChevronDown, LucideIcon } from "lucide-react";
// headless ui // headless ui
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
// helpers // helpers
@ -19,6 +19,7 @@ import { MemberDropdownProps } from "./types";
type Props = { type Props = {
projectId?: string; projectId?: string;
icon?: LucideIcon;
onClose?: () => void; onClose?: () => void;
} & MemberDropdownProps; } & MemberDropdownProps;
@ -43,6 +44,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
showTooltip = false, showTooltip = false,
tabIndex, tabIndex,
value, value,
icon,
} = props; } = props;
// states // states
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -115,7 +117,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
showTooltip={showTooltip} showTooltip={showTooltip}
variant={buttonVariant} variant={buttonVariant}
> >
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />} {!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} icon={icon} />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate text-xs leading-5"> <span className="flex-grow truncate text-xs leading-5">
{Array.isArray(value) && value.length > 0 {Array.isArray(value) && value.length > 0

View File

@ -1,30 +1,26 @@
import { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { FileText } from "lucide-react"; import { FileText } from "lucide-react";
// hooks
// ui // ui
import { Breadcrumbs, Button } from "@plane/ui"; import { Breadcrumbs, Button } from "@plane/ui";
// helpers
import { BreadcrumbLink } from "@/components/common";
// components // components
import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project"; import { ProjectLogo } from "@/components/project";
import { useCommandPalette, usePage, useProject } from "@/hooks/store"; // hooks
import { usePage, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
export interface IPagesHeaderProps { export const PageDetailsHeader = observer(() => {
showButton?: boolean;
}
export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
const { showButton = false } = props;
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, pageId } = router.query; const { workspaceSlug, pageId } = router.query;
// store hooks // store hooks
const { toggleCreatePageModal } = useCommandPalette();
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
const { isContentEditable, isSubmitting, name } = usePage(pageId?.toString() ?? "");
const { name } = usePage(pageId?.toString() ?? ""); // use platform
const { platform } = usePlatformOS();
// derived values
const isMac = platform === "MacOS";
return ( return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4"> <div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
@ -77,12 +73,24 @@ export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
</Breadcrumbs> </Breadcrumbs>
</div> </div>
</div> </div>
{showButton && ( {isContentEditable && (
<div className="flex items-center gap-2"> <Button
<Button variant="primary" size="sm" onClick={() => toggleCreatePageModal(true)}> variant="primary"
Add Page size="sm"
</Button> onClick={() => {
</div> // ctrl/cmd + s to save the changes
const event = new KeyboardEvent("keydown", {
key: "s",
ctrlKey: !isMac,
metaKey: isMac,
});
window.dispatchEvent(event);
}}
className="flex-shrink-0"
loading={isSubmitting === "submitting"}
>
{isSubmitting === "submitting" ? "Saving" : "Save changes"}
</Button>
)} )}
</div> </div>
); );

View File

@ -1,9 +1,9 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CalendarCheck2, CopyPlus, Signal, Tag } from "lucide-react"; import { CalendarCheck2, CopyPlus, Signal, Tag, Users } from "lucide-react";
import { TInboxDuplicateIssueDetails, TIssue } from "@plane/types"; import { TInboxDuplicateIssueDetails, TIssue } from "@plane/types";
import { ControlLink, DoubleCircleIcon, Tooltip, UserGroupIcon } from "@plane/ui"; import { ControlLink, DoubleCircleIcon, Tooltip } from "@plane/ui";
// components // components
import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns"; import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns";
import { IssueLabel, TIssueOperations } from "@/components/issues"; import { IssueLabel, TIssueOperations } from "@/components/issues";
@ -64,7 +64,7 @@ export const InboxIssueContentProperties: React.FC<Props> = observer((props) =>
{/* Assignee */} {/* Assignee */}
<div className="flex h-8 items-center gap-2"> <div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> <div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<UserGroupIcon className="h-4 w-4 flex-shrink-0" /> <Users className="h-4 w-4 flex-shrink-0" />
<span>Assignees</span> <span>Assignees</span>
</div> </div>
<MemberDropdown <MemberDropdown

View File

@ -42,6 +42,7 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
const router = useRouter(); const router = useRouter();
// refs // refs
const descriptionEditorRef = useRef<EditorRefApi>(null); const descriptionEditorRef = useRef<EditorRefApi>(null);
const submitBtnRef = useRef<HTMLButtonElement | null>(null);
// hooks // hooks
const { captureIssueEvent } = useEventTracker(); const { captureIssueEvent } = useEventTracker();
const { createInboxIssue } = useProjectInbox(); const { createInboxIssue } = useProjectInbox();
@ -139,6 +140,7 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
handleData={handleFormData} handleData={handleFormData}
editorRef={descriptionEditorRef} editorRef={descriptionEditorRef}
containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]" containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]"
onEnterKeyPress={() => submitBtnRef?.current?.click()}
/> />
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} /> <InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} />
</div> </div>
@ -158,6 +160,7 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
ref={submitBtnRef}
size="sm" size="sm"
type="submit" type="submit"
loading={formSubmitting} loading={formSubmitting}

View File

@ -34,6 +34,7 @@ export const InboxIssueEditRoot: FC<TInboxIssueEditRoot> = observer((props) => {
const router = useRouter(); const router = useRouter();
// refs // refs
const descriptionEditorRef = useRef<EditorRefApi>(null); const descriptionEditorRef = useRef<EditorRefApi>(null);
const submitBtnRef = useRef<HTMLButtonElement | null>(null);
// store hooks // store hooks
const { captureIssueEvent } = useEventTracker(); const { captureIssueEvent } = useEventTracker();
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
@ -148,6 +149,7 @@ export const InboxIssueEditRoot: FC<TInboxIssueEditRoot> = observer((props) => {
handleData={handleFormData} handleData={handleFormData}
editorRef={descriptionEditorRef} editorRef={descriptionEditorRef}
containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]" containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]"
onEnterKeyPress={() => submitBtnRef?.current?.click()}
/> />
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} isVisible /> <InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} isVisible />
</div> </div>
@ -160,6 +162,7 @@ export const InboxIssueEditRoot: FC<TInboxIssueEditRoot> = observer((props) => {
variant="primary" variant="primary"
size="sm" size="sm"
type="button" type="button"
ref={submitBtnRef}
loading={formSubmitting} loading={formSubmitting}
disabled={isTitleLengthMoreThan255Character} disabled={isTitleLengthMoreThan255Character}
onClick={handleFormSubmit} onClick={handleFormSubmit}

View File

@ -18,11 +18,13 @@ type TInboxIssueDescription = {
data: Partial<TIssue>; data: Partial<TIssue>;
handleData: (issueKey: keyof Partial<TIssue>, issueValue: Partial<TIssue>[keyof Partial<TIssue>]) => void; handleData: (issueKey: keyof Partial<TIssue>, issueValue: Partial<TIssue>[keyof Partial<TIssue>]) => void;
editorRef: RefObject<EditorRefApi>; editorRef: RefObject<EditorRefApi>;
onEnterKeyPress?: (e?: any) => void;
}; };
// TODO: have to implement GPT Assistance // TODO: have to implement GPT Assistance
export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props) => { export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props) => {
const { containerClassName, workspaceSlug, projectId, workspaceId, data, handleData, editorRef } = props; const { containerClassName, workspaceSlug, projectId, workspaceId, data, handleData, editorRef, onEnterKeyPress } =
props;
// hooks // hooks
const { loader } = useProjectInbox(); const { loader } = useProjectInbox();
@ -44,6 +46,7 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
onChange={(_description: object, description_html: string) => handleData("description_html", description_html)} onChange={(_description: object, description_html: string) => handleData("description_html", description_html)}
placeholder={getDescriptionPlaceholder} placeholder={getDescriptionPlaceholder}
containerClassName={containerClassName} containerClassName={containerClassName}
onEnterKeyPress={onEnterKeyPress}
/> />
); );
}); });

View File

@ -10,9 +10,9 @@ import useSWR, { mutate } from "swr";
// react-hook-form // react-hook-form
// services // services
// components // components
import { ArrowLeft, Check, List, Settings, UploadCloud } from "lucide-react"; import { ArrowLeft, Check, List, Settings, UploadCloud, Users } from "lucide-react";
import { IGithubRepoCollaborator, IGithubServiceImportFormData } from "@plane/types"; import { IGithubRepoCollaborator, IGithubServiceImportFormData } from "@plane/types";
import { UserGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; import { TOAST_TYPE, setToast } from "@plane/ui";
import { import {
GithubImportConfigure, GithubImportConfigure,
GithubImportData, GithubImportData,
@ -72,7 +72,7 @@ const integrationWorkflowData = [
{ {
title: "Users", title: "Users",
key: "import-users", key: "import-users",
icon: UserGroupIcon, icon: Users,
}, },
{ {
title: "Confirm", title: "Confirm",

View File

@ -5,12 +5,12 @@ import { useRouter } from "next/router";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { mutate } from "swr"; import { mutate } from "swr";
// icons // icons
import { ArrowLeft, Check, List, Settings } from "lucide-react"; import { ArrowLeft, Check, List, Settings, Users } from "lucide-react";
import { IJiraImporterForm } from "@plane/types"; import { IJiraImporterForm } from "@plane/types";
// services // services
// fetch keys // fetch keys
// components // components
import { Button, UserGroupIcon } from "@plane/ui"; import { Button } from "@plane/ui";
import { IMPORTER_SERVICES_LIST } from "@/constants/fetch-keys"; import { IMPORTER_SERVICES_LIST } from "@/constants/fetch-keys";
// assets // assets
import { JiraImporterService } from "@/services/integrations"; import { JiraImporterService } from "@/services/integrations";
@ -44,7 +44,7 @@ const integrationWorkflowData: Array<{
{ {
title: "Users", title: "Users",
key: "import-users", key: "import-users",
icon: UserGroupIcon, icon: Users,
}, },
{ {
title: "Confirm", title: "Confirm",

View File

@ -1,11 +1,11 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// hooks // icons
import { UserGroupIcon } from "@plane/ui"; import { Users } from "lucide-react";
// hooks;
import { useIssueDetail } from "@/hooks/store"; import { useIssueDetail } from "@/hooks/store";
// components // components
import { IssueActivityBlockComponent, IssueLink } from "./"; import { IssueActivityBlockComponent, IssueLink } from "./";
// icons
type TIssueAssigneeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; type TIssueAssigneeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
@ -21,7 +21,7 @@ export const IssueAssigneeActivity: FC<TIssueAssigneeActivity> = observer((props
if (!activity) return <></>; if (!activity) return <></>;
return ( return (
<IssueActivityBlockComponent <IssueActivityBlockComponent
icon={<UserGroupIcon className="h-4 w-4 flex-shrink-0" />} icon={<Users className="h-3 w-3 flex-shrink-0" />}
activityId={activityId} activityId={activityId}
ends={ends} ends={ends}
> >

View File

@ -103,7 +103,7 @@ export const IssueParentSelect: React.FC<TIssueParentSelect> = observer((props)
<div className="flex items-center gap-1 bg-green-500/20 text-green-700 rounded px-1.5 py-1"> <div className="flex items-center gap-1 bg-green-500/20 text-green-700 rounded px-1.5 py-1">
<Tooltip tooltipHeading="Title" tooltipContent={parentIssue.name} isMobile={isMobile}> <Tooltip tooltipHeading="Title" tooltipContent={parentIssue.name} isMobile={isMobile}>
<Link <Link
href={`/${workspaceSlug}/projects/${projectId}/issues/${parentIssue?.id}`} href={`/${workspaceSlug}/projects/${parentIssue.project_id}/issues/${parentIssue?.id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs font-medium" className="text-xs font-medium"

View File

@ -56,7 +56,7 @@ export const IssueParentDetail: FC<TIssueParentDetail> = observer((props) => {
Sibling issues Sibling issues
</div> </div>
<IssueParentSiblings currentIssue={issue} parentIssue={parentIssue} /> <IssueParentSiblings workspaceSlug={workspaceSlug} currentIssue={issue} parentIssue={parentIssue} />
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: null })} onClick={() => issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: null })}

View File

@ -1,4 +1,5 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
// ui // ui
import { CustomMenu, LayersIcon } from "@plane/ui"; import { CustomMenu, LayersIcon } from "@plane/ui";
@ -6,15 +7,15 @@ import { CustomMenu, LayersIcon } from "@plane/ui";
import { useIssueDetail, useProject } from "@/hooks/store"; import { useIssueDetail, useProject } from "@/hooks/store";
type TIssueParentSiblingItem = { type TIssueParentSiblingItem = {
workspaceSlug: string;
issueId: string; issueId: string;
}; };
export const IssueParentSiblingItem: FC<TIssueParentSiblingItem> = (props) => { export const IssueParentSiblingItem: FC<TIssueParentSiblingItem> = observer((props) => {
const { issueId } = props; const { workspaceSlug, issueId } = props;
// hooks // hooks
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const { const {
peekIssue,
issue: { getIssueById }, issue: { getIssueById },
} = useIssueDetail(); } = useIssueDetail();
@ -27,7 +28,7 @@ export const IssueParentSiblingItem: FC<TIssueParentSiblingItem> = (props) => {
<> <>
<CustomMenu.MenuItem key={issueDetail.id}> <CustomMenu.MenuItem key={issueDetail.id}>
<Link <Link
href={`/${peekIssue?.workspaceSlug}/projects/${issueDetail?.project_id as string}/issues/${issueDetail.id}`} href={`/${workspaceSlug}/projects/${issueDetail?.project_id as string}/issues/${issueDetail.id}`}
className="flex items-center gap-2 py-2" className="flex items-center gap-2 py-2"
> >
<LayersIcon className="h-4 w-4" /> <LayersIcon className="h-4 w-4" />
@ -36,4 +37,4 @@ export const IssueParentSiblingItem: FC<TIssueParentSiblingItem> = (props) => {
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
</> </>
); );
}; });

View File

@ -1,4 +1,5 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react";
import useSWR from "swr"; import useSWR from "swr";
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
// components // components
@ -8,25 +9,25 @@ import { useIssueDetail } from "@/hooks/store";
import { IssueParentSiblingItem } from "./sibling-item"; import { IssueParentSiblingItem } from "./sibling-item";
export type TIssueParentSiblings = { export type TIssueParentSiblings = {
workspaceSlug: string;
currentIssue: TIssue; currentIssue: TIssue;
parentIssue: TIssue; parentIssue: TIssue;
}; };
export const IssueParentSiblings: FC<TIssueParentSiblings> = (props) => { export const IssueParentSiblings: FC<TIssueParentSiblings> = observer((props) => {
const { currentIssue, parentIssue } = props; const { workspaceSlug, currentIssue, parentIssue } = props;
// hooks // hooks
const { const {
peekIssue,
fetchSubIssues, fetchSubIssues,
subIssues: { subIssuesByIssueId }, subIssues: { subIssuesByIssueId },
} = useIssueDetail(); } = useIssueDetail();
const { isLoading } = useSWR( const { isLoading } = useSWR(
peekIssue && parentIssue && parentIssue.project_id parentIssue && parentIssue.project_id
? `ISSUE_PARENT_CHILD_ISSUES_${peekIssue?.workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}` ? `ISSUE_PARENT_CHILD_ISSUES_${workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}`
: null, : null,
peekIssue && parentIssue && parentIssue.project_id parentIssue && parentIssue.project_id
? () => fetchSubIssues(peekIssue?.workspaceSlug, parentIssue.project_id, parentIssue.id) ? () => fetchSubIssues(workspaceSlug, parentIssue.project_id, parentIssue.id)
: null : null
); );
@ -40,7 +41,10 @@ export const IssueParentSiblings: FC<TIssueParentSiblings> = (props) => {
</div> </div>
) : subIssueIds && subIssueIds.length > 0 ? ( ) : subIssueIds && subIssueIds.length > 0 ? (
subIssueIds.map( subIssueIds.map(
(issueId) => currentIssue.id != issueId && <IssueParentSiblingItem key={issueId} issueId={issueId} /> (issueId) =>
currentIssue.id != issueId && (
<IssueParentSiblingItem key={issueId} workspaceSlug={workspaceSlug} issueId={issueId} />
)
) )
) : ( ) : (
<div className="flex items-center gap-2 whitespace-nowrap px-1 py-1 text-left text-xs text-custom-text-200"> <div className="flex items-center gap-2 whitespace-nowrap px-1 py-1 text-left text-xs text-custom-text-200">
@ -49,4 +53,4 @@ export const IssueParentSiblings: FC<TIssueParentSiblings> = (props) => {
)} )}
</div> </div>
); );
}; });

View File

@ -12,6 +12,7 @@ import {
Tag, Tag,
Trash2, Trash2,
Triangle, Triangle,
Users,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
// hooks // hooks
@ -24,7 +25,6 @@ import {
RelatedIcon, RelatedIcon,
TOAST_TYPE, TOAST_TYPE,
Tooltip, Tooltip,
UserGroupIcon,
setToast, setToast,
} from "@plane/ui"; } from "@plane/ui";
import { import {
@ -219,7 +219,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="flex h-8 items-center gap-2"> <div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300"> <div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<UserGroupIcon className="h-4 w-4 flex-shrink-0" /> <Users className="h-4 w-4 flex-shrink-0" />
<span>Assignees</span> <span>Assignees</span>
</div> </div>
<MemberDropdown <MemberDropdown

View File

@ -68,7 +68,6 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
? EmptyStateType.PROJECT_EMPTY_FILTER ? EmptyStateType.PROJECT_EMPTY_FILTER
: EmptyStateType.PROJECT_CYCLE_NO_ISSUES; : EmptyStateType.PROJECT_CYCLE_NO_ISSUES;
const additionalPath = isCompletedAndEmpty ? undefined : activeLayout ?? "list"; const additionalPath = isCompletedAndEmpty ? undefined : activeLayout ?? "list";
const emptyStateSize = isEmptyFilters ? "lg" : "sm";
return ( return (
<> <>
@ -84,7 +83,6 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
<EmptyState <EmptyState
type={emptyStateType} type={emptyStateType}
additionalPath={additionalPath} additionalPath={additionalPath}
size={emptyStateSize}
primaryButtonOnClick={ primaryButtonOnClick={
!isCompletedAndEmpty && !isEmptyFilters !isCompletedAndEmpty && !isEmptyFilters
? () => { ? () => {

View File

@ -41,14 +41,12 @@ export const ProjectDraftEmptyState: React.FC = observer(() => {
const emptyStateType = const emptyStateType =
issueFilterCount > 0 ? EmptyStateType.PROJECT_DRAFT_EMPTY_FILTER : EmptyStateType.PROJECT_DRAFT_NO_ISSUES; issueFilterCount > 0 ? EmptyStateType.PROJECT_DRAFT_EMPTY_FILTER : EmptyStateType.PROJECT_DRAFT_NO_ISSUES;
const additionalPath = issueFilterCount > 0 ? activeLayout ?? "list" : undefined; const additionalPath = issueFilterCount > 0 ? activeLayout ?? "list" : undefined;
const emptyStateSize = issueFilterCount > 0 ? "lg" : "sm";
return ( return (
<div className="relative h-full w-full overflow-y-auto"> <div className="relative h-full w-full overflow-y-auto">
<EmptyState <EmptyState
type={emptyStateType} type={emptyStateType}
additionalPath={additionalPath} additionalPath={additionalPath}
size={emptyStateSize}
secondaryButtonOnClick={issueFilterCount > 0 ? handleClearAllFilters : undefined} secondaryButtonOnClick={issueFilterCount > 0 ? handleClearAllFilters : undefined}
/> />
</div> </div>

View File

@ -43,14 +43,12 @@ export const ProjectEmptyState: React.FC = observer(() => {
const emptyStateType = issueFilterCount > 0 ? EmptyStateType.PROJECT_EMPTY_FILTER : EmptyStateType.PROJECT_NO_ISSUES; const emptyStateType = issueFilterCount > 0 ? EmptyStateType.PROJECT_EMPTY_FILTER : EmptyStateType.PROJECT_NO_ISSUES;
const additionalPath = issueFilterCount > 0 ? activeLayout ?? "list" : undefined; const additionalPath = issueFilterCount > 0 ? activeLayout ?? "list" : undefined;
const emptyStateSize = issueFilterCount > 0 ? "lg" : "sm";
return ( return (
<div className="relative h-full w-full overflow-y-auto"> <div className="relative h-full w-full overflow-y-auto">
<EmptyState <EmptyState
type={emptyStateType} type={emptyStateType}
additionalPath={additionalPath} additionalPath={additionalPath}
size={emptyStateSize}
primaryButtonOnClick={ primaryButtonOnClick={
issueFilterCount > 0 issueFilterCount > 0
? undefined ? undefined

View File

@ -182,14 +182,14 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
}; };
const handleKanbanFilters = (toggle: "group_by" | "sub_group_by", value: string) => { const handleKanbanFilters = (toggle: "group_by" | "sub_group_by", value: string) => {
if (workspaceSlug && projectId) { if (workspaceSlug) {
let kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || []; let kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || [];
if (kanbanFilters.includes(value)) { if (kanbanFilters.includes(value)) {
kanbanFilters = kanbanFilters.filter((_value) => _value != value); kanbanFilters = kanbanFilters.filter((_value) => _value != value);
} else { } else {
kanbanFilters.push(value); kanbanFilters.push(value);
} }
updateFilters(projectId.toString(), EIssueFilterType.KANBAN_FILTERS, { updateFilters(projectId?.toString() ?? "", EIssueFilterType.KANBAN_FILTERS, {
[toggle]: kanbanFilters, [toggle]: kanbanFilters,
}); });
} }

View File

@ -68,7 +68,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id, nestingLevel: nestingLevel }); setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id, nestingLevel: nestingLevel });
const issue = issuesMap[issueId]; const issue = issuesMap[issueId];
const subIssuesCount = issue.sub_issues_count; const subIssuesCount = issue?.sub_issues_count ?? 0;
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();

View File

@ -63,7 +63,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const currentLayout = `${activeLayout} layout`; const currentLayout = `${activeLayout} layout`;
// derived values // derived values
const stateDetails = getStateById(issue.state_id); const stateDetails = getStateById(issue.state_id);
const subIssueCount = issue.sub_issues_count; const subIssueCount = issue?.sub_issues_count ?? 0;
const issueOperations = useMemo( const issueOperations = useMemo(
() => ({ () => ({

View File

@ -154,12 +154,11 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
return ( return (
<div className="relative flex h-full w-full flex-col overflow-hidden"> <div className="relative flex h-full w-full flex-col overflow-hidden">
<div className="relative flex h-full w-full flex-col"> <div className="relative flex h-full w-full flex-col overflow-auto">
<GlobalViewsAppliedFiltersRoot globalViewId={globalViewId} /> <GlobalViewsAppliedFiltersRoot globalViewId={globalViewId} />
{issueIds.length === 0 ? ( {issueIds.length === 0 ? (
<EmptyState <EmptyState
type={emptyStateType as keyof typeof EMPTY_STATE_DETAILS} type={emptyStateType as keyof typeof EMPTY_STATE_DETAILS}
size="sm"
primaryButtonOnClick={ primaryButtonOnClick={
(workspaceProjectIds ?? []).length > 0 (workspaceProjectIds ?? []).length > 0
? currentView !== "custom-view" && currentView !== "subscribed" ? currentView !== "custom-view" && currentView !== "subscribed"

View File

@ -19,7 +19,7 @@ export const SpreadsheetSubIssueColumn: React.FC<Props> = observer((props: Props
// hooks // hooks
const { workspaceSlug } = useAppRouter(); const { workspaceSlug } = useAppRouter();
// derived values // derived values
const subIssueCount = issue.sub_issues_count; const subIssueCount = issue?.sub_issues_count ?? 0;
const redirectToIssueDetail = () => { const redirectToIssueDetail = () => {
router.push({ router.push({

View File

@ -203,7 +203,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
}; };
const disableUserActions = !canEditProperties(issueDetail.project_id); const disableUserActions = !canEditProperties(issueDetail.project_id);
const subIssuesCount = issueDetail.sub_issues_count; const subIssuesCount = issueDetail?.sub_issues_count ?? 0;
return ( return (
<> <>

View File

@ -109,6 +109,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
// refs // refs
const editorRef = useRef<EditorRefApi>(null); const editorRef = useRef<EditorRefApi>(null);
const submitBtnRef = useRef<HTMLButtonElement | null>(null);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -470,6 +471,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
onChange(description_html); onChange(description_html);
handleFormChange(); handleFormChange();
}} }}
onEnterKeyPress={() => submitBtnRef?.current?.click()}
ref={editorRef} ref={editorRef}
tabIndex={getTabIndex("description_html")} tabIndex={getTabIndex("description_html")}
placeholder={getDescriptionPlaceholder} placeholder={getDescriptionPlaceholder}
@ -770,6 +772,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
variant="primary" variant="primary"
type="submit" type="submit"
size="sm" size="sm"
ref={submitBtnRef}
loading={isSubmitting} loading={isSubmitting}
tabIndex={isDraft ? getTabIndex("submit_button") : getTabIndex("draft_button")} tabIndex={isDraft ? getTabIndex("submit_button") : getTabIndex("draft_button")}
> >

View File

@ -10,10 +10,11 @@ import {
XCircle, XCircle,
CalendarClock, CalendarClock,
CalendarCheck2, CalendarCheck2,
Users,
} from "lucide-react"; } from "lucide-react";
// hooks // hooks
// ui icons // ui icons
import { DiceIcon, DoubleCircleIcon, UserGroupIcon, ContrastIcon, RelatedIcon } from "@plane/ui"; import { DiceIcon, DoubleCircleIcon, ContrastIcon, RelatedIcon } from "@plane/ui";
// components // components
import { import {
DateDropdown, DateDropdown,
@ -94,7 +95,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
{/* assignee */} {/* assignee */}
<div className="flex w-full items-center gap-3 h-8"> <div className="flex w-full items-center gap-3 h-8">
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300"> <div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
<UserGroupIcon className="h-4 w-4 flex-shrink-0" /> <Users className="h-4 w-4 flex-shrink-0" />
<span>Assignees</span> <span>Assignees</span>
</div> </div>
<MemberDropdown <MemberDropdown

View File

@ -61,7 +61,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
undefined; undefined;
const subIssueHelpers = subIssueHelpersByIssueId(parentIssueId); const subIssueHelpers = subIssueHelpersByIssueId(parentIssueId);
const subIssueCount = issue?.sub_issues_count || 0; const subIssueCount = issue?.sub_issues_count ?? 0;
const handleIssuePeekOverview = (issue: TIssue) => const handleIssuePeekOverview = (issue: TIssue) =>
workspaceSlug && workspaceSlug &&

View File

@ -2,7 +2,7 @@ import React, { useRef } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CalendarCheck2, CalendarClock, Info, MoveRight, User2 } from "lucide-react"; import { CalendarCheck2, CalendarClock, Info, MoveRight, SquareUser } from "lucide-react";
// ui // ui
import { LayersIcon, Tooltip, setPromiseToast } from "@plane/ui"; import { LayersIcon, Tooltip, setPromiseToast } from "@plane/ui";
// components // components
@ -188,9 +188,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
</span> </span>
) : ( ) : (
<Tooltip tooltipContent="No lead"> <Tooltip tooltipContent="No lead">
<span className="cursor-default flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80"> <SquareUser className="h-4 w-4 mx-1 text-custom-text-300 " />
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
</Tooltip> </Tooltip>
)} )}
</div> </div>

View File

@ -2,7 +2,7 @@ import React, { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// icons // icons
import { CalendarCheck2, CalendarClock, MoveRight, User2 } from "lucide-react"; import { CalendarCheck2, CalendarClock, MoveRight, SquareUser } from "lucide-react";
// types // types
import { IModule } from "@plane/types"; import { IModule } from "@plane/types";
// ui // ui
@ -140,9 +140,7 @@ export const ModuleListItemAction: FC<Props> = observer((props) => {
</span> </span>
) : ( ) : (
<Tooltip tooltipContent="No lead"> <Tooltip tooltipContent="No lead">
<span className="cursor-default flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80"> <SquareUser className="h-4 w-4 text-custom-text-300" />
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
</Tooltip> </Tooltip>
)} )}

View File

@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import isEqual from "lodash/isEqual";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
@ -11,8 +12,9 @@ import {
Info, Info,
LinkIcon, LinkIcon,
Plus, Plus,
SquareUser,
Trash2, Trash2,
UserCircle2, Users,
} from "lucide-react"; } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
import { IIssueFilterOptions, ILinkDetails, IModule, ModuleLink } from "@plane/types"; import { IIssueFilterOptions, ILinkDetails, IModule, ModuleLink } from "@plane/types";
@ -23,7 +25,6 @@ import {
LayersIcon, LayersIcon,
CustomSelect, CustomSelect,
ModuleStatusIcon, ModuleStatusIcon,
UserGroupIcon,
TOAST_TYPE, TOAST_TYPE,
setToast, setToast,
ArchiveIcon, ArchiveIcon,
@ -252,14 +253,18 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
const handleFiltersUpdate = useCallback( const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => { (key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
const newValues = issueFilters?.filters?.[key] ?? []; let newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom if (key === "state") {
value.forEach((val) => { if (isEqual(newValues, value)) newValues = [];
if (!newValues.includes(val)) newValues.push(val); else newValues = value;
else newValues.splice(newValues.indexOf(val), 1); } else {
}); value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
}
} else { } else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value); else newValues.push(value);
@ -493,7 +498,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="flex flex-col gap-5 pb-6 pt-2.5"> <div className="flex flex-col gap-5 pb-6 pt-2.5">
<div className="flex items-center justify-start gap-1"> <div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300"> <div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<UserCircle2 className="h-4 w-4" /> <SquareUser className="h-4 w-4" />
<span className="text-base">Lead</span> <span className="text-base">Lead</span>
</div> </div>
<Controller <Controller
@ -511,6 +516,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
buttonVariant="background-with-text" buttonVariant="background-with-text"
placeholder="Lead" placeholder="Lead"
disabled={!isEditingAllowed || isArchived} disabled={!isEditingAllowed || isArchived}
icon={SquareUser}
/> />
</div> </div>
)} )}
@ -518,7 +524,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
</div> </div>
<div className="flex items-center justify-start gap-1"> <div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300"> <div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<UserGroupIcon className="h-4 w-4" /> <Users className="h-4 w-4" />
<span className="text-base">Members</span> <span className="text-base">Members</span>
</div> </div>
<Controller <Controller

View File

@ -1,8 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Control, Controller } from "react-hook-form"; // document-editor
// document editor
import { import {
DocumentEditorWithRef, DocumentEditorWithRef,
DocumentReadOnlyEditorWithRef, DocumentReadOnlyEditorWithRef,
@ -11,15 +10,15 @@ import {
IMarking, IMarking,
} from "@plane/document-editor"; } from "@plane/document-editor";
// types // types
import { IUserLite, TPage } from "@plane/types"; import { IUserLite } from "@plane/types";
// components // components
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages"; import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store"; import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
import { usePageDescription } from "@/hooks/use-page-description";
import { usePageFilters } from "@/hooks/use-page-filters"; import { usePageFilters } from "@/hooks/use-page-filters";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// services // services
import { FileService } from "@/services/file.service"; import { FileService } from "@/services/file.service";
// store // store
@ -28,13 +27,10 @@ import { IPageStore } from "@/store/pages/page.store";
const fileService = new FileService(); const fileService = new FileService();
type Props = { type Props = {
control: Control<TPage, any>;
editorRef: React.RefObject<EditorRefApi>; editorRef: React.RefObject<EditorRefApi>;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>; readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
swrPageDetails: TPage | undefined;
handleSubmit: () => void;
markings: IMarking[]; markings: IMarking[];
pageStore: IPageStore; page: IPageStore;
sidePeekVisible: boolean; sidePeekVisible: boolean;
handleEditorReady: (value: boolean) => void; handleEditorReady: (value: boolean) => void;
handleReadOnlyEditorReady: (value: boolean) => void; handleReadOnlyEditorReady: (value: boolean) => void;
@ -43,15 +39,12 @@ type Props = {
export const PageEditorBody: React.FC<Props> = observer((props) => { export const PageEditorBody: React.FC<Props> = observer((props) => {
const { const {
control,
handleReadOnlyEditorReady, handleReadOnlyEditorReady,
handleEditorReady, handleEditorReady,
editorRef, editorRef,
markings, markings,
readOnlyEditorRef, readOnlyEditorRef,
handleSubmit, page,
pageStore,
swrPageDetails,
sidePeekVisible, sidePeekVisible,
updateMarkings, updateMarkings,
} = props; } = props;
@ -67,11 +60,19 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
} = useMember(); } = useMember();
// derived values // derived values
const workspaceId = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "" : ""; const workspaceId = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "" : "";
const pageTitle = pageStore?.name ?? ""; const pageId = page?.id;
const pageDescription = pageStore?.description_html; const pageTitle = page?.name ?? "";
const { description_html, isContentEditable, updateTitle, isSubmitting, setIsSubmitting } = pageStore; const pageDescription = page?.description_html;
const { isContentEditable, updateTitle, setIsSubmitting } = page;
const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : []; const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : [];
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite); const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
// project-description
const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS } = usePageDescription({
editorRef,
page,
projectId,
workspaceSlug,
});
// use-mention // use-mention
const { mentionHighlights, mentionSuggestions } = useMention({ const { mentionHighlights, mentionSuggestions } = useMention({
workspaceSlug: workspaceSlug?.toString() ?? "", workspaceSlug: workspaceSlug?.toString() ?? "",
@ -82,13 +83,11 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
// page filters // page filters
const { isFullWidth } = usePageFilters(); const { isFullWidth } = usePageFilters();
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
useEffect(() => { useEffect(() => {
updateMarkings(description_html ?? "<p></p>"); updateMarkings(pageDescription ?? "<p></p>");
}, [description_html, updateMarkings]); }, [pageDescription, updateMarkings]);
if (pageDescription === undefined) return <PageContentLoader />; if (pageId === undefined || !pageDescriptionYJS || !isDescriptionReady) return <PageContentLoader />;
return ( return (
<div className="flex items-center h-full w-full overflow-y-auto"> <div className="flex items-center h-full w-full overflow-y-auto">
@ -122,35 +121,24 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
/> />
</div> </div>
{isContentEditable ? ( {isContentEditable ? (
<Controller <DocumentEditorWithRef
name="description_html" id={pageId}
control={control} fileHandler={{
render={({ field: { onChange } }) => ( cancel: fileService.cancelUpload,
<DocumentEditorWithRef delete: fileService.getDeleteImageFunction(workspaceId),
fileHandler={{ restore: fileService.getRestoreImageFunction(workspaceId),
cancel: fileService.cancelUpload, upload: fileService.getUploadFileFunction(workspaceSlug as string, setIsSubmitting),
delete: fileService.getDeleteImageFunction(workspaceId), }}
restore: fileService.getRestoreImageFunction(workspaceId), handleEditorReady={handleEditorReady}
upload: fileService.getUploadFileFunction(workspaceSlug as string, setIsSubmitting), value={pageDescriptionYJS}
}} ref={editorRef}
handleEditorReady={handleEditorReady} containerClassName="p-0 pb-64"
initialValue={pageDescription ?? "<p></p>"} editorClassName="pl-10"
value={swrPageDetails?.description_html ?? "<p></p>"} onChange={handleDescriptionChange}
ref={editorRef} mentionHandler={{
containerClassName="p-0 pb-64" highlights: mentionHighlights,
editorClassName="lg:px-10 pl-8" suggestions: mentionSuggestions,
onChange={(_description_json, description_html) => { }}
setIsSubmitting("submitting");
setShowAlert(true);
onChange(description_html);
handleSubmit();
}}
mentionHandler={{
highlights: mentionHighlights,
suggestions: mentionSuggestions,
}}
/>
)}
/> />
) : ( ) : (
<DocumentReadOnlyEditorWithRef <DocumentReadOnlyEditorWithRef
@ -158,7 +146,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
initialValue={pageDescription ?? "<p></p>"} initialValue={pageDescription ?? "<p></p>"}
handleEditorReady={handleReadOnlyEditorReady} handleEditorReady={handleReadOnlyEditorReady}
containerClassName="p-0 pb-64 border-none" containerClassName="p-0 pb-64 border-none"
editorClassName="lg:px-10 pl-8" editorClassName="pl-10"
mentionHandler={{ mentionHandler={{
highlights: mentionHighlights, highlights: mentionHighlights,
}} }}

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