diff --git a/admin/app/ai/page.tsx b/admin/app/ai/page.tsx index 0979bbabe..a54ce6d8c 100644 --- a/admin/app/ai/page.tsx +++ b/admin/app/ai/page.tsx @@ -19,14 +19,14 @@ const InstanceAIPage = observer(() => { return ( <> -
-
+
+
AI features for all your workspaces
Configure your AI API credentials so Plane AI features are turned on for all your workspaces.
-
+
{formattedConfig ? ( ) : ( diff --git a/admin/app/authentication/github/page.tsx b/admin/app/authentication/github/page.tsx index b65b99205..8532910f7 100644 --- a/admin/app/authentication/github/page.tsx +++ b/admin/app/authentication/github/page.tsx @@ -64,8 +64,8 @@ const InstanceGithubAuthenticationPage = observer(() => { return ( <> -
-
+
+
{ withBorder={false} />
-
+
{formattedConfig ? ( ) : ( diff --git a/admin/app/authentication/google/page.tsx b/admin/app/authentication/google/page.tsx index 05117dbe3..fcdcd47ad 100644 --- a/admin/app/authentication/google/page.tsx +++ b/admin/app/authentication/google/page.tsx @@ -58,8 +58,8 @@ const InstanceGoogleAuthenticationPage = observer(() => { return ( <> -
-
+
+
+
{formattedConfig ? ( ) : ( diff --git a/admin/app/authentication/page.tsx b/admin/app/authentication/page.tsx index 25be147ca..d1e6fb0ba 100644 --- a/admin/app/authentication/page.tsx +++ b/admin/app/authentication/page.tsx @@ -119,14 +119,14 @@ const InstanceAuthenticationPage = observer(() => { return ( <> -
-
+
+
Manage authentication for your instance
Configure authentication modes for your team and restrict sign ups to be invite only.
-
+
{formattedConfig ? (
Authentication modes
diff --git a/admin/app/email/page.tsx b/admin/app/email/page.tsx index de776b175..198020d4d 100644 --- a/admin/app/email/page.tsx +++ b/admin/app/email/page.tsx @@ -19,8 +19,8 @@ const InstanceEmailPage = observer(() => { return ( <> -
-
+
+
Secure emails from your own instance
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(() => {
-
+
{formattedConfig ? ( ) : ( diff --git a/admin/app/email/test-email-modal.tsx b/admin/app/email/test-email-modal.tsx index 0feea4128..6d5cb8032 100644 --- a/admin/app/email/test-email-modal.tsx +++ b/admin/app/email/test-email-modal.tsx @@ -51,7 +51,7 @@ export const SendTestEmailModal: FC = (props) => { setSendEmailStep(ESendEmailSteps.SUCCESS); }) .catch((error) => { - setError(error?.message || "Failed to send email"); + setError(error?.error || "Failed to send email"); setSendEmailStep(ESendEmailSteps.FAILED); }) .finally(() => { diff --git a/admin/app/general/page.tsx b/admin/app/general/page.tsx index bab2a94fc..5aaea9f8e 100644 --- a/admin/app/general/page.tsx +++ b/admin/app/general/page.tsx @@ -10,15 +10,15 @@ function GeneralPage() { console.log("instance", instance); return ( <> -
-
+
+
General settings
Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your instance.
-
+
{instance && instanceAdmins && ( )} diff --git a/admin/app/globals.css b/admin/app/globals.css index eefcb1b26..0a2218c21 100644 --- a/admin/app/globals.css +++ b/admin/app/globals.css @@ -332,42 +332,90 @@ body { } /* scrollbar style */ -::-webkit-scrollbar { - display: none; +@-moz-document url-prefix() { + * { + 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 { - overflow-x: scroll; +.vertical-scrollbar { + overflow-y: auto; } - -.horizontal-scroll-enable::-webkit-scrollbar { +.horizontal-scrollbar { + overflow-x: auto; +} +.vertical-scrollbar::-webkit-scrollbar, +.horizontal-scrollbar::-webkit-scrollbar { 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 { - height: 7px; - background-color: rgba(var(--color-background-100)); +/* scrollbar sm size */ +.scrollbar-sm::-webkit-scrollbar { + height: 12px; + width: 12px; } - -.horizontal-scroll-enable::-webkit-scrollbar-thumb { - border-radius: 5px; - background-color: rgba(var(--color-scrollbar)); +.scrollbar-sm::-webkit-scrollbar-thumb { + border: 3px solid rgba(0, 0, 0, 0); } - -.vertical-scroll-enable::-webkit-scrollbar { - display: block; - width: 5px; +/* scrollbar md size */ +.scrollbar-md::-webkit-scrollbar { + height: 14px; + width: 14px; } - -.vertical-scroll-enable::-webkit-scrollbar-track { - width: 5px; +.scrollbar-md::-webkit-scrollbar-thumb { + border: 3px solid rgba(0, 0, 0, 0); } +/* scrollbar lg size */ -.vertical-scroll-enable::-webkit-scrollbar-thumb { - border-radius: 5px; - background-color: rgba(var(--color-background-90)); +.scrollbar-lg::-webkit-scrollbar { + height: 16px; + width: 16px; +} +.scrollbar-lg::-webkit-scrollbar-thumb { + border: 4px solid rgba(0, 0, 0, 0); } /* end scrollbar style */ diff --git a/admin/app/image/page.tsx b/admin/app/image/page.tsx index 5c1b838be..ceaad61f2 100644 --- a/admin/app/image/page.tsx +++ b/admin/app/image/page.tsx @@ -19,14 +19,14 @@ const InstanceImagePage = observer(() => { return ( <> -
-
+
+
Third-party image libraries
Let your users search and choose images from third-party libraries
-
+
{formattedConfig ? ( ) : ( diff --git a/admin/components/admin-sidebar/help-section.tsx b/admin/components/admin-sidebar/help-section.tsx index 371bb49d8..56ccbcd84 100644 --- a/admin/components/admin-sidebar/help-section.tsx +++ b/admin/components/admin-sidebar/help-section.tsx @@ -38,7 +38,7 @@ export const HelpSection: FC = observer(() => { // refs const helpOptionsRef = useRef(null); - const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace"); + const redirectionLink = encodeURI(WEB_BASE_URL + "/"); return (
{ }; return ( -
+
{INSTANCE_ADMIN_LINKS.map((item, index) => { const isActive = item.href === pathName || pathName.includes(item.href); return ( diff --git a/admin/components/instance/setup-form.tsx b/admin/components/instance/setup-form.tsx index 56d536c74..77bf8e562 100644 --- a/admin/components/instance/setup-form.tsx +++ b/admin/components/instance/setup-form.tsx @@ -158,6 +158,7 @@ export const InstanceSetupForm: FC = (props) => { onError={() => setIsSubmitting(false)} > +
@@ -319,8 +320,6 @@ export const InstanceSetupForm: FC = (props) => {
handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)} checked={formData.is_telemetry_enabled} /> diff --git a/admin/components/new-user-popup.tsx b/admin/components/new-user-popup.tsx index 73a405d4a..840de0c3a 100644 --- a/admin/components/new-user-popup.tsx +++ b/admin/components/new-user-popup.tsx @@ -7,9 +7,9 @@ import { useTheme as nextUseTheme } from "next-themes"; // ui import { Button, getButtonStyling } from "@plane/ui"; // helpers -import { resolveGeneralTheme } from "helpers/common.helper"; +import { WEB_BASE_URL, resolveGeneralTheme } from "helpers/common.helper"; // hooks -import { useInstance, useTheme } from "@/hooks/store"; +import { useTheme } from "@/hooks/store"; // icons import TakeoffIconLight from "/public/logos/takeoff-icon-light.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(() => { // hooks const { isNewUserPopup, toggleNewUserPopup } = useTheme(); - const { config } = useInstance(); // theme 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 <>; return ( diff --git a/admin/next.config.js b/admin/next.config.js index 07f6664af..2109cec69 100644 --- a/admin/next.config.js +++ b/admin/next.config.js @@ -1,4 +1,5 @@ /** @type {import('next').NextConfig} */ + const nextConfig = { trailingSlash: true, reactStrictMode: false, diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index 4f3cde39b..41f46c6e4 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -106,7 +106,9 @@ class PageDetailSerializer(PageSerializer): description_html = serializers.CharField() class Meta(PageSerializer.Meta): - fields = PageSerializer.Meta.fields + ["description_html"] + fields = PageSerializer.Meta.fields + [ + "description_html", + ] class SubPageSerializer(BaseSerializer): diff --git a/apiserver/plane/app/urls/page.py b/apiserver/plane/app/urls/page.py index 1a73e4ed3..a6d43600f 100644 --- a/apiserver/plane/app/urls/page.py +++ b/apiserver/plane/app/urls/page.py @@ -6,6 +6,7 @@ from plane.app.views import ( PageFavoriteViewSet, PageLogEndpoint, SubPagesEndpoint, + PagesDescriptionViewSet, ) @@ -79,4 +80,14 @@ urlpatterns = [ SubPagesEndpoint.as_view(), name="sub-page", ), + path( + "workspaces//projects//pages//description/", + PagesDescriptionViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + } + ), + name="page-description", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index bf765e719..0c489593d 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -177,6 +177,7 @@ from .page.base import ( PageFavoriteViewSet, PageLogEndpoint, SubPagesEndpoint, + PagesDescriptionViewSet, ) from .search import GlobalSearchEndpoint, IssueSearchEndpoint diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index 16ea78033..c7f53b9fe 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -1,5 +1,6 @@ # Python imports import json +import base64 from datetime import datetime 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.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page +from django.http import StreamingHttpResponse # Third party imports from rest_framework import status @@ -388,3 +390,48 @@ class SubPagesEndpoint(BaseAPIView): return Response( 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"}) diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py index 9a9cdde43..de1559b0c 100644 --- a/apiserver/plane/app/views/user/base.py +++ b/apiserver/plane/app/views/user/base.py @@ -1,5 +1,5 @@ # Python imports -# import uuid +import uuid # Django imports from django.db.models import Case, Count, IntegerField, Q, When @@ -183,8 +183,8 @@ class UserEndpoint(BaseViewSet): profile.save() # Reset password - # user.is_password_autoset = True - # user.set_password(uuid.uuid4().hex) + user.is_password_autoset = True + user.set_password(uuid.uuid4().hex) # Deactivate the user user.is_active = False diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index 457a67f4f..7b12db945 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -17,6 +17,7 @@ AUTHENTICATION_ERROR_CODES = { "INVALID_EMAIL_SIGN_UP": 5045, "INVALID_EMAIL_MAGIC_SIGN_UP": 5050, "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED": 5055, + "EMAIL_PASSWORD_AUTHENTICATION_DISABLED": 5056, # Sign In "USER_DOES_NOT_EXIST": 5060, "AUTHENTICATION_FAILED_SIGN_IN": 5065, diff --git a/apiserver/plane/authentication/adapter/oauth.py b/apiserver/plane/authentication/adapter/oauth.py index 60c2ea0c6..a917c002a 100644 --- a/apiserver/plane/authentication/adapter/oauth.py +++ b/apiserver/plane/authentication/adapter/oauth.py @@ -8,6 +8,10 @@ from django.utils import timezone from plane.db.models import Account from .base import Adapter +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) class OauthAdapter(Adapter): @@ -50,20 +54,42 @@ class OauthAdapter(Adapter): return self.complete_login_or_signup() def get_user_token(self, data, headers=None): - headers = headers or {} - response = requests.post( - self.get_token_url(), data=data, headers=headers - ) - response.raise_for_status() - return response.json() + try: + headers = headers or {} + response = requests.post( + self.get_token_url(), data=data, headers=headers + ) + 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): - headers = { - "Authorization": f"Bearer {self.token_data.get('access_token')}" - } - response = requests.get(self.get_user_info_url(), headers=headers) - response.raise_for_status() - return response.json() + try: + headers = { + "Authorization": f"Bearer {self.token_data.get('access_token')}" + } + response = requests.get(self.get_user_info_url(), headers=headers) + 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): self.user_data = data diff --git a/apiserver/plane/authentication/provider/credentials/email.py b/apiserver/plane/authentication/provider/credentials/email.py index 7e4e619d8..4c7764128 100644 --- a/apiserver/plane/authentication/provider/credentials/email.py +++ b/apiserver/plane/authentication/provider/credentials/email.py @@ -41,8 +41,10 @@ class EmailProvider(CredentialAdapter): if ENABLE_EMAIL_PASSWORD == "0": raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["ENABLE_EMAIL_PASSWORD"], - error_message="ENABLE_EMAIL_PASSWORD", + error_code=AUTHENTICATION_ERROR_CODES[ + "EMAIL_PASSWORD_AUTHENTICATION_DISABLED" + ], + error_message="EMAIL_PASSWORD_AUTHENTICATION_DISABLED", ) def set_user_data(self): diff --git a/apiserver/plane/authentication/provider/oauth/github.py b/apiserver/plane/authentication/provider/oauth/github.py index 798863d8f..edd99b1ba 100644 --- a/apiserver/plane/authentication/provider/oauth/github.py +++ b/apiserver/plane/authentication/provider/oauth/github.py @@ -105,14 +105,26 @@ class GitHubOAuthProvider(OauthAdapter): ) def __get_email(self, headers): - # Github does not provide email in user response - emails_url = "https://api.github.com/user/emails" - emails_response = requests.get(emails_url, headers=headers).json() - email = next( - (email["email"] for email in emails_response if email["primary"]), - None, - ) - return email + try: + # Github does not provide email in user response + emails_url = "https://api.github.com/user/emails" + emails_response = requests.get(emails_url, headers=headers).json() + email = next( + ( + email["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): user_info_response = self.get_user_response() diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 3602bce1f..e079dcbe5 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -18,6 +18,7 @@ def get_view_props(): class Page(ProjectBaseModel): name = models.CharField(max_length=255, blank=True) description = models.JSONField(default=dict, blank=True) + description_binary = models.BinaryField(null=True) description_html = models.TextField(blank=True, default="

") description_stripped = models.TextField(blank=True, null=True) owned_by = models.ForeignKey( @@ -43,7 +44,6 @@ class Page(ProjectBaseModel): is_locked = models.BooleanField(default=False) view_props = models.JSONField(default=get_view_props) logo_props = models.JSONField(default=dict) - description_binary = models.BinaryField(null=True) class Meta: verbose_name = "Page" diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index 525ab54ec..1ec09fbb5 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -148,7 +148,7 @@ class InstanceEndpoint(BaseAPIView): data["app_base_url"] = settings.APP_BASE_URL 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} return Response(response_data, status=status.HTTP_200_OK) diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index c75e9cfee..10f64fd1c 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -128,7 +128,7 @@ services: image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} platform: ${DOCKER_PLATFORM:-} pull_policy: ${PULL_POLICY:-always} - restart: no + restart: "no" command: ./bin/docker-entrypoint-migrator.sh volumes: - logs_migrator:/code/plane/logs diff --git a/packages/editor/core/src/hooks/use-editor.tsx b/packages/editor/core/src/hooks/use-editor.tsx index 778fdc5e4..2d2e1662a 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/core/src/hooks/use-editor.tsx @@ -13,17 +13,21 @@ import { EditorMenuItemNames, getEditorMenuItems } from "src/ui/menus/menu-items import { EditorRefApi } from "src/types/editor-ref-api"; 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; - uploadFile: UploadImage; - restoreFile: RestoreImage; - deleteFile: DeleteImage; - cancelUploadImage?: () => void; - initialValue: string; + fileHandler: TFileHandler; + initialValue?: string; editorClassName: string; // undefined when prop is not passed, null if intentionally passed to stop // swr syncing - value: string | null | undefined; + value?: string | null | undefined; onChange?: (json: object, html: string) => void; extensions?: any; editorProps?: EditorProps; @@ -38,19 +42,16 @@ interface CustomEditorProps { } export const useEditor = ({ - uploadFile, id = "", - deleteFile, - cancelUploadImage, editorProps = {}, initialValue, editorClassName, value, extensions = [], + fileHandler, onChange, forwardedRef, tabIndex, - restoreFile, handleEditorReady, mentionHandler, placeholder, @@ -67,10 +68,10 @@ export const useEditor = ({ mentionHighlights: mentionHandler.highlights ?? [], }, fileConfig: { - deleteFile, - restoreFile, - cancelUploadImage, - uploadFile, + uploadFile: fileHandler.upload, + deleteFile: fileHandler.delete, + restoreFile: fileHandler.restore, + cancelUploadImage: fileHandler.cancel, }, placeholder, tabIndex, @@ -139,7 +140,7 @@ export const useEditor = ({ } }, 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); @@ -155,7 +156,7 @@ export const useEditor = ({ } }, 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 item = getEditorMenuItem(itemName); @@ -177,6 +178,10 @@ export const useEditor = ({ const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); return markdownOutput; }, + getHTML: (): string => { + const htmlOutput = editorRef.current?.getHTML() ?? "

"; + return htmlOutput; + }, scrollSummary: (marking: IMarking): void => { if (!editorRef.current) return; scrollSummary(editorRef.current, marking); @@ -199,7 +204,7 @@ export const useEditor = ({ } }, }), - [editorRef, savedSelection, uploadFile] + [editorRef, savedSelection, fileHandler.upload] ); if (!editor) { diff --git a/packages/editor/core/src/hooks/use-read-only-editor.tsx b/packages/editor/core/src/hooks/use-read-only-editor.tsx index 9607586d8..8b16d1e76 100644 --- a/packages/editor/core/src/hooks/use-read-only-editor.tsx +++ b/packages/editor/core/src/hooks/use-read-only-editor.tsx @@ -68,6 +68,10 @@ export const useReadOnlyEditor = ({ const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); return markdownOutput; }, + getHTML: (): string => { + const htmlOutput = editorRef.current?.getHTML() ?? "

"; + return htmlOutput; + }, scrollSummary: (marking: IMarking): void => { if (!editorRef.current) return; scrollSummary(editorRef.current, marking); diff --git a/packages/editor/core/src/index.ts b/packages/editor/core/src/index.ts index 336daed43..86066eeba 100644 --- a/packages/editor/core/src/index.ts +++ b/packages/editor/core/src/index.ts @@ -24,6 +24,7 @@ export * from "src/ui/menus/menu-items"; export * from "src/lib/editor-commands"; // types +export type { CustomEditorProps, TFileHandler } from "src/hooks/use-editor"; export type { DeleteImage } from "src/types/delete-image"; export type { UploadImage } from "src/types/upload-image"; export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api"; diff --git a/packages/editor/core/src/lib/utils.ts b/packages/editor/core/src/lib/utils.ts index 84ad7046e..137c70c2e 100644 --- a/packages/editor/core/src/lib/utils.ts +++ b/packages/editor/core/src/lib/utils.ts @@ -1,5 +1,7 @@ +import { Extensions, generateJSON, getSchema } from "@tiptap/core"; import { Selection } from "@tiptap/pm/state"; import { clsx, type ClassValue } from "clsx"; +import { CoreEditorExtensionsWithoutProps } from "src/ui/extensions/core-without-props"; import { twMerge } from "tailwind-merge"; interface EditorClassNames { noBorder?: boolean; @@ -58,3 +60,20 @@ export const isValidHttpUrl = (string: string): boolean => { 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 ?? "

", extensions as Extensions); + const editorSchema = getSchema(extensions as Extensions); + return { + contentJSON, + editorSchema, + }; +}; diff --git a/packages/editor/core/src/types/editor-ref-api.ts b/packages/editor/core/src/types/editor-ref-api.ts index df5df2c7b..4eed815d6 100644 --- a/packages/editor/core/src/types/editor-ref-api.ts +++ b/packages/editor/core/src/types/editor-ref-api.ts @@ -3,6 +3,7 @@ import { EditorMenuItemNames } from "src/ui/menus/menu-items"; export type EditorReadOnlyRefApi = { getMarkDown: () => string; + getHTML: () => string; clearEditor: () => void; setEditorValue: (content: string) => void; scrollSummary: (marking: IMarking) => void; diff --git a/packages/editor/core/src/ui/extensions/core-without-props.tsx b/packages/editor/core/src/ui/extensions/core-without-props.tsx new file mode 100644 index 000000000..3bb00010b --- /dev/null +++ b/packages/editor/core/src/ui/extensions/core-without-props.tsx @@ -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, + }), +]; diff --git a/packages/editor/core/src/ui/extensions/image/image-extension-without-props.tsx b/packages/editor/core/src/ui/extensions/image/image-extension-without-props.tsx new file mode 100644 index 000000000..838a6a1c9 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/image/image-extension-without-props.tsx @@ -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 + addStorage() { + return { + images: new Map(), + uploadInProgress: false, + }; + }, + + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + height: { + default: null, + }, + }; + }, + }); diff --git a/packages/editor/core/src/ui/mentions/mention-without-props.tsx b/packages/editor/core/src/ui/mentions/mention-without-props.tsx new file mode 100644 index 000000000..a0d22ef4f --- /dev/null +++ b/packages/editor/core/src/ui/mentions/mention-without-props.tsx @@ -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(); + }, + }; + }, + }, + }); diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index 47e68a87e..d3bfbd6aa 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -34,12 +34,17 @@ "@plane/ui": "*", "@tippyjs/react": "^4.2.6", "@tiptap/core": "^2.1.13", + "@tiptap/extension-collaboration": "^2.3.2", "@tiptap/pm": "^2.1.13", "@tiptap/suggestion": "^2.1.13", "lucide-react": "^0.378.0", "react-popper": "^2.3.0", "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": { "@types/node": "18.15.3", diff --git a/packages/editor/document-editor/src/hooks/use-document-editor.ts b/packages/editor/document-editor/src/hooks/use-document-editor.ts new file mode 100644 index 000000000..c2070a9f3 --- /dev/null +++ b/packages/editor/document-editor/src/hooks/use-document-editor.ts @@ -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; + mentionHandler: { + highlights: () => Promise; + suggestions?: () => Promise; + }; + 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; +}; diff --git a/packages/editor/document-editor/src/index.ts b/packages/editor/document-editor/src/index.ts index f8eea14ce..9e8407ce3 100644 --- a/packages/editor/document-editor/src/index.ts +++ b/packages/editor/document-editor/src/index.ts @@ -3,6 +3,8 @@ export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "src/ui/re // hooks 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"; diff --git a/packages/editor/document-editor/src/providers/collaboration-provider.ts b/packages/editor/document-editor/src/providers/collaboration-provider.ts new file mode 100644 index 000000000..b61ceebd5 --- /dev/null +++ b/packages/editor/document-editor/src/providers/collaboration-provider.ts @@ -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> & + Partial; + +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 = {}): 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); + } +} diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx index b2816974e..10c9fa596 100644 --- a/packages/editor/document-editor/src/ui/extensions/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/index.tsx @@ -2,14 +2,20 @@ import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-wi import { SlashCommand, DragAndDrop } from "@plane/editor-extensions"; import { UploadImage } from "@plane/editor-core"; +import { CollaborationProvider } from "src/providers/collaboration-provider"; +import Collaboration from "@tiptap/extension-collaboration"; type TArguments = { uploadFile: UploadImage; setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; + provider: CollaborationProvider; }; -export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle }: TArguments) => [ +export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle, provider }: TArguments) => [ SlashCommand(uploadFile), DragAndDrop(setHideDragHandle), IssueWidgetPlaceholder(), + Collaboration.configure({ + document: provider.document, + }), ]; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index 1f1c5f706..1cafe6de7 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -1,30 +1,25 @@ import React, { useState } from "react"; +// editor-core import { - UploadImage, - DeleteImage, - RestoreImage, getEditorClassNames, - useEditor, EditorRefApi, IMentionHighlight, IMentionSuggestion, + TFileHandler, } from "@plane/editor-core"; -import { DocumentEditorExtensions } from "src/ui/extensions"; +// components import { PageRenderer } from "src/ui/components/page-renderer"; +// hooks +import { useDocumentEditor } from "src/hooks/use-document-editor"; interface IDocumentEditor { - initialValue: string; - value?: string; - fileHandler: { - cancel: () => void; - delete: DeleteImage; - upload: UploadImage; - restore: RestoreImage; - }; + id: string; + value: Uint8Array; + fileHandler: TFileHandler; handleEditorReady?: (value: boolean) => void; containerClassName?: string; editorClassName?: string; - onChange: (json: object, html: string) => void; + onChange: (updates: Uint8Array) => void; forwardedRef?: React.MutableRefObject; mentionHandler: { highlights: () => Promise; @@ -37,7 +32,7 @@ interface IDocumentEditor { const DocumentEditor = (props: IDocumentEditor) => { const { onChange, - initialValue, + id, value, fileHandler, containerClassName, @@ -50,32 +45,24 @@ const DocumentEditor = (props: IDocumentEditor) => { } = props; // states const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {}); - // 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 const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => { setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop); }; - // use editor - const editor = useEditor({ - onChange(json, html) { - onChange(json, html); - }, + + // use document editor + const editor = useDocumentEditor({ + id, editorClassName, - restoreFile: fileHandler.restore, - uploadFile: fileHandler.upload, - deleteFile: fileHandler.delete, - cancelUploadImage: fileHandler.cancel, - initialValue, + fileHandler, value, + onChange, handleEditorReady, forwardedRef, mentionHandler, - extensions: DocumentEditorExtensions({ - uploadFile: fileHandler.upload, - setHideDragHandle: setHideDragHandleFunction, - }), placeholder, + setHideDragHandleFunction, tabIndex, }); diff --git a/packages/editor/document-editor/src/utils/yjs.ts b/packages/editor/document-editor/src/utils/yjs.ts new file mode 100644 index 000000000..71a945d3c --- /dev/null +++ b/packages/editor/document-editor/src/utils/yjs.ts @@ -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 = "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; +}; diff --git a/packages/editor/lite-text-editor/src/ui/index.tsx b/packages/editor/lite-text-editor/src/ui/index.tsx index 6b22809d6..77d3ca0ec 100644 --- a/packages/editor/lite-text-editor/src/ui/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/index.tsx @@ -1,27 +1,22 @@ import * as React from "react"; +// editor-core import { - UploadImage, - DeleteImage, IMentionSuggestion, - RestoreImage, EditorContainer, EditorContentWrapper, getEditorClassNames, useEditor, IMentionHighlight, EditorRefApi, + TFileHandler, } from "@plane/editor-core"; +// extensions import { LiteTextEditorExtensions } from "src/ui/extensions"; export interface ILiteTextEditor { initialValue: string; value?: string | null; - fileHandler: { - cancel: () => void; - delete: DeleteImage; - upload: UploadImage; - restore: RestoreImage; - }; + fileHandler: TFileHandler; containerClassName?: string; editorClassName?: string; onChange?: (json: object, html: string) => void; @@ -58,10 +53,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => { value, id, editorClassName, - restoreFile: fileHandler.restore, - uploadFile: fileHandler.upload, - deleteFile: fileHandler.delete, - cancelUploadImage: fileHandler.cancel, + fileHandler, forwardedRef, extensions: LiteTextEditorExtensions(onEnterKeyPress), mentionHandler, diff --git a/packages/editor/rich-text-editor/src/ui/extensions/enter-key-extension.tsx b/packages/editor/rich-text-editor/src/ui/extensions/enter-key-extension.tsx new file mode 100644 index 000000000..70037f046 --- /dev/null +++ b/packages/editor/rich-text-editor/src/ui/extensions/enter-key-extension.tsx @@ -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(), + ]), + }; + }, + }); diff --git a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx index 406fb677f..b52361f6e 100644 --- a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx @@ -1,13 +1,21 @@ import { UploadImage } from "@plane/editor-core"; import { DragAndDrop, SlashCommand } from "@plane/editor-extensions"; +import { EnterKeyExtension } from "./enter-key-extension"; type TArguments = { uploadFile: UploadImage; dragDropEnabled?: boolean; setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; + onEnterKeyPress?: () => void; }; -export const RichTextEditorExtensions = ({ uploadFile, dragDropEnabled, setHideDragHandle }: TArguments) => [ +export const RichTextEditorExtensions = ({ + uploadFile, + dragDropEnabled, + setHideDragHandle, + onEnterKeyPress, +}: TArguments) => [ SlashCommand(uploadFile), dragDropEnabled === true && DragAndDrop(setHideDragHandle), + EnterKeyExtension(onEnterKeyPress), ]; diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index 0cb32e543..2b8348a62 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -1,30 +1,26 @@ "use client"; +import * as React from "react"; +// editor-core import { - DeleteImage, EditorContainer, EditorContentWrapper, getEditorClassNames, IMentionHighlight, IMentionSuggestion, - RestoreImage, - UploadImage, useEditor, EditorRefApi, + TFileHandler, } from "@plane/editor-core"; -import * as React from "react"; +// extensions import { RichTextEditorExtensions } from "src/ui/extensions"; +// components import { EditorBubbleMenu } from "src/ui/menus/bubble-menu"; export type IRichTextEditor = { initialValue: string; value?: string | null; dragDropEnabled?: boolean; - fileHandler: { - cancel: () => void; - delete: DeleteImage; - upload: UploadImage; - restore: RestoreImage; - }; + fileHandler: TFileHandler; id?: string; containerClassName?: string; editorClassName?: string; @@ -37,6 +33,7 @@ export type IRichTextEditor = { }; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; + onEnterKeyPress?: (e?: any) => void; }; const RichTextEditor = (props: IRichTextEditor) => { @@ -54,6 +51,7 @@ const RichTextEditor = (props: IRichTextEditor) => { placeholder, tabIndex, mentionHandler, + onEnterKeyPress, } = props; const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {}); @@ -67,10 +65,7 @@ const RichTextEditor = (props: IRichTextEditor) => { const editor = useEditor({ id, editorClassName, - restoreFile: fileHandler.restore, - uploadFile: fileHandler.upload, - deleteFile: fileHandler.delete, - cancelUploadImage: fileHandler.cancel, + fileHandler, onChange, initialValue, value, @@ -80,6 +75,7 @@ const RichTextEditor = (props: IRichTextEditor) => { uploadFile: fileHandler.upload, dragDropEnabled, setHideDragHandle: setHideDragHandleFunction, + onEnterKeyPress, }), tabIndex, mentionHandler, diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index dbed6ba89..5028848d8 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -19,4 +19,3 @@ export * from "./priority-icon"; export * from "./related-icon"; export * from "./side-panel-icon"; export * from "./transfer-icon"; -export * from "./user-group-icon"; diff --git a/packages/ui/src/icons/user-group-icon.tsx b/packages/ui/src/icons/user-group-icon.tsx deleted file mode 100644 index 7cad96d23..000000000 --- a/packages/ui/src/icons/user-group-icon.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from "react"; - -import { ISvgIcons } from "./type"; - -export const UserGroupIcon: React.FC = ({ className = "text-current", ...rest }) => ( - - - - - - -); diff --git a/space/.gitignore b/space/.gitignore index a2a963ee7..a64f113f1 100644 --- a/space/.gitignore +++ b/space/.gitignore @@ -37,3 +37,6 @@ next-env.d.ts # env .env + +# Sentry Config File +.env.sentry-build-plugin diff --git a/space/instrumentation.ts b/space/instrumentation.ts new file mode 100644 index 000000000..7b89a972e --- /dev/null +++ b/space/instrumentation.ts @@ -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'); + } +} diff --git a/space/next.config.js b/space/next.config.js index eb9dde88a..d18ce805f 100644 --- a/space/next.config.js +++ b/space/next.config.js @@ -28,12 +28,46 @@ const nextConfig = { }, }; -if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0", 10)) { - module.exports = withSentryConfig( - nextConfig, - { silent: true, authToken: process.env.SENTRY_AUTH_TOKEN }, - { hideSourceMaps: true } - ); + +const sentryConfig = { + // For all available options, see: + // https://github.com/getsentry/sentry-webpack-plugin#options + + 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 { module.exports = nextConfig; } + diff --git a/space/package.json b/space/package.json index a084c143b..7ba146f27 100644 --- a/space/package.json +++ b/space/package.json @@ -23,7 +23,7 @@ "@plane/rich-text-editor": "*", "@plane/types": "*", "@plane/ui": "*", - "@sentry/nextjs": "^7.108.0", + "@sentry/nextjs": "^8", "axios": "^1.3.4", "clsx": "^2.0.0", "dompurify": "^3.0.11", diff --git a/space/sentry.client.config.js b/space/sentry.client.config.js deleted file mode 100644 index ca473045b..000000000 --- a/space/sentry.client.config.js +++ /dev/null @@ -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 -}); diff --git a/space/sentry.client.config.ts b/space/sentry.client.config.ts new file mode 100644 index 000000000..c81030622 --- /dev/null +++ b/space/sentry.client.config.ts @@ -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, + }), + ], +}); diff --git a/space/sentry.edge.config.js b/space/sentry.edge.config.js deleted file mode 100644 index 8374ed410..000000000 --- a/space/sentry.edge.config.js +++ /dev/null @@ -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 -}); diff --git a/space/sentry.edge.config.ts b/space/sentry.edge.config.ts new file mode 100644 index 000000000..2dbc6e93a --- /dev/null +++ b/space/sentry.edge.config.ts @@ -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, +}); diff --git a/web/sentry.server.config.js b/space/sentry.server.config.ts similarity index 56% rename from web/sentry.server.config.js rename to space/sentry.server.config.ts index d2acb07e1..e578f1530 100644 --- a/web/sentry.server.config.js +++ b/space/sentry.server.config.ts @@ -4,15 +4,16 @@ import * as Sentry from "@sentry/nextjs"; -const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN; - Sentry.init({ - dsn: SENTRY_DSN, + 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.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 + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + // Uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: process.env.NODE_ENV === 'development', }); diff --git a/turbo.json b/turbo.json index c08733c85..fde4ffc79 100644 --- a/turbo.json +++ b/turbo.json @@ -8,10 +8,6 @@ "NEXT_PUBLIC_SPACE_BASE_URL", "NEXT_PUBLIC_SPACE_BASE_PATH", "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_CRISP_ID", "NEXT_PUBLIC_ENABLE_SESSION_RECORDER", @@ -21,7 +17,12 @@ "NEXT_PUBLIC_POSTHOG_HOST", "NEXT_PUBLIC_POSTHOG_DEBUG", "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": { "build": { diff --git a/web/components/analytics/project-modal/main-content.tsx b/web/components/analytics/project-modal/main-content.tsx index 030760a1c..e91282801 100644 --- a/web/components/analytics/project-modal/main-content.tsx +++ b/web/components/analytics/project-modal/main-content.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { Fragment } from "react"; import { observer } from "mobx-react"; import { Tab } from "@headlessui/react"; import { ICycle, IModule, IProject } from "@plane/types"; @@ -20,20 +20,21 @@ export const ProjectAnalyticsModalMainContent: React.FC = observer((props return ( - + {ANALYTICS_TABS.map((tab) => ( - - `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 ${ - selected - ? "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" - }` - } - onClick={() => {}} - > - {tab.title} + + {({ selected }) => ( + + )} ))} diff --git a/web/components/command-palette/actions/issue-actions/actions-list.tsx b/web/components/command-palette/actions/issue-actions/actions-list.tsx index 6e8351804..040eb2e3c 100644 --- a/web/components/command-palette/actions/issue-actions/actions-list.tsx +++ b/web/components/command-palette/actions/issue-actions/actions-list.tsx @@ -1,10 +1,10 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; 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"; // hooks -import { DoubleCircleIcon, UserGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; +import { DoubleCircleIcon, TOAST_TYPE, setToast } from "@plane/ui"; // constants import { EIssuesStoreType } from "@/constants/issue"; // helpers @@ -115,7 +115,7 @@ export const CommandPaletteIssueActions: React.FC = observer((props) => { className="focus:outline-none" >
- + Assign to...
diff --git a/web/components/core/activity.tsx b/web/components/core/activity.tsx index 2f89820af..2c0ebb608 100644 --- a/web/components/core/activity.tsx +++ b/web/components/core/activity.tsx @@ -514,7 +514,7 @@ const activityDetails: { name: { message: (activity, showIssue) => ( <> - set the name to {activity.new_value} + set the title to {activity.new_value} {showIssue && ( <> {" "} diff --git a/web/components/core/list/list-item.tsx b/web/components/core/list/list-item.tsx index ae32c9b31..89b23dbb5 100644 --- a/web/components/core/list/list-item.tsx +++ b/web/components/core/list/list-item.tsx @@ -2,6 +2,8 @@ import React, { FC } from "react"; import Link from "next/link"; // ui import { Tooltip } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; interface IListItemProps { title: string; @@ -12,6 +14,7 @@ interface IListItemProps { actionableItems?: JSX.Element; isMobile?: boolean; parentRef: React.RefObject; + className?: string; } export const ListItem: FC = (props) => { @@ -24,12 +27,18 @@ export const ListItem: FC = (props) => { onItemClick, isMobile = false, parentRef, + className = "", } = props; return (
-
+
diff --git a/web/components/core/sidebar/sidebar-progress-stats.tsx b/web/components/core/sidebar/sidebar-progress-stats.tsx index db9d94a8f..0194ba01f 100644 --- a/web/components/core/sidebar/sidebar-progress-stats.tsx +++ b/web/components/core/sidebar/sidebar-progress-stats.tsx @@ -1,5 +1,5 @@ import React from "react"; - +import { observer } from "mobx-react"; import Image from "next/image"; // headless ui import { Tab } from "@headlessui/react"; @@ -15,6 +15,7 @@ import { // hooks import { Avatar, StateGroupIcon } from "@plane/ui"; import { SingleProgressStats } from "@/components/core"; +import { useProjectState } from "@/hooks/store"; import useLocalStorage from "@/hooks/use-local-storage"; // images import emptyLabel from "public/empty-state/empty_label.svg"; @@ -44,20 +45,23 @@ type Props = { handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; }; -export const SidebarProgressStats: React.FC = ({ - distribution, - groupedIssues, - totalIssues, - module, - roundedTab, - noBackground, - isPeekView = false, - isCompleted = false, - filters, - handleFiltersUpdate, -}) => { +export const SidebarProgressStats: React.FC = observer((props) => { + const { + distribution, + groupedIssues, + totalIssues, + module, + roundedTab, + noBackground, + isPeekView = false, + isCompleted = false, + filters, + handleFiltersUpdate, + } = props; const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees"); + const { groupedProjectStates } = useProjectState(); + const currentValue = (tab: string | null) => { switch (tab) { case "Assignees": @@ -71,6 +75,12 @@ export const SidebarProgressStats: React.FC = ({ } }; + const getStateGroupState = (stateGroup: string) => { + const stateGroupStates = groupedProjectStates?.[stateGroup]; + const stateGroupStatesId = stateGroupStates?.map((state) => state.id); + return stateGroupStatesId; + }; + return ( = ({ } completed={groupedIssues[group]} total={totalIssues} + {...(!isPeekView && + !isCompleted && { + onClick: () => handleFiltersUpdate("state", getStateGroupState(group) ?? []), + })} /> ))} ); -}; +}); diff --git a/web/components/cycles/active-cycle/productivity.tsx b/web/components/cycles/active-cycle/productivity.tsx index e270b5ad8..32d17df75 100644 --- a/web/components/cycles/active-cycle/productivity.tsx +++ b/web/components/cycles/active-cycle/productivity.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import Link from "next/link"; // types import { ICycle } from "@plane/types"; // components @@ -8,14 +9,19 @@ import { EmptyState } from "@/components/empty-state"; import { EmptyStateType } from "@/constants/empty-state"; export type ActiveCycleProductivityProps = { + workspaceSlug: string; + projectId: string; cycle: ICycle; }; export const ActiveCycleProductivity: FC = (props) => { - const { cycle } = props; + const { workspaceSlug, projectId, cycle } = props; return ( -
+

Issue burndown

@@ -53,6 +59,6 @@ export const ActiveCycleProductivity: FC = (props)
)} -
+ ); }; diff --git a/web/components/cycles/active-cycle/progress.tsx b/web/components/cycles/active-cycle/progress.tsx index 6aae998be..fd537148c 100644 --- a/web/components/cycles/active-cycle/progress.tsx +++ b/web/components/cycles/active-cycle/progress.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import Link from "next/link"; // types import { ICycle } from "@plane/types"; // ui @@ -10,11 +11,13 @@ import { CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle"; import { EmptyStateType } from "@/constants/empty-state"; export type ActiveCycleProgressProps = { + workspaceSlug: string; + projectId: string; cycle: ICycle; }; export const ActiveCycleProgress: FC = (props) => { - const { cycle } = props; + const { workspaceSlug, projectId, cycle } = props; const progressIndicatorData = CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({ id: index, @@ -31,7 +34,10 @@ export const ActiveCycleProgress: FC = (props) => { }; return ( -
+

Progress

@@ -85,6 +91,6 @@ export const ActiveCycleProgress: FC = (props) => {
)} -
+ ); }; diff --git a/web/components/cycles/active-cycle/root.tsx b/web/components/cycles/active-cycle/root.tsx index 625210fd4..8b51a692b 100644 --- a/web/components/cycles/active-cycle/root.tsx +++ b/web/components/cycles/active-cycle/root.tsx @@ -62,13 +62,18 @@ export const ActiveCycleRoot: React.FC = observer((props) = cycleId={currentProjectActiveCycleId} workspaceSlug={workspaceSlug} projectId={projectId} + className="!border-b-transparent" /> )} -
-
- - - +
+
+ + +
diff --git a/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx index 9b63b0f6f..a66af73c3 100644 --- a/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx +++ b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx @@ -2,7 +2,7 @@ import { useRef } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; -import { User2 } from "lucide-react"; +import { Users } from "lucide-react"; // ui import { Avatar, AvatarGroup, setPromiseToast } from "@plane/ui"; // components @@ -112,9 +112,7 @@ export const UpcomingCycleListItem: React.FC = observer((props) => { })} ) : ( - - - + )} = observer((props) => { })} ) : ( - - - + )}
diff --git a/web/components/cycles/list/cycles-list-item.tsx b/web/components/cycles/list/cycles-list-item.tsx index 92c11dd69..b2d9cb882 100644 --- a/web/components/cycles/list/cycles-list-item.tsx +++ b/web/components/cycles/list/cycles-list-item.tsx @@ -22,10 +22,11 @@ type TCyclesListItem = { handleRemoveFromFavorites?: () => void; workspaceSlug: string; projectId: string; + className?: string; }; export const CyclesListItem: FC = observer((props) => { - const { cycleId, workspaceSlug, projectId } = props; + const { cycleId, workspaceSlug, projectId, className = "" } = props; // refs const parentRef = useRef(null); // router @@ -83,6 +84,7 @@ export const CyclesListItem: FC = observer((props) => { onItemClick={(e) => { if (cycleDetails.archived_at) openCycleOverview(e); }} + className={className} prependTitleElement={ {isCompleted ? ( diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index 2aa3afc48..595fe9b7a 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useState } from "react"; import isEmpty from "lodash/isEmpty"; +import isEqual from "lodash/isEqual"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; @@ -9,10 +10,10 @@ import { ChevronDown, LinkIcon, Trash2, - UserCircle2, AlertCircle, ChevronRight, CalendarClock, + SquareUser, } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; // types @@ -199,14 +200,18 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const handleFiltersUpdate = useCallback( (key: keyof IIssueFilterOptions, value: string | string[]) => { if (!workspaceSlug || !projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; + let newValues = issueFilters?.filters?.[key] ?? []; if (Array.isArray(value)) { - // this validation is majorly for the filter start_date, target_date custom - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); + if (key === "state") { + if (isEqual(newValues, value)) newValues = []; + else newValues = value; + } else { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } } else { if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); else newValues.push(value); @@ -427,7 +432,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
- + Lead
diff --git a/web/components/dropdowns/member/avatar.tsx b/web/components/dropdowns/member/avatar.tsx index 15e1fbd8c..868c28665 100644 --- a/web/components/dropdowns/member/avatar.tsx +++ b/web/components/dropdowns/member/avatar.tsx @@ -1,16 +1,19 @@ import { observer } from "mobx-react"; -// hooks -import { Avatar, AvatarGroup, UserGroupIcon } from "@plane/ui"; -import { useMember } from "@/hooks/store"; +// icons +import { LucideIcon, Users } from "lucide-react"; // ui +import { Avatar, AvatarGroup } from "@plane/ui"; +// hooks +import { useMember } from "@/hooks/store"; type AvatarProps = { showTooltip: boolean; userIds: string | string[] | null; + icon?: LucideIcon; }; export const ButtonAvatars: React.FC = observer((props) => { - const { showTooltip, userIds } = props; + const { showTooltip, userIds, icon: Icon } = props; // store hooks const { getUserDetails } = useMember(); @@ -33,5 +36,9 @@ export const ButtonAvatars: React.FC = observer((props) => { } } - return ; + return Icon ? ( + + ) : ( + + ); }); diff --git a/web/components/dropdowns/member/index.tsx b/web/components/dropdowns/member/index.tsx index d14cf316f..7af6f4fe1 100644 --- a/web/components/dropdowns/member/index.tsx +++ b/web/components/dropdowns/member/index.tsx @@ -1,6 +1,6 @@ import { Fragment, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, LucideIcon } from "lucide-react"; // headless ui import { Combobox } from "@headlessui/react"; // helpers @@ -19,6 +19,7 @@ import { MemberDropdownProps } from "./types"; type Props = { projectId?: string; + icon?: LucideIcon; onClose?: () => void; } & MemberDropdownProps; @@ -43,6 +44,7 @@ export const MemberDropdown: React.FC = observer((props) => { showTooltip = false, tabIndex, value, + icon, } = props; // states const [isOpen, setIsOpen] = useState(false); @@ -115,7 +117,7 @@ export const MemberDropdown: React.FC = observer((props) => { showTooltip={showTooltip} variant={buttonVariant} > - {!hideIcon && } + {!hideIcon && } {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( {Array.isArray(value) && value.length > 0 diff --git a/web/components/headers/page-details.tsx b/web/components/headers/page-details.tsx index 0a02c1528..3e5424305 100644 --- a/web/components/headers/page-details.tsx +++ b/web/components/headers/page-details.tsx @@ -1,30 +1,26 @@ -import { FC } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { FileText } from "lucide-react"; -// hooks // ui import { Breadcrumbs, Button } from "@plane/ui"; -// helpers -import { BreadcrumbLink } from "@/components/common"; // components +import { BreadcrumbLink } from "@/components/common"; 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 { - showButton?: boolean; -} - -export const PageDetailsHeader: FC = observer((props) => { - const { showButton = false } = props; +export const PageDetailsHeader = observer(() => { // router const router = useRouter(); const { workspaceSlug, pageId } = router.query; // store hooks - const { toggleCreatePageModal } = useCommandPalette(); const { currentProjectDetails } = useProject(); - - const { name } = usePage(pageId?.toString() ?? ""); + const { isContentEditable, isSubmitting, name } = usePage(pageId?.toString() ?? ""); + // use platform + const { platform } = usePlatformOS(); + // derived values + const isMac = platform === "MacOS"; return (
@@ -77,12 +73,24 @@ export const PageDetailsHeader: FC = observer((props) => {
- {showButton && ( -
- -
+ {isContentEditable && ( + )}
); diff --git a/web/components/inbox/content/issue-properties.tsx b/web/components/inbox/content/issue-properties.tsx index 9074f67ca..92205e626 100644 --- a/web/components/inbox/content/issue-properties.tsx +++ b/web/components/inbox/content/issue-properties.tsx @@ -1,9 +1,9 @@ import React from "react"; import { observer } from "mobx-react"; 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 { ControlLink, DoubleCircleIcon, Tooltip, UserGroupIcon } from "@plane/ui"; +import { ControlLink, DoubleCircleIcon, Tooltip } from "@plane/ui"; // components import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns"; import { IssueLabel, TIssueOperations } from "@/components/issues"; @@ -64,7 +64,7 @@ export const InboxIssueContentProperties: React.FC = observer((props) => {/* Assignee */}
- + Assignees
= observer((props) const router = useRouter(); // refs const descriptionEditorRef = useRef(null); + const submitBtnRef = useRef(null); // hooks const { captureIssueEvent } = useEventTracker(); const { createInboxIssue } = useProjectInbox(); @@ -139,6 +140,7 @@ export const InboxIssueCreateRoot: FC = observer((props) handleData={handleFormData} editorRef={descriptionEditorRef} containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]" + onEnterKeyPress={() => submitBtnRef?.current?.click()} />
@@ -158,6 +160,7 @@ export const InboxIssueCreateRoot: FC = observer((props)
@@ -160,6 +162,7 @@ export const InboxIssueEditRoot: FC = observer((props) => { variant="primary" size="sm" type="button" + ref={submitBtnRef} loading={formSubmitting} disabled={isTitleLengthMoreThan255Character} onClick={handleFormSubmit} diff --git a/web/components/inbox/modals/create-edit-modal/issue-description.tsx b/web/components/inbox/modals/create-edit-modal/issue-description.tsx index 882fb0f95..4b4cb261e 100644 --- a/web/components/inbox/modals/create-edit-modal/issue-description.tsx +++ b/web/components/inbox/modals/create-edit-modal/issue-description.tsx @@ -18,11 +18,13 @@ type TInboxIssueDescription = { data: Partial; handleData: (issueKey: keyof Partial, issueValue: Partial[keyof Partial]) => void; editorRef: RefObject; + onEnterKeyPress?: (e?: any) => void; }; // TODO: have to implement GPT Assistance export const InboxIssueDescription: FC = observer((props) => { - const { containerClassName, workspaceSlug, projectId, workspaceId, data, handleData, editorRef } = props; + const { containerClassName, workspaceSlug, projectId, workspaceId, data, handleData, editorRef, onEnterKeyPress } = + props; // hooks const { loader } = useProjectInbox(); @@ -44,6 +46,7 @@ export const InboxIssueDescription: FC = observer((props onChange={(_description: object, description_html: string) => handleData("description_html", description_html)} placeholder={getDescriptionPlaceholder} containerClassName={containerClassName} + onEnterKeyPress={onEnterKeyPress} /> ); }); diff --git a/web/components/integration/github/root.tsx b/web/components/integration/github/root.tsx index d5866e95a..7e9322a5a 100644 --- a/web/components/integration/github/root.tsx +++ b/web/components/integration/github/root.tsx @@ -10,9 +10,9 @@ import useSWR, { mutate } from "swr"; // react-hook-form // services // 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 { UserGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/ui"; import { GithubImportConfigure, GithubImportData, @@ -72,7 +72,7 @@ const integrationWorkflowData = [ { title: "Users", key: "import-users", - icon: UserGroupIcon, + icon: Users, }, { title: "Confirm", diff --git a/web/components/integration/jira/root.tsx b/web/components/integration/jira/root.tsx index b95ec1986..1b98c27ba 100644 --- a/web/components/integration/jira/root.tsx +++ b/web/components/integration/jira/root.tsx @@ -5,12 +5,12 @@ import { useRouter } from "next/router"; import { FormProvider, useForm } from "react-hook-form"; import { mutate } from "swr"; // icons -import { ArrowLeft, Check, List, Settings } from "lucide-react"; +import { ArrowLeft, Check, List, Settings, Users } from "lucide-react"; import { IJiraImporterForm } from "@plane/types"; // services // fetch keys // components -import { Button, UserGroupIcon } from "@plane/ui"; +import { Button } from "@plane/ui"; import { IMPORTER_SERVICES_LIST } from "@/constants/fetch-keys"; // assets import { JiraImporterService } from "@/services/integrations"; @@ -44,7 +44,7 @@ const integrationWorkflowData: Array<{ { title: "Users", key: "import-users", - icon: UserGroupIcon, + icon: Users, }, { title: "Confirm", diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/assignee.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/assignee.tsx index 459283b88..547c55f66 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/assignee.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/assignee.tsx @@ -1,11 +1,11 @@ import { FC } from "react"; import { observer } from "mobx-react"; -// hooks -import { UserGroupIcon } from "@plane/ui"; +// icons +import { Users } from "lucide-react"; +// hooks; import { useIssueDetail } from "@/hooks/store"; // components import { IssueActivityBlockComponent, IssueLink } from "./"; -// icons type TIssueAssigneeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; @@ -21,7 +21,7 @@ export const IssueAssigneeActivity: FC = observer((props if (!activity) return <>; return ( } + icon={} activityId={activityId} ends={ends} > diff --git a/web/components/issues/issue-detail/parent-select.tsx b/web/components/issues/issue-detail/parent-select.tsx index d8399fc02..402319af4 100644 --- a/web/components/issues/issue-detail/parent-select.tsx +++ b/web/components/issues/issue-detail/parent-select.tsx @@ -103,7 +103,7 @@ export const IssueParentSelect: React.FC = observer((props)
= observer((props) => { Sibling issues
- + issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: null })} diff --git a/web/components/issues/issue-detail/parent/sibling-item.tsx b/web/components/issues/issue-detail/parent/sibling-item.tsx index c6eef2a9e..c66a18899 100644 --- a/web/components/issues/issue-detail/parent/sibling-item.tsx +++ b/web/components/issues/issue-detail/parent/sibling-item.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import { observer } from "mobx-react"; import Link from "next/link"; // ui import { CustomMenu, LayersIcon } from "@plane/ui"; @@ -6,15 +7,15 @@ import { CustomMenu, LayersIcon } from "@plane/ui"; import { useIssueDetail, useProject } from "@/hooks/store"; type TIssueParentSiblingItem = { + workspaceSlug: string; issueId: string; }; -export const IssueParentSiblingItem: FC = (props) => { - const { issueId } = props; +export const IssueParentSiblingItem: FC = observer((props) => { + const { workspaceSlug, issueId } = props; // hooks const { getProjectById } = useProject(); const { - peekIssue, issue: { getIssueById }, } = useIssueDetail(); @@ -27,7 +28,7 @@ export const IssueParentSiblingItem: FC = (props) => { <> @@ -36,4 +37,4 @@ export const IssueParentSiblingItem: FC = (props) => { ); -}; +}); diff --git a/web/components/issues/issue-detail/parent/siblings.tsx b/web/components/issues/issue-detail/parent/siblings.tsx index 56e93fc0f..e23d8a595 100644 --- a/web/components/issues/issue-detail/parent/siblings.tsx +++ b/web/components/issues/issue-detail/parent/siblings.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import { observer } from "mobx-react"; import useSWR from "swr"; import { TIssue } from "@plane/types"; // components @@ -8,25 +9,25 @@ import { useIssueDetail } from "@/hooks/store"; import { IssueParentSiblingItem } from "./sibling-item"; export type TIssueParentSiblings = { + workspaceSlug: string; currentIssue: TIssue; parentIssue: TIssue; }; -export const IssueParentSiblings: FC = (props) => { - const { currentIssue, parentIssue } = props; +export const IssueParentSiblings: FC = observer((props) => { + const { workspaceSlug, currentIssue, parentIssue } = props; // hooks const { - peekIssue, fetchSubIssues, subIssues: { subIssuesByIssueId }, } = useIssueDetail(); const { isLoading } = useSWR( - peekIssue && parentIssue && parentIssue.project_id - ? `ISSUE_PARENT_CHILD_ISSUES_${peekIssue?.workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}` + parentIssue && parentIssue.project_id + ? `ISSUE_PARENT_CHILD_ISSUES_${workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}` : null, - peekIssue && parentIssue && parentIssue.project_id - ? () => fetchSubIssues(peekIssue?.workspaceSlug, parentIssue.project_id, parentIssue.id) + parentIssue && parentIssue.project_id + ? () => fetchSubIssues(workspaceSlug, parentIssue.project_id, parentIssue.id) : null ); @@ -40,7 +41,10 @@ export const IssueParentSiblings: FC = (props) => {
) : subIssueIds && subIssueIds.length > 0 ? ( subIssueIds.map( - (issueId) => currentIssue.id != issueId && + (issueId) => + currentIssue.id != issueId && ( + + ) ) ) : (
@@ -49,4 +53,4 @@ export const IssueParentSiblings: FC = (props) => { )}
); -}; +}); diff --git a/web/components/issues/issue-detail/sidebar.tsx b/web/components/issues/issue-detail/sidebar.tsx index a9a23ebdc..568dc4ced 100644 --- a/web/components/issues/issue-detail/sidebar.tsx +++ b/web/components/issues/issue-detail/sidebar.tsx @@ -12,6 +12,7 @@ import { Tag, Trash2, Triangle, + Users, XCircle, } from "lucide-react"; // hooks @@ -24,7 +25,6 @@ import { RelatedIcon, TOAST_TYPE, Tooltip, - UserGroupIcon, setToast, } from "@plane/ui"; import { @@ -219,7 +219,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {
- + Assignees
= observer((props) => { ? EmptyStateType.PROJECT_EMPTY_FILTER : EmptyStateType.PROJECT_CYCLE_NO_ISSUES; const additionalPath = isCompletedAndEmpty ? undefined : activeLayout ?? "list"; - const emptyStateSize = isEmptyFilters ? "lg" : "sm"; return ( <> @@ -84,7 +83,6 @@ export const CycleEmptyState: React.FC = observer((props) => { { diff --git a/web/components/issues/issue-layouts/empty-states/draft-issues.tsx b/web/components/issues/issue-layouts/empty-states/draft-issues.tsx index 07cb70ceb..7fc6811c8 100644 --- a/web/components/issues/issue-layouts/empty-states/draft-issues.tsx +++ b/web/components/issues/issue-layouts/empty-states/draft-issues.tsx @@ -41,14 +41,12 @@ export const ProjectDraftEmptyState: React.FC = observer(() => { const emptyStateType = issueFilterCount > 0 ? EmptyStateType.PROJECT_DRAFT_EMPTY_FILTER : EmptyStateType.PROJECT_DRAFT_NO_ISSUES; const additionalPath = issueFilterCount > 0 ? activeLayout ?? "list" : undefined; - const emptyStateSize = issueFilterCount > 0 ? "lg" : "sm"; return (
0 ? handleClearAllFilters : undefined} />
diff --git a/web/components/issues/issue-layouts/empty-states/project-issues.tsx b/web/components/issues/issue-layouts/empty-states/project-issues.tsx index 8b496e961..5682eaf77 100644 --- a/web/components/issues/issue-layouts/empty-states/project-issues.tsx +++ b/web/components/issues/issue-layouts/empty-states/project-issues.tsx @@ -43,14 +43,12 @@ export const ProjectEmptyState: React.FC = observer(() => { const emptyStateType = issueFilterCount > 0 ? EmptyStateType.PROJECT_EMPTY_FILTER : EmptyStateType.PROJECT_NO_ISSUES; const additionalPath = issueFilterCount > 0 ? activeLayout ?? "list" : undefined; - const emptyStateSize = issueFilterCount > 0 ? "lg" : "sm"; return (
0 ? undefined diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 56994f708..6d32c4375 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -182,14 +182,14 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas }; const handleKanbanFilters = (toggle: "group_by" | "sub_group_by", value: string) => { - if (workspaceSlug && projectId) { + if (workspaceSlug) { let kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || []; if (kanbanFilters.includes(value)) { kanbanFilters = kanbanFilters.filter((_value) => _value != value); } else { kanbanFilters.push(value); } - updateFilters(projectId.toString(), EIssueFilterType.KANBAN_FILTERS, { + updateFilters(projectId?.toString() ?? "", EIssueFilterType.KANBAN_FILTERS, { [toggle]: kanbanFilters, }); } diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 3cf82cd31..4f5c7784d 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -68,7 +68,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => { setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id, nestingLevel: nestingLevel }); const issue = issuesMap[issueId]; - const subIssuesCount = issue.sub_issues_count; + const subIssuesCount = issue?.sub_issues_count ?? 0; const { isMobile } = usePlatformOS(); diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index cab3f917f..87682a087 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -63,7 +63,7 @@ export const IssueProperties: React.FC = observer((props) => { const currentLayout = `${activeLayout} layout`; // derived values const stateDetails = getStateById(issue.state_id); - const subIssueCount = issue.sub_issues_count; + const subIssueCount = issue?.sub_issues_count ?? 0; const issueOperations = useMemo( () => ({ diff --git a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index 8945a33a7..6f7bb75c3 100644 --- a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -154,12 +154,11 @@ export const AllIssueLayoutRoot: React.FC = observer(() => { return (
-
+
{issueIds.length === 0 ? ( 0 ? currentView !== "custom-view" && currentView !== "subscribed" diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx index c597bc698..8a6d26ac6 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx @@ -19,7 +19,7 @@ export const SpreadsheetSubIssueColumn: React.FC = observer((props: Props // hooks const { workspaceSlug } = useAppRouter(); // derived values - const subIssueCount = issue.sub_issues_count; + const subIssueCount = issue?.sub_issues_count ?? 0; const redirectToIssueDetail = () => { router.push({ diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 03854fcad..eb33a13f3 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -203,7 +203,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { }; const disableUserActions = !canEditProperties(issueDetail.project_id); - const subIssuesCount = issueDetail.sub_issues_count; + const subIssuesCount = issueDetail?.sub_issues_count ?? 0; return ( <> diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx index 4c608f83a..a8a43ced8 100644 --- a/web/components/issues/issue-modal/form.tsx +++ b/web/components/issues/issue-modal/form.tsx @@ -109,6 +109,7 @@ export const IssueFormRoot: FC = observer((props) => { const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); // refs const editorRef = useRef(null); + const submitBtnRef = useRef(null); // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -470,6 +471,7 @@ export const IssueFormRoot: FC = observer((props) => { onChange(description_html); handleFormChange(); }} + onEnterKeyPress={() => submitBtnRef?.current?.click()} ref={editorRef} tabIndex={getTabIndex("description_html")} placeholder={getDescriptionPlaceholder} @@ -770,6 +772,7 @@ export const IssueFormRoot: FC = observer((props) => { variant="primary" type="submit" size="sm" + ref={submitBtnRef} loading={isSubmitting} tabIndex={isDraft ? getTabIndex("submit_button") : getTabIndex("draft_button")} > diff --git a/web/components/issues/peek-overview/properties.tsx b/web/components/issues/peek-overview/properties.tsx index cbc35b5e2..5364dacbb 100644 --- a/web/components/issues/peek-overview/properties.tsx +++ b/web/components/issues/peek-overview/properties.tsx @@ -10,10 +10,11 @@ import { XCircle, CalendarClock, CalendarCheck2, + Users, } from "lucide-react"; // hooks // ui icons -import { DiceIcon, DoubleCircleIcon, UserGroupIcon, ContrastIcon, RelatedIcon } from "@plane/ui"; +import { DiceIcon, DoubleCircleIcon, ContrastIcon, RelatedIcon } from "@plane/ui"; // components import { DateDropdown, @@ -94,7 +95,7 @@ export const PeekOverviewProperties: FC = observer((pro {/* assignee */}
- + Assignees
= observer((props) => { undefined; const subIssueHelpers = subIssueHelpersByIssueId(parentIssueId); - const subIssueCount = issue?.sub_issues_count || 0; + const subIssueCount = issue?.sub_issues_count ?? 0; const handleIssuePeekOverview = (issue: TIssue) => workspaceSlug && diff --git a/web/components/modules/module-card-item.tsx b/web/components/modules/module-card-item.tsx index 851af664a..ce63238b7 100644 --- a/web/components/modules/module-card-item.tsx +++ b/web/components/modules/module-card-item.tsx @@ -2,7 +2,7 @@ import React, { useRef } from "react"; import { observer } from "mobx-react-lite"; import Link from "next/link"; import { useRouter } from "next/router"; -import { CalendarCheck2, CalendarClock, Info, MoveRight, User2 } from "lucide-react"; +import { CalendarCheck2, CalendarClock, Info, MoveRight, SquareUser } from "lucide-react"; // ui import { LayersIcon, Tooltip, setPromiseToast } from "@plane/ui"; // components @@ -188,9 +188,7 @@ export const ModuleCardItem: React.FC = observer((props) => { ) : ( - - - + )}
diff --git a/web/components/modules/module-list-item-action.tsx b/web/components/modules/module-list-item-action.tsx index fa7d71577..2a5a3cdd0 100644 --- a/web/components/modules/module-list-item-action.tsx +++ b/web/components/modules/module-list-item-action.tsx @@ -2,7 +2,7 @@ import React, { FC } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // icons -import { CalendarCheck2, CalendarClock, MoveRight, User2 } from "lucide-react"; +import { CalendarCheck2, CalendarClock, MoveRight, SquareUser } from "lucide-react"; // types import { IModule } from "@plane/types"; // ui @@ -140,9 +140,7 @@ export const ModuleListItemAction: FC = observer((props) => { ) : ( - - - + )} diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index d2c847ecc..3482afb1b 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; @@ -11,8 +12,9 @@ import { Info, LinkIcon, Plus, + SquareUser, Trash2, - UserCircle2, + Users, } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; import { IIssueFilterOptions, ILinkDetails, IModule, ModuleLink } from "@plane/types"; @@ -23,7 +25,6 @@ import { LayersIcon, CustomSelect, ModuleStatusIcon, - UserGroupIcon, TOAST_TYPE, setToast, ArchiveIcon, @@ -252,14 +253,18 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const handleFiltersUpdate = useCallback( (key: keyof IIssueFilterOptions, value: string | string[]) => { if (!workspaceSlug || !projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; + let newValues = issueFilters?.filters?.[key] ?? []; if (Array.isArray(value)) { - // this validation is majorly for the filter start_date, target_date custom - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); + if (key === "state") { + if (isEqual(newValues, value)) newValues = []; + else newValues = value; + } else { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } } else { if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); else newValues.push(value); @@ -493,7 +498,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => {
- + Lead
= observer((props) => { buttonVariant="background-with-text" placeholder="Lead" disabled={!isEditingAllowed || isArchived} + icon={SquareUser} />
)} @@ -518,7 +524,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => {
- + Members
; editorRef: React.RefObject; readOnlyEditorRef: React.RefObject; - swrPageDetails: TPage | undefined; - handleSubmit: () => void; markings: IMarking[]; - pageStore: IPageStore; + page: IPageStore; sidePeekVisible: boolean; handleEditorReady: (value: boolean) => void; handleReadOnlyEditorReady: (value: boolean) => void; @@ -43,15 +39,12 @@ type Props = { export const PageEditorBody: React.FC = observer((props) => { const { - control, handleReadOnlyEditorReady, handleEditorReady, editorRef, markings, readOnlyEditorRef, - handleSubmit, - pageStore, - swrPageDetails, + page, sidePeekVisible, updateMarkings, } = props; @@ -67,11 +60,19 @@ export const PageEditorBody: React.FC = observer((props) => { } = useMember(); // derived values const workspaceId = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "" : ""; - const pageTitle = pageStore?.name ?? ""; - const pageDescription = pageStore?.description_html; - const { description_html, isContentEditable, updateTitle, isSubmitting, setIsSubmitting } = pageStore; + const pageId = page?.id; + const pageTitle = page?.name ?? ""; + const pageDescription = page?.description_html; + const { isContentEditable, updateTitle, setIsSubmitting } = page; const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : []; const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite); + // project-description + const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS } = usePageDescription({ + editorRef, + page, + projectId, + workspaceSlug, + }); // use-mention const { mentionHighlights, mentionSuggestions } = useMention({ workspaceSlug: workspaceSlug?.toString() ?? "", @@ -82,13 +83,11 @@ export const PageEditorBody: React.FC = observer((props) => { // page filters const { isFullWidth } = usePageFilters(); - const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); - useEffect(() => { - updateMarkings(description_html ?? "

"); - }, [description_html, updateMarkings]); + updateMarkings(pageDescription ?? "

"); + }, [pageDescription, updateMarkings]); - if (pageDescription === undefined) return ; + if (pageId === undefined || !pageDescriptionYJS || !isDescriptionReady) return ; return (
@@ -122,35 +121,24 @@ export const PageEditorBody: React.FC = observer((props) => { />
{isContentEditable ? ( - ( -

"} - value={swrPageDetails?.description_html ?? "

"} - ref={editorRef} - containerClassName="p-0 pb-64" - editorClassName="lg:px-10 pl-8" - onChange={(_description_json, description_html) => { - setIsSubmitting("submitting"); - setShowAlert(true); - onChange(description_html); - handleSubmit(); - }} - mentionHandler={{ - highlights: mentionHighlights, - suggestions: mentionSuggestions, - }} - /> - )} + ) : ( = observer((props) => { initialValue={pageDescription ?? "

"} handleEditorReady={handleReadOnlyEditorReady} containerClassName="p-0 pb-64 border-none" - editorClassName="lg:px-10 pl-8" + editorClassName="pl-10" mentionHandler={{ highlights: mentionHighlights, }} diff --git a/web/components/pages/editor/header/extra-options.tsx b/web/components/pages/editor/header/extra-options.tsx index dee77d19e..632799846 100644 --- a/web/components/pages/editor/header/extra-options.tsx +++ b/web/components/pages/editor/header/extra-options.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { Lock, RefreshCw, Sparkle } from "lucide-react"; +import { Lock, Sparkle } from "lucide-react"; // editor import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/document-editor"; // ui @@ -9,7 +9,6 @@ import { ArchiveIcon } from "@plane/ui"; import { GptAssistantPopover } from "@/components/core"; import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages"; // helpers -import { cn } from "@/helpers/common.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; // hooks import { useInstance } from "@/hooks/store"; @@ -19,20 +18,19 @@ import { IPageStore } from "@/store/pages/page.store"; type Props = { editorRef: React.RefObject; handleDuplicatePage: () => void; - isSyncing: boolean; - pageStore: IPageStore; + page: IPageStore; projectId: string; readOnlyEditorRef: React.RefObject; }; export const PageExtraOptions: React.FC = observer((props) => { - const { editorRef, handleDuplicatePage, isSyncing, pageStore, projectId, readOnlyEditorRef } = props; + const { editorRef, handleDuplicatePage, page, projectId, readOnlyEditorRef } = props; // states const [gptModalOpen, setGptModal] = useState(false); // store hooks const { config } = useInstance(); // derived values - const { archived_at, isContentEditable, isSubmitting, is_locked } = pageStore; + const { archived_at, isContentEditable, is_locked } = page; const handleAiAssistance = async (response: string) => { if (!editorRef) return; @@ -41,22 +39,6 @@ export const PageExtraOptions: React.FC = observer((props) => { return (
- {isContentEditable && ( -
- {isSubmitting === "submitting" && } - {isSubmitting === "submitting" ? "Saving..." : "Saved"} -
- )} - {isSyncing && ( -
- - Syncing... -
- )} {is_locked && (
@@ -93,11 +75,11 @@ export const PageExtraOptions: React.FC = observer((props) => { className="!min-w-[38rem]" /> )} - +
); diff --git a/web/components/pages/editor/header/info-popover.tsx b/web/components/pages/editor/header/info-popover.tsx index 55b4b28fb..270da934b 100644 --- a/web/components/pages/editor/header/info-popover.tsx +++ b/web/components/pages/editor/header/info-popover.tsx @@ -7,11 +7,11 @@ import { renderFormattedDate } from "@/helpers/date-time.helper"; import { IPageStore } from "@/store/pages/page.store"; type Props = { - pageStore: IPageStore; + page: IPageStore; }; export const PageInfoPopover: React.FC = (props) => { - const { pageStore } = props; + const { page } = props; // states const [isPopoverOpen, setIsPopoverOpen] = useState(false); // refs @@ -22,7 +22,7 @@ export const PageInfoPopover: React.FC = (props) => { placement: "bottom-start", }); // derived values - const { created_at, updated_at } = pageStore; + const { created_at, updated_at } = page; return (
setIsPopoverOpen(true)} onMouseLeave={() => setIsPopoverOpen(false)}> diff --git a/web/components/pages/editor/header/mobile-root.tsx b/web/components/pages/editor/header/mobile-root.tsx index de0425879..44cd9d38b 100644 --- a/web/components/pages/editor/header/mobile-root.tsx +++ b/web/components/pages/editor/header/mobile-root.tsx @@ -11,9 +11,8 @@ type Props = { editorRef: React.RefObject; readOnlyEditorRef: React.RefObject; handleDuplicatePage: () => void; - isSyncing: boolean; markings: IMarking[]; - pageStore: IPageStore; + page: IPageStore; projectId: string; sidePeekVisible: boolean; setSidePeekVisible: (sidePeekState: boolean) => void; @@ -29,14 +28,13 @@ export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { markings, readOnlyEditorReady, handleDuplicatePage, - isSyncing, - pageStore, + page, projectId, sidePeekVisible, setSidePeekVisible, } = props; // derived values - const { isContentEditable } = pageStore; + const { isContentEditable } = page; // page filters const { isFullWidth } = usePageFilters(); @@ -57,8 +55,7 @@ export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { diff --git a/web/components/pages/editor/header/options-dropdown.tsx b/web/components/pages/editor/header/options-dropdown.tsx index 9d3c8627b..9aeb2a679 100644 --- a/web/components/pages/editor/header/options-dropdown.tsx +++ b/web/components/pages/editor/header/options-dropdown.tsx @@ -16,11 +16,11 @@ import { IPageStore } from "@/store/pages/page.store"; type Props = { editorRef: EditorRefApi | EditorReadOnlyRefApi | null; handleDuplicatePage: () => void; - pageStore: IPageStore; + page: IPageStore; }; export const PageOptionsDropdown: React.FC = observer((props) => { - const { editorRef, handleDuplicatePage, pageStore } = props; + const { editorRef, handleDuplicatePage, page } = props; // store values const { archived_at, @@ -33,7 +33,7 @@ export const PageOptionsDropdown: React.FC = observer((props) => { canCurrentUserDuplicatePage, canCurrentUserLockPage, restore, - } = pageStore; + } = page; // store hooks const { workspaceSlug, projectId } = useAppRouter(); // page filters diff --git a/web/components/pages/editor/header/root.tsx b/web/components/pages/editor/header/root.tsx index 7234f3ad4..7f17c43c3 100644 --- a/web/components/pages/editor/header/root.tsx +++ b/web/components/pages/editor/header/root.tsx @@ -13,9 +13,8 @@ type Props = { editorRef: React.RefObject; readOnlyEditorRef: React.RefObject; handleDuplicatePage: () => void; - isSyncing: boolean; markings: IMarking[]; - pageStore: IPageStore; + page: IPageStore; projectId: string; sidePeekVisible: boolean; setSidePeekVisible: (sidePeekState: boolean) => void; @@ -31,14 +30,13 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { markings, readOnlyEditorReady, handleDuplicatePage, - isSyncing, - pageStore, + page, projectId, sidePeekVisible, setSidePeekVisible, } = props; // derived values - const { isContentEditable } = pageStore; + const { isContentEditable } = page; // page filters const { isFullWidth } = usePageFilters(); @@ -67,8 +65,7 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { @@ -81,8 +78,7 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { readOnlyEditorReady={readOnlyEditorReady} markings={markings} handleDuplicatePage={handleDuplicatePage} - isSyncing={isSyncing} - pageStore={pageStore} + page={page} projectId={projectId} sidePeekVisible={sidePeekVisible} setSidePeekVisible={setSidePeekVisible} diff --git a/web/components/pages/editor/title.tsx b/web/components/pages/editor/title.tsx index f472ecb6d..0c9473690 100644 --- a/web/components/pages/editor/title.tsx +++ b/web/components/pages/editor/title.tsx @@ -33,7 +33,6 @@ export const PageEditorTitle: React.FC = observer((props) => { ) : ( <>