Compare commits

..

33 Commits

Author SHA1 Message Date
Nikhil 002269d1f4 Potential fix for code scanning alert no. 590: URL redirection from remote source
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-02-21 18:46:51 +05:30
Aaryan Khandelwal 6fac320a05 chore: add month and year picker (#6656) 2025-02-21 17:14:28 +05:30
Anmol Singh Bhatia cc7b34e399 [WEB-3439] fix: work item attachment mutation (#6655)
* chore: created by field added to attachment response

* fix: work item attachment mutation
2025-02-21 00:10:16 +05:30
Anmol Singh Bhatia 2d6c26a5d6 [WEB-3436] fix: work item delete permission and header translation (#6654)
* fix: work item header translation

* fix: work item delete permission
2025-02-20 18:32:22 +05:30
Vamsi Krishna f1acd46e15 fix: add favorites (#6652) 2025-02-20 18:24:43 +05:30
Prateek Shourya c023f7d89b chore: update breadcrumb translation of work item detail page (#6653) 2025-02-20 18:24:13 +05:30
Prateek Shourya 8fa45ef9a6 fix: command palette search (#6651) 2025-02-20 17:59:32 +05:30
sriram veeraghanta 8bcc295061 fix: turbo repo upgrade 2025-02-19 22:06:11 +05:30
sriram veeraghanta 1b080012ab fix: react date picker update 2025-02-19 22:04:10 +05:30
Akshita Goyal f6dfca4fdc Fix: project settings pages permissions (#6649)
* fix: Handled workspace switcher closing on click

* fix: permissions for labels dnd + issue state creation
2025-02-19 18:05:43 +05:30
Anmol Singh Bhatia 3de655cbd4 fix: home recent n progress (#6648) 2025-02-19 18:04:26 +05:30
Anmol Singh Bhatia 376f781052 fix: attachment item avatar (#6650) 2025-02-19 18:02:39 +05:30
Aaryan Khandelwal 827f47809b [PE-238] refactor: page store hooks (#6409)
* refactor: page store hooks

* fix: page details instances

* fix: build errors

* refactor: page store hooks

* fix: minor bug
2025-02-19 18:02:14 +05:30
Aaryan Khandelwal dd11ebf335 fix: sticky collapse icon (#6647) 2025-02-19 17:29:45 +05:30
Aaryan Khandelwal 0c35e196be [regression]: space app editor helpers (#6646)
* fix: editor helpers

* fix: animation ref type

* fix: animation ref type
2025-02-19 17:28:55 +05:30
Aaryan Khandelwal 6303847026 fix: editor image block condition (#6645) 2025-02-19 15:49:59 +05:30
Aaryan Khandelwal 214692f5b2 [PE-242, 243] refactor: editor file handling, image upload status (#6442)
* refactor: editor file handling

* refactor: asset store

* refactor: space app file handlers

* fix: separate webhook connection params

* chore: handle undefined status

* chore: add type to upload status

* chore: added transition for upload status update
2025-02-19 15:18:01 +05:30
Prateek Shourya b7198234de chore: minor trasnslation update related to work items (#6643) 2025-02-19 15:14:03 +05:30
Aaryan Khandelwal 7e0ac10fe8 [PE-239] chore: add strictNullCheck flag to the editor package (#6439)
* chore: add strictNullCheck flag

* fix: types and errors

* chore: update error handling
2025-02-19 15:13:37 +05:30
Akshita Goyal f9d154dd82 Fix: date range selector (#6625)
* fix: Handled workspace switcher closing on click

* fix: removed action btns from date range selector

* fix: updated calendar component
2025-02-19 15:01:51 +05:30
Dancia 1c6a2fb7dd Add language translation guidelines (#6639)
* Add language translation guidelines

* fix: minor formatting fix

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2025-02-19 14:54:35 +05:30
Aaryan Khandelwal 5c272db83b fix: app header layer (#6640) 2025-02-19 14:54:15 +05:30
Prateek Shourya 602ae01b0b fix: command modal mutation (#6641)
* fix: command modal mutation

* chore: minor update
2025-02-19 14:43:58 +05:30
Prateek Shourya cd3fa94b9c fix: build error (#6638) 2025-02-19 11:16:57 +05:30
Anmol Singh Bhatia 51c2ea6fcb fix: cmd-k project level action in work item detail page (#6637) 2025-02-19 03:00:39 +05:30
Vipin Chaudhary 64752de3a8 fix: removing self from private project error 404 (#6631) 2025-02-19 02:21:47 +05:30
Anmol Singh Bhatia 84578a2764 fix: undefined workspaceslug (#6636) 2025-02-19 02:20:23 +05:30
M. Palanikannan 126575d22a fix: bubble menu weird flickering fixed (#6591) 2025-02-19 02:09:27 +05:30
Nikhil d3af913ec7 fix: error handling for db based integrity errors (#6632)
* fix: error handling for db based integrity errors

* fix: meta endpoint to return correct error message

* fix: module activity
2025-02-19 02:04:28 +05:30
Anmol Singh Bhatia db4ecee475 fix: inbox count (#6635) 2025-02-19 01:55:46 +05:30
Anmol Singh Bhatia 527c4ece57 [WEB-3422] fix: app sidebar improvements (#6634)
* chore: sidebar project list improvements

* chore: code refactor
2025-02-18 23:40:13 +05:30
Anmol Singh Bhatia 23b0d4339d [WEB-3422] fix: app sidebar fixes and improvements (#6633)
* chore: app sidebar improvements

* chore: overview icon updated
2025-02-18 20:49:17 +05:30
Anmol Singh Bhatia 1478e66dc4 fix: app sidebar fixes and improvements (#6630) 2025-02-18 18:14:31 +05:30
153 changed files with 1532 additions and 850 deletions
+130 -4
View File
@@ -62,17 +62,143 @@ To ensure consistency throughout the source code, please keep these rules in min
- All features or bug fixes must be tested by one or more specs (unit-tests).
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
## Need help? Questions and suggestions
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge).
## Ways to contribute
- Try Plane Cloud and the self hosting platform and give feedback
- Add new integrations
- Add or update translations
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
- Share your thoughts and suggestions with us
- Help create tutorials and blog posts
- Request a feature by submitting a proposal
- Report a bug
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
## Contributing to language support
This guide is designed to help contributors understand how to add or update translations in the application.
### Understanding translation structure
#### File organization
Translations are organized by language in the locales directory. Each language has its own folder containing JSON files for translations. Here's how it looks:
```
packages/i18n/src/locales/
├── en/
│ ├── core.json # Critical translations
│ └── translations.json
├── fr/
│ └── translations.json
└── [language]/
└── translations.json
```
#### Nested structure
To keep translations organized, we use a nested structure for keys. This makes it easier to manage and locate specific translations. For example:
```json
{
"issue": {
"label": "Work item",
"title": {
"label": "Work item title"
}
}
}
```
### Translation formatting guide
We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/) to handle dynamic content, such as variables and pluralization. Here's how to format your translations:
#### Examples
- **Simple variables**
```json
{
"greeting": "Hello, {name}!"
}
```
- **Pluralization**
```json
{
"items": "{count, plural, one {Work item} other {Work items}}"
}
```
### Contributing guidelines
#### Updating existing translations
1. Locate the key in `locales/<language>/translations.json`.
2. Update the value while ensuring the key structure remains intact.
3. Preserve any existing ICU formats (e.g., variables, pluralization).
#### Adding new translation keys
1. When introducing a new key, ensure it is added to **all** language files, even if translations are not immediately available. Use English as a placeholder if needed.
2. Keep the nesting structure consistent across all languages.
3. If the new key requires dynamic content (e.g., variables or pluralization), ensure the ICU format is applied uniformly across all languages.
### Adding new languages
Adding a new language involves several steps to ensure it integrates seamlessly with the project. Follow these instructions carefully:
1. **Update type definitions**
Add the new language to the TLanguage type in the language definitions file:
```typescript
// types/language.ts
export type TLanguage = "en" | "fr" | "your-lang";
```
2. **Add language configuration**
Include the new language in the list of supported languages:
```typescript
// constants/language.ts
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "English", value: "en" },
{ label: "Your Language", value: "your-lang" }
];
```
3. **Create translation files**
1. Create a new folder for your language under locales (e.g., `locales/your-lang/`).
2. Add a `translations.json` file inside the folder.
3. Copy the structure from an existing translation file and translate all keys.
4. **Update import logic**
Modify the language import logic to include your new language:
```typescript
private importLanguageFile(language: TLanguage): Promise<any> {
switch (language) {
case "your-lang":
return import("../locales/your-lang/translations.json");
// ...
}
}
```
### Quality checklist
Before submitting your contribution, please ensure the following:
- All translation keys exist in every language file.
- Nested structures match across all language files.
- ICU message formats are correctly implemented.
- All languages load without errors in the application.
- Dynamic values and pluralization work as expected.
- There are no missing or untranslated keys.
#### Pro tips
- When in doubt, refer to the English translations for context.
- Verify pluralization works with different numbers.
- Ensure dynamic values (e.g., `{name}`) are correctly interpolated.
- Double-check that nested key access paths are accurate.
Happy translating! 🌍✨
## Need help? Questions and suggestions
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge).
+1
View File
@@ -522,6 +522,7 @@ class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
"asset",
"attributes",
# "issue_id",
"created_by",
"updated_at",
"updated_by",
"asset_url",
@@ -9,11 +9,10 @@ from celery import shared_task
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
# Module imports
from plane.utils.valid_uuid import is_valid_uuid
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
# Module imports
from plane.db.models import (
CommentReaction,
Cycle,
@@ -1569,10 +1568,6 @@ def issue_activity(
try:
issue_activities = []
# Validate UUIDs
if not is_valid_uuid(project_id):
return
project = Project.objects.get(pk=project_id)
workspace_id = project.workspace_id
+31 -25
View File
@@ -11,6 +11,7 @@ from django.core.exceptions import ValidationError
from django.utils import timezone
from django.contrib.auth.hashers import make_password
from django.contrib.auth import logout
from django.utils.http import url_has_allowed_host_and_scheme
# Third party imports
from rest_framework.response import Response
@@ -248,11 +249,12 @@ class InstanceAdminSignInEndpoint(View):
error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"],
error_message="INSTANCE_NOT_CONFIGURED",
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
base_url = base_host(request=request, is_admin=True)
redirect_url = urljoin(base_url, "?" + urlencode(exc.get_error_dict()))
if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
return HttpResponseRedirect(redirect_url)
else:
return HttpResponseRedirect('/')
# Get email and password
email = request.POST.get("email", False)
@@ -265,11 +267,12 @@ class InstanceAdminSignInEndpoint(View):
error_message="REQUIRED_ADMIN_EMAIL_PASSWORD",
payload={"email": email},
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
base_url = base_host(request=request, is_admin=True)
redirect_url = urljoin(base_url, "?" + urlencode(exc.get_error_dict()))
if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
return HttpResponseRedirect(redirect_url)
else:
return HttpResponseRedirect('/')
# Validate the email
email = email.strip().lower()
@@ -281,11 +284,12 @@ class InstanceAdminSignInEndpoint(View):
error_message="INVALID_ADMIN_EMAIL",
payload={"email": email},
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
base_url = base_host(request=request, is_admin=True)
redirect_url = urljoin(base_url, "?" + urlencode(exc.get_error_dict()))
if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
return HttpResponseRedirect(redirect_url)
else:
return HttpResponseRedirect('/')
# Fetch the user
user = User.objects.filter(email=email).first()
@@ -297,11 +301,12 @@ class InstanceAdminSignInEndpoint(View):
error_message="ADMIN_USER_DOES_NOT_EXIST",
payload={"email": email},
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
base_url = base_host(request=request, is_admin=True)
redirect_url = urljoin(base_url, "?" + urlencode(exc.get_error_dict()))
if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
return HttpResponseRedirect(redirect_url)
else:
return HttpResponseRedirect('/')
# is_active
if not user.is_active:
@@ -309,11 +314,12 @@ class InstanceAdminSignInEndpoint(View):
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
error_message="ADMIN_USER_DEACTIVATED",
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
base_url = base_host(request=request, is_admin=True)
redirect_url = urljoin(base_url, "?" + urlencode(exc.get_error_dict()))
if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
return HttpResponseRedirect(redirect_url)
else:
return HttpResponseRedirect('/')
# Check password of the user
if not user.check_password(password):
-9
View File
@@ -1,9 +0,0 @@
import uuid
def is_valid_uuid(uuid_to_test, version=4):
try:
# check for validity of Uuid
uuid_obj = uuid.UUID(uuid_to_test, version=version)
except ValueError:
return False
return True
+1 -1
View File
@@ -22,7 +22,7 @@
"devDependencies": {
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"turbo": "^2.4.1"
"turbo": "^2.4.2"
},
"resolutions": {
"nanoid": "3.3.8",
+3 -10
View File
@@ -271,12 +271,6 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspa
href: `/workspace-views/all-issues/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
},
"active-cycles": {
key: "active_cycles",
labelTranslationKey: "cycles",
href: `/active-cycles/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
},
analytics: {
key: "analytics",
labelTranslationKey: "analytics",
@@ -298,7 +292,6 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspa
};
export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["views"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["active-cycles"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["analytics"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["your-work"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["drafts"],
@@ -312,8 +305,8 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac
href: `/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
},
notifications: {
key: "notifications",
inbox: {
key: "inbox",
labelTranslationKey: "notification.label",
href: `/notifications/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
@@ -328,6 +321,6 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac
export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["home"],
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["notifications"],
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["inbox"],
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["projects"],
];
@@ -1,3 +1,4 @@
import { Extensions } from "@tiptap/core";
import React from "react";
// components
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
@@ -35,7 +36,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
user,
} = props;
const extensions = [];
const extensions: Extensions = [];
if (embedHandler?.issue) {
extensions.push(
IssueWidget({
@@ -1,3 +1,4 @@
import { Extensions } from "@tiptap/core";
import { forwardRef, MutableRefObject } from "react";
// components
import { PageRenderer } from "@/components/editors";
@@ -10,7 +11,13 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import { EditorReadOnlyRefApi, TDisplayConfig, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
import {
EditorReadOnlyRefApi,
TDisplayConfig,
TExtensions,
TReadOnlyFileHandler,
TReadOnlyMentionHandler,
} from "@/types";
interface IDocumentReadOnlyEditor {
disabledExtensions: TExtensions[];
@@ -20,7 +27,7 @@ interface IDocumentReadOnlyEditor {
displayConfig?: TDisplayConfig;
editorClassName?: string;
embedHandler: any;
fileHandler: Pick<TFileHandler, "getAssetSrc">;
fileHandler: TReadOnlyFileHandler;
tabIndex?: number;
handleEditorReady?: (value: boolean) => void;
mentionHandler: TReadOnlyMentionHandler;
@@ -41,7 +48,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
initialValue,
mentionHandler,
} = props;
const extensions = [];
const extensions: Extensions = [];
if (embedHandler?.issue) {
extensions.push(
IssueWidget({
@@ -23,6 +23,7 @@ export const AIFeaturesMenu: React.FC<Props> = (props) => {
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - Tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
@@ -34,6 +34,7 @@ export const BlockMenu = (props: BlockMenuProps) => {
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - Tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
@@ -6,15 +6,15 @@ import { cn } from "@plane/utils";
import { TextAlignItem } from "@/components/menus";
// types
import { TEditorCommands } from "@/types";
import { EditorStateType } from "./root";
type Props = {
editor: Editor;
onClose: () => void;
editorState: EditorStateType;
};
export const TextAlignmentSelector: React.FC<Props> = (props) => {
const { editor, onClose } = props;
const { editor, editorState } = props;
const menuItem = TextAlignItem(editor);
const textAlignmentOptions: {
@@ -32,10 +32,7 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
menuItem.command({
alignment: "left",
}),
isActive: () =>
menuItem.isActive({
alignment: "left",
}),
isActive: () => editorState.left,
},
{
itemKey: "text-align",
@@ -45,10 +42,7 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
menuItem.command({
alignment: "center",
}),
isActive: () =>
menuItem.isActive({
alignment: "center",
}),
isActive: () => editorState.center,
},
{
itemKey: "text-align",
@@ -58,10 +52,7 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
menuItem.command({
alignment: "right",
}),
isActive: () =>
menuItem.isActive({
alignment: "right",
}),
isActive: () => editorState.right,
},
];
@@ -74,7 +65,6 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
onClick={(e) => {
e.stopPropagation();
item.command();
onClose();
}}
className={cn(
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
@@ -1,24 +1,26 @@
import { Dispatch, FC, SetStateAction } from "react";
import { Editor } from "@tiptap/react";
import { ALargeSmall, Ban } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
// plane utils
import { cn } from "@plane/utils";
// constants
import { COLORS_LIST } from "@/constants/common";
// helpers
import { BackgroundColorItem, TextColorItem } from "../menu-items";
import { EditorStateType } from "./root";
type Props = {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
editorState: EditorStateType;
};
export const BubbleMenuColorSelector: FC<Props> = (props) => {
const { editor, isOpen, setIsOpen } = props;
const { editor, isOpen, setIsOpen, editorState } = props;
const activeTextColor = COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key }));
const activeBackgroundColor = COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key }));
const activeTextColor = editorState.color;
const activeBackgroundColor = editorState.backgroundColor;
return (
<div className="relative h-full">
@@ -1,9 +1,10 @@
import { FC, useEffect, useState } from "react";
import { BubbleMenu, BubbleMenuProps, Editor, isNodeSelection } from "@tiptap/react";
import { BubbleMenu, BubbleMenuProps, Editor, isNodeSelection, useEditorState } from "@tiptap/react";
import { FC, useEffect, useState, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
// components
import {
BackgroundColorItem,
BoldItem,
BubbleMenuColorSelector,
BubbleMenuLinkSelector,
@@ -11,8 +12,12 @@ import {
CodeItem,
ItalicItem,
StrikeThroughItem,
TextAlignItem,
TextColorItem,
UnderLineItem,
} from "@/components/menus";
// constants
import { COLORS_LIST } from "@/constants/common";
// extensions
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
// local components
@@ -20,16 +25,61 @@ import { TextAlignmentSelector } from "./alignment-selector";
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
// states
export interface EditorStateType {
code: boolean;
bold: boolean;
italic: boolean;
underline: boolean;
strike: boolean;
left: boolean;
right: boolean;
center: boolean;
color: { key: string; label: string; textColor: string; backgroundColor: string } | undefined;
backgroundColor:
| {
key: string;
label: string;
textColor: string;
backgroundColor: string;
}
| undefined;
}
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Editor }) => {
const menuRef = useRef<HTMLDivElement>(null);
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
const basicFormattingOptions = props.editor.isActive("code")
? [CodeItem(props.editor)]
: [BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor)];
const formattingItems = {
code: CodeItem(props.editor),
bold: BoldItem(props.editor),
italic: ItalicItem(props.editor),
underline: UnderLineItem(props.editor),
strike: StrikeThroughItem(props.editor),
textAlign: TextAlignItem(props.editor),
};
const editorState: EditorStateType = useEditorState({
editor: props.editor,
selector: ({ editor }: { editor: Editor }) => ({
code: formattingItems.code.isActive(),
bold: formattingItems.bold.isActive(),
italic: formattingItems.italic.isActive(),
underline: formattingItems.underline.isActive(),
strike: formattingItems.strike.isActive(),
left: formattingItems.textAlign.isActive({ alignment: "left" }),
right: formattingItems.textAlign.isActive({ alignment: "right" }),
center: formattingItems.textAlign.isActive({ alignment: "center" }),
color: COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })),
backgroundColor: COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })),
}),
});
const basicFormattingOptions = editorState.code
? [formattingItems.code]
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strike];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
@@ -51,6 +101,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
duration: [300, 0],
onHidden: () => {
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
@@ -60,7 +111,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
};
useEffect(() => {
function handleMouseDown() {
function handleMouseDown(e: MouseEvent) {
if (menuRef.current?.contains(e.target as Node)) return;
function handleMouseMove() {
if (!props.editor.state.selection.empty) {
setIsSelecting(true);
@@ -70,7 +123,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
function handleMouseUp() {
setIsSelecting(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
}
@@ -84,27 +136,28 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, []);
}, [props.editor]);
return (
<BubbleMenu {...bubbleMenuProps}>
{!isSelecting && (
<div className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg">
<div
ref={menuRef}
className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
>
<div className="px-2">
{!props.editor.isActive("table") && (
<BubbleMenuNodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen((prev) => !prev);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
)}
<BubbleMenuNodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen((prev) => !prev);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
</div>
<div className="px-2">
{!props.editor.isActive("code") && (
{!editorState.code && (
<div className="px-2">
<BubbleMenuLinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
@@ -114,21 +167,22 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
setIsColorSelectorOpen(false);
}}
/>
)}
</div>
<div className="px-2">
{!props.editor.isActive("code") && (
</div>
)}
{!editorState.code && (
<div className="px-2">
<BubbleMenuColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
editorState={editorState}
setIsOpen={() => {
setIsColorSelectorOpen((prev) => !prev);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
}}
/>
)}
</div>
</div>
)}
<div className="flex gap-0.5 px-2">
{basicFormattingOptions.map((item) => (
<button
@@ -141,7 +195,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
className={cn(
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(""),
"bg-custom-background-80 text-custom-text-100": editorState[item.key],
}
)}
>
@@ -149,15 +203,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
</button>
))}
</div>
<TextAlignmentSelector
editor={props.editor}
onClose={() => {
const editor = props.editor as Editor;
if (!editor) return;
const pos = editor.state.selection.to;
editor.commands.setTextSelection(pos ?? 0);
}}
/>
<TextAlignmentSelector editor={props.editor} editorState={editorState} />
</div>
)}
</BubbleMenu>
@@ -142,8 +142,8 @@ export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({
icon: UnderlineIcon,
});
export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({
key: "strikethrough",
export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strike"> => ({
key: "strike",
name: "Strikethrough",
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),
@@ -218,24 +218,33 @@ export const HorizontalRuleItem = (editor: Editor) =>
export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => ({
key: "text-color",
name: "Color",
isActive: ({ color }) => editor.isActive("customColor", { color }),
command: ({ color }) => toggleTextColor(color, editor),
isActive: (props) => editor.isActive("customColor", { color: props?.color }),
command: (props) => {
if (!props) return;
toggleTextColor(props.color, editor);
},
icon: Palette,
});
export const BackgroundColorItem = (editor: Editor): EditorMenuItem<"background-color"> => ({
key: "background-color",
name: "Background color",
isActive: ({ color }) => editor.isActive("customColor", { backgroundColor: color }),
command: ({ color }) => toggleBackgroundColor(color, editor),
isActive: (props) => editor.isActive("customColor", { backgroundColor: props?.color }),
command: (props) => {
if (!props) return;
toggleBackgroundColor(props.color, editor);
},
icon: Palette,
});
export const TextAlignItem = (editor: Editor): EditorMenuItem<"text-align"> => ({
key: "text-align",
name: "Text align",
isActive: ({ alignment }) => editor.isActive({ textAlign: alignment }),
command: ({ alignment }) => setTextAlign(alignment, editor),
isActive: (props) => editor.isActive({ textAlign: props?.alignment }),
command: (props) => {
if (!props) return;
setTextAlign(props.alignment, editor);
},
icon: AlignCenter,
});
+2 -2
View File
@@ -87,7 +87,7 @@ export const TEXT_ALIGNMENT_ITEMS: ToolbarMenuItem<"text-align">[] = [
},
];
const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strikethrough">[] = [
const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strike">[] = [
{
itemKey: "bold",
renderKey: "bold",
@@ -113,7 +113,7 @@ const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strik
editors: ["lite", "document"],
},
{
itemKey: "strikethrough",
itemKey: "strike",
renderKey: "strikethrough",
name: "Strikethrough",
icon: Strikethrough,
@@ -106,6 +106,8 @@ export const CustomColorExtension = Mark.create({
};
},
// @ts-expect-error types are incorrect
// TODO: check this and update types
parseHTML() {
return [
{
@@ -1,9 +1,10 @@
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
import { NodeSelection } from "@tiptap/pm/state";
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
// plane utils
import { cn } from "@plane/utils";
// extensions
import { CustoBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
import { ImageUploadStatus } from "./upload-status";
const MIN_SIZE = 100;
@@ -38,11 +39,11 @@ const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefin
};
type CustomImageBlockProps = CustoBaseImageNodeViewProps & {
imageFromFileSystem: string;
imageFromFileSystem: string | undefined;
setFailedToLoadImage: (isError: boolean) => void;
editorContainer: HTMLDivElement | null;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
src: string;
src: string | undefined;
};
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
@@ -62,8 +63,8 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
// states
const [size, setSize] = useState<Size>({
width: ensurePixelString(nodeWidth, "35%"),
height: ensurePixelString(nodeHeight, "auto"),
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
height: ensurePixelString(nodeHeight, "auto") ?? "auto",
aspectRatio: nodeAspectRatio || null,
});
const [isResizing, setIsResizing] = useState(false);
@@ -144,8 +145,8 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
useLayoutEffect(() => {
setSize((prevSize) => ({
...prevSize,
width: ensurePixelString(nodeWidth),
height: ensurePixelString(nodeHeight),
width: ensurePixelString(nodeWidth) ?? "35%",
height: ensurePixelString(nodeHeight) ?? "auto",
aspectRatio: nodeAspectRatio,
}));
}, [nodeWidth, nodeHeight, nodeAspectRatio]);
@@ -210,6 +211,8 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
const showImageLoader = !(resolvedImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad;
// show the image upload status only when the resolvedImageSrc is not ready
const showUploadStatus = !resolvedImageSrc;
// show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageUtils = resolvedImageSrc && initialResizeComplete;
// show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
@@ -247,7 +250,16 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
try {
setHasErroredOnFirstLoad(true);
// this is a type error from tiptap, don't remove await until it's fixed
if (!imgNodeSrc) {
throw new Error("No source image to restore from");
}
await editor?.commands.restoreImage?.(imgNodeSrc);
if (!imageRef.current) {
throw new Error("Image reference not found");
}
if (!resolvedImageSrc) {
throw new Error("No resolved image source available");
}
imageRef.current.src = resolvedImageSrc;
} catch {
// if the image failed to even restore, then show the error state
@@ -270,6 +282,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}}
/>
{showUploadStatus && node.attrs.id && <ImageUploadStatus editor={editor} nodeId={node.attrs.id} />}
{showImageUtils && (
<ImageToolbarRoot
containerClassName={
@@ -277,7 +290,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
}
image={{
src: resolvedImageSrc,
aspectRatio: size.aspectRatio,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
height: size.height,
width: size.width,
}}
@@ -1,13 +1,13 @@
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
import { ImageIcon } from "lucide-react";
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
// constants
import { ACCEPTED_FILE_EXTENSIONS } from "@/constants/config";
// hooks
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
// extensions
import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
// hooks
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
type CustomImageUploaderProps = CustoBaseImageNodeViewProps & {
maxFileSize: number;
@@ -38,6 +38,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const onUpload = useCallback(
(url: string) => {
if (url) {
if (!imageEntityId) return;
setIsUploaded(true);
// Update the node view's src attribute post upload
updateAttributes({ src: url });
@@ -68,6 +69,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
);
// hooks
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({
blockId: imageEntityId ?? "",
editor,
loadImageFromFileSystem,
maxFileSize,
@@ -82,7 +84,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
// the meta data of the image component
const meta = useMemo(
() => imageComponentImageFileMap?.get(imageEntityId),
() => imageComponentImageFileMap?.get(imageEntityId ?? ""),
[imageComponentImageFileMap, imageEntityId]
);
@@ -96,7 +98,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
if (meta.hasOpenedFileInputOnce) return;
fileInputRef.current.click();
hasTriggeredFilePickerRef.current = true;
imageComponentImageFileMap?.set(imageEntityId, { ...meta, hasOpenedFileInputOnce: true });
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
}
}
}, [meta, uploadFile, imageComponentImageFileMap]);
@@ -29,7 +29,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
const dragStart = useRef({ x: 0, y: 0 });
const dragOffset = useRef({ x: 0, y: 0 });
const modalRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]);
@@ -0,0 +1,60 @@
import { Editor } from "@tiptap/core";
import { useEditorState } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
type Props = {
editor: Editor;
nodeId: string;
};
export const ImageUploadStatus: React.FC<Props> = (props) => {
const { editor, nodeId } = props;
// Displayed status that will animate smoothly
const [displayStatus, setDisplayStatus] = useState(0);
// Animation frame ID for cleanup
const animationFrameRef = useRef<number | null>(null);
// subscribe to image upload status
const uploadStatus: number | undefined = useEditorState({
editor,
selector: ({ editor }) => editor.storage.imageComponent.assetsUploadStatus[nodeId],
});
useEffect(() => {
const animateToValue = (start: number, end: number, startTime: number) => {
const duration = 200;
const animation = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function for smooth animation
const easeOutCubic = 1 - Math.pow(1 - progress, 3);
// Calculate current display value
const currentValue = Math.floor(start + (end - start) * easeOutCubic);
setDisplayStatus(currentValue);
// Continue animation if not complete
if (progress < 1) {
animationFrameRef.current = requestAnimationFrame((time) => animation(time));
}
};
animationFrameRef.current = requestAnimationFrame((time) => animation(time));
};
animateToValue(displayStatus, uploadStatus == undefined ? 100 : uploadStatus, performance.now());
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [uploadStatus]);
if (uploadStatus === undefined) return null;
return (
<div className="absolute top-1 right-1 z-20 bg-black/60 rounded text-xs font-medium w-10 text-center">
{displayStatus}%
</div>
);
};
@@ -4,12 +4,12 @@ import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// extensions
import { CustomImageNode } from "@/extensions/custom-image";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// plugins
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image";
// types
import { TFileHandler } from "@/types";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
export type InsertImageComponentProps = {
file?: File;
@@ -21,7 +21,8 @@ declare module "@tiptap/core" {
interface Commands<ReturnType> {
imageComponent: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
uploadImage: (file: File) => () => Promise<string> | undefined;
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
updateAssetsUploadStatus: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
getImageSource?: (path: string) => () => Promise<string>;
restoreImage: (src: string) => () => Promise<void>;
};
@@ -32,6 +33,7 @@ export const getImageComponentImageFileMap = (editor: Editor) =>
(editor.storage.imageComponent as UploadImageExtensionStorage | undefined)?.fileMap;
export interface UploadImageExtensionStorage {
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
fileMap: Map<string, UploadEntity>;
}
@@ -39,6 +41,7 @@ export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File })
export const CustomImageExtension = (props: TFileHandler) => {
const {
assetsUploadStatus,
getAssetSrc,
upload,
delete: deleteImageFn,
@@ -105,7 +108,6 @@ export const CustomImageExtension = (props: TFileHandler) => {
this.editor.state.doc.descendants((node) => {
if (node.type.name === this.name) {
if (!node.attrs.src?.startsWith("http")) return;
imageSources.add(node.attrs.src);
}
});
@@ -128,13 +130,14 @@ export const CustomImageExtension = (props: TFileHandler) => {
markdown: {
serialize() {},
},
assetsUploadStatus,
};
},
addCommands() {
return {
insertImageComponent:
(props: { file?: File; pos?: number; event: "insert" | "drop" }) =>
(props) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
if (
@@ -182,12 +185,15 @@ export const CustomImageExtension = (props: TFileHandler) => {
attrs: attributes,
});
},
uploadImage: (file: File) => async () => {
const fileUrl = await upload(file);
uploadImage: (blockId, file) => async () => {
const fileUrl = await upload(blockId, file);
return fileUrl;
},
getImageSource: (path: string) => async () => await getAssetSrc(path),
restoreImage: (src: string) => async () => {
updateAssetsUploadStatus: (updatedStatus) => () => {
this.storage.assetsUploadStatus = updatedStatus;
},
getImageSource: (path) => async () => await getAssetSrc(path),
restoreImage: (src) => async () => {
await restoreImageFn(src);
},
};
@@ -4,9 +4,9 @@ import { ReactNodeViewRenderer } from "@tiptap/react";
// components
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image";
// types
import { TFileHandler } from "@/types";
import { TReadOnlyFileHandler } from "@/types";
export const CustomReadOnlyImageExtension = (props: Pick<TFileHandler, "getAssetSrc">) => {
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
const { getAssetSrc } = props;
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
@@ -56,6 +56,7 @@ export const CustomReadOnlyImageExtension = (props: Pick<TFileHandler, "getAsset
markdown: {
serialize() {},
},
assetsUploadStatus: {},
};
},
@@ -32,10 +32,10 @@ import {
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
// plane editor extensions
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
type TArguments = {
disabledExtensions: TExtensions[];
@@ -148,7 +148,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomMentionExtension(mentionHandler),
Placeholder.configure({
placeholder: ({ editor, node }) => {
if (!editor.isEditable) return;
if (!editor.isEditable) return "";
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
@@ -3,9 +3,9 @@ import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
// types
import { TFileHandler } from "@/types";
import { TReadOnlyFileHandler } from "@/types";
export const ReadOnlyImageExtension = (props: Pick<TFileHandler, "getAssetSrc">) => {
export const ReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
const { getAssetSrc } = props;
return Image.extend({
@@ -27,14 +27,14 @@ import {
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
import { TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
// plane editor extensions
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
fileHandler: Pick<TFileHandler, "getAssetSrc">;
fileHandler: TReadOnlyFileHandler;
mentionHandler: TReadOnlyMentionHandler;
};
@@ -94,16 +94,12 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
},
}),
CustomTypographyExtension,
ReadOnlyImageExtension({
getAssetSrc: fileHandler.getAssetSrc,
}).configure({
ReadOnlyImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomReadOnlyImageExtension({
getAssetSrc: fileHandler.getAssetSrc,
}),
CustomReadOnlyImageExtension(fileHandler),
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -69,6 +69,7 @@ const renderItems = () => {
const tippyContainer =
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]');
// @ts-expect-error - Tippy types are incorrect
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: tippyContainer,
@@ -1,4 +1,4 @@
import { Extension } from "@tiptap/core";
import { Extension, InputRule } from "@tiptap/core";
import {
TypographyOptions,
emDash,
@@ -26,7 +26,7 @@ export const CustomTypographyExtension = Extension.create<TypographyOptions>({
name: "typography",
addInputRules() {
const rules = [];
const rules: InputRule[] = [];
if (this.options.emDash !== false) {
rules.push(emDash(this.options.emDash));
+11 -4
View File
@@ -125,16 +125,23 @@ export const useEditor = (props: CustomEditorProps) => {
}
}, [editor, value, id]);
// update assets upload status
useEffect(() => {
if (!editor) return;
const assetsUploadStatus = fileHandler.assetsUploadStatus;
editor.commands.updateAssetsUploadStatus(assetsUploadStatus);
}, [editor, fileHandler.assetsUploadStatus]);
useImperativeHandle(
forwardedRef,
() => ({
blur: () => editor.commands.blur(),
blur: () => editor?.commands.blur(),
scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) {
const resolvedPos = pos ?? editor.state.selection.from;
const resolvedPos = pos ?? editor?.state.selection.from;
if (!editor || !resolvedPos) return;
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
},
getCurrentCursorPosition: () => editor.state.selection.from,
getCurrentCursorPosition: () => editor?.state.selection.from,
clearEditor: (emitUpdate = false) => {
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
@@ -142,7 +149,7 @@ export const useEditor = (props: CustomEditorProps) => {
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
},
setEditorValueAtCursorPosition: (content: string) => {
if (editor.state.selection) {
if (editor?.state.selection) {
insertContentAtSavedSelection(editor, content);
}
},
@@ -6,6 +6,7 @@ import { insertImagesSafely } from "@/extensions/drop";
import { isFileValid } from "@/plugins/image";
type TUploaderArgs = {
blockId: string;
editor: Editor;
loadImageFromFileSystem: (file: string) => void;
maxFileSize: number;
@@ -13,7 +14,7 @@ type TUploaderArgs = {
};
export const useUploader = (args: TUploaderArgs) => {
const { editor, loadImageFromFileSystem, maxFileSize, onUpload } = args;
const { blockId, editor, loadImageFromFileSystem, maxFileSize, onUpload } = args;
// states
const [uploading, setUploading] = useState(false);
@@ -49,7 +50,7 @@ export const useUploader = (args: TUploaderArgs) => {
reader.readAsDataURL(fileWithTrimmedName);
// @ts-expect-error - TODO: fix typings, and don't remove await from
// here for now
const url: string = await editor?.commands.uploadImage(fileWithTrimmedName);
const url: string = await editor?.commands.uploadImage(blockId, fileWithTrimmedName);
if (!url) {
throw new Error("Something went wrong while uploading the image");
@@ -11,7 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// props
import { CoreReadOnlyEditorProps } from "@/props";
// types
import type { EditorReadOnlyRefApi, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
import type { EditorReadOnlyRefApi, TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
interface CustomReadOnlyEditorProps {
disabledExtensions: TExtensions[];
@@ -20,7 +20,7 @@ interface CustomReadOnlyEditorProps {
extensions?: Extensions;
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
initialValue?: string;
fileHandler: Pick<TFileHandler, "getAssetSrc">;
fileHandler: TReadOnlyFileHandler;
handleEditorReady?: (value: boolean) => void;
mentionHandler: TReadOnlyMentionHandler;
provider?: HocuspocusProvider;
@@ -99,14 +99,11 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
if (!editor) return;
scrollSummary(editor, marking);
},
getDocumentInfo: () => {
if (!editor) return;
return {
characters: editor.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editor.state),
words: editor.storage?.characterCount?.words?.() ?? 0,
};
},
getDocumentInfo: () => ({
characters: editor.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editor.state),
words: editor.storage?.characterCount?.words?.() ?? 0,
}),
}));
if (!editor) {
@@ -9,6 +9,7 @@ import {
TExtensions,
TFileHandler,
TMentionHandler,
TReadOnlyFileHandler,
TReadOnlyMentionHandler,
TRealtimeConfig,
TUserDetails,
@@ -43,7 +44,7 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
};
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
fileHandler: Pick<TFileHandler, "getAssetSrc">;
fileHandler: TReadOnlyFileHandler;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
mentionHandler: TReadOnlyMentionHandler;
};
+6 -2
View File
@@ -1,11 +1,15 @@
import { DeleteImage, RestoreImage, UploadImage } from "@/types";
export type TFileHandler = {
export type TReadOnlyFileHandler = {
getAssetSrc: (path: string) => Promise<string>;
restore: RestoreImage;
};
export type TFileHandler = TReadOnlyFileHandler & {
assetsUploadStatus: Record<string, number>; // blockId => progress percentage
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
validation: {
/**
* @description max file size in bytes
+8 -3
View File
@@ -16,6 +16,7 @@ import {
TExtensions,
TFileHandler,
TMentionHandler,
TReadOnlyFileHandler,
TReadOnlyMentionHandler,
TServerHandler,
} from "@/types";
@@ -31,7 +32,7 @@ export type TEditorCommands =
| "bold"
| "italic"
| "underline"
| "strikethrough"
| "strike"
| "bulleted-list"
| "numbered-list"
| "to-do-list"
@@ -44,12 +45,16 @@ export type TEditorCommands =
| "text-color"
| "background-color"
| "text-align"
| "callout";
| "callout"
| "attachment";
export type TCommandExtraProps = {
image: {
savedSelection: Selection | null;
};
attachment: {
savedSelection: Selection | null;
};
"text-color": {
color: string | undefined;
};
@@ -155,7 +160,7 @@ export interface IReadOnlyEditorProps {
disabledExtensions: TExtensions[];
displayConfig?: TDisplayConfig;
editorClassName?: string;
fileHandler: Pick<TFileHandler, "getAssetSrc">;
fileHandler: TReadOnlyFileHandler;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
id: string;
initialValue: string;
+1 -1
View File
@@ -2,4 +2,4 @@ export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
export type UploadImage = (file: File) => Promise<string>;
export type UploadImage = (blockId: string, file: File) => Promise<string>;
+1
View File
@@ -12,6 +12,7 @@
"@/styles/*": ["src/styles/*"],
"@/plane-editor/*": ["src/ce/*"]
},
"strictNullChecks": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*", "index.d.ts"],
+1 -1
View File
@@ -9,7 +9,7 @@
"workspace": "Workspace",
"views": "Views",
"analytics": "Analytics",
"work_items": "Work items",
"work_items": "Work Items",
"cycles": "Cycles",
"modules": "Modules",
"intake": "Intake",
@@ -492,6 +492,7 @@
"epic": "Epic",
"epics": "Epics",
"work_item": "Work item",
"work_items": "Work items",
"sub_work_item": "Sub-work item",
"add": "Add",
"warning": "Warning",
@@ -662,6 +662,7 @@
"epic": "Epic",
"epics": "Epics",
"work_item": "Elemento de trabajo",
"work_items": "Elementos de trabajo",
"sub_work_item": "Sub-elemento de trabajo",
"add": "Agregar",
"warning": "Advertencia",
@@ -662,6 +662,7 @@
"epic": "Epic",
"epics": "Epics",
"work_item": "Élément de travail",
"work_items": "Éléments de travail",
"sub_work_item": "Sous-élément de travail",
"add": "Ajouter",
"warning": "Avertissement",
@@ -662,6 +662,7 @@
"epic": "エピック",
"epics": "エピック",
"work_item": "作業項目",
"work_items": "作業項目",
"sub_work_item": "サブ作業項目",
"add": "追加",
"warning": "警告",
@@ -662,6 +662,7 @@
"epic": "史诗",
"epics": "史诗",
"work_item": "工作项",
"work_items": "工作项",
"sub_work_item": "子工作项",
"add": "添加",
"warning": "警告",
+1
View File
@@ -11,6 +11,7 @@ export type TIssueAttachment = {
// required
updated_at: string;
updated_by: string;
created_by: string;
};
export type TIssueAttachmentUploadResponse = TFileSignedURLResponse & {
+1
View File
@@ -40,6 +40,7 @@
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"react-color": "^2.19.3",
"react-day-picker": "9.5.0",
"react-popper": "^2.3.0",
"sonner": "^1.4.41",
"tailwind-merge": "^2.0.0",
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { ChevronLeft } from "lucide-react";
import * as React from "react";
import { DayPicker } from "react-day-picker";
import { cn } from "../helpers";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
export const Calendar = ({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) => (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
// classNames={{
// months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
// month: "space-y-4",
// // caption: "flex justify-center pt-1 relative items-center",
// // caption_label: "hidden",
// nav: "box-border absolute top-[1.2rem] right-[1rem] flex items-center",
// button_next:
// "size-[1.25rem] border-none bg-none p-[0.25rem] m-0 cursor-pointer inline-flex items-center justify-center relative appearance-none rounded-sm hover:bg-custom-background-80 focus-visible:bg-custom-background-80",
// button_previous:
// "size-[1.25rem] border-none bg-none p-[0.25rem] m-0 cursor-pointer inline-flex items-center justify-center relative appearance-none rounded-sm hover:bg-custom-background-80 focus-visible:bg-custom-background-80",
// chevron: "m-0 ml-1 size-[0.75rem]",
// // nav_button: cn("h-10 bg-transparent p-0 opacity-50 hover:opacity-100"),
// // nav_button_previous: "absolute left-1",
// // nav_button_next: "absolute right-1",
// table: "w-full border-collapse space-y-1",
// head_row: "flex w-full items-center",
// head_cell: "rounded-md w-10 text-[10px] text-center m-auto font-semibold uppercase",
// row: "flex w-full mt-2",
// cell: cn(
// "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-custom-primary-100/50 [&:has([aria-selected].day-range-end)]:rounded-r-full",
// props.mode === "range"
// ? "[&:has(>.day-range-end)]:rounded-r-full [&:has(>.day-range-start)]:rounded-l-full first:[&:has([aria-selected])]:rounded-l-full last:[&:has([aria-selected])]:rounded-r-full"
// : "[&:has([aria-selected])]:rounded-full [&:has([aria-selected])]:bg-custom-primary-100 [&:has([aria-selected])]:text-white"
// ),
// // day_button:
// // "size-10 flex items-center justify-center overflow-hidden box-border m-0 border-2 border-transparent rounded-full",
// day: "size-10 p-0 font-normal aria-selected:opacity-100 rounded-full hover:bg-custom-primary-100/60",
// day_range_start: "day-range-start bg-custom-primary-100 text-white",
// day_range_end: "day-range-end bg-custom-primary-100 text-white",
// day_selected: "",
// day_today:
// "relative after:content-[''] after:absolute after:m-auto after:left-1/3 after:bottom-[6px] after:w-[6px] after:h-[6px] after:bg-custom-primary-100/50 after:rounded-full after:translate-x-1/2 after:translate-y-1/2",
// day_outside: "day-outside",
// day_disabled: "opacity-50 hover:!bg-transparent",
// day_range_middle: "text-black",
// day_hidden: "invisible",
// caption_dropdowns: "inline-flex bg-transparent",
// dropdown_root: "m-0 relative inline-flex items-center",
// dropdowns: "relative inline-flex items-center",
// dropdown:
// "appearance-none absolute z-[2] top-0 bottom-0 left-0 w-full m-0 p-0 opacity-0 border-none text-[1rem] cursor-pointer bg-transparent hover:bg-custom-background-80",
// months_dropdown: "capitalize",
// caption_label:
// "z-[1] inline-flex items-center gap-[0.25rem] m-0 py-0 px-[0.25rem] whitespace-nowrap border-2 border-transparent font-semibold bg-transparent rounded",
// ...classNames,
// }}
components={{
Chevron: ({ className, ...props }) => (
<ChevronLeft
className={cn(
"size-4",
{
"rotate-180": props.orientation === "right",
"-rotate-90": props.orientation === "down",
},
className
)}
{...props}
/>
),
}}
{...props}
/>
);
+2 -2
View File
@@ -3,11 +3,11 @@ import * as React from "react";
import { ISvgIcons } from "./type";
export const OverviewIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg viewBox="0 0 16 16" className={className} xmlns="http://www.w3.org/2000/svg" {...rest}>
<svg viewBox="0 0 12 12" className={className} fill="none" xmlns="http://www.w3.org/2000/svg" {...rest}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.5 3C2.5 2.86739 2.55268 2.74021 2.64645 2.64645C2.74021 2.55268 2.86739 2.5 3 2.5H3.5C9.02267 2.5 13.5 6.97733 13.5 12.5V13C13.5 13.1326 13.4473 13.2598 13.3536 13.3536C13.2598 13.4473 13.1326 13.5 13 13.5H12.5C12.3674 13.5 12.2402 13.4473 12.1464 13.3536C12.0527 13.2598 12 13.1326 12 13V12.5C12 7.80533 8.19467 4 3.5 4H3C2.86739 4 2.74021 3.94732 2.64645 3.85355C2.55268 3.75979 2.5 3.63261 2.5 3.5V3ZM2.5 7.5C2.5 7.36739 2.55268 7.24022 2.64645 7.14645C2.74021 7.05268 2.86739 7 3 7H3.5C4.22227 7 4.93747 7.14226 5.60476 7.41866C6.27205 7.69506 6.87837 8.10019 7.38909 8.61091C7.89981 9.12164 8.30494 9.72795 8.58134 10.3952C8.85774 11.0625 9 11.7777 9 12.5V13C9 13.1326 8.94732 13.2598 8.85355 13.3536C8.75978 13.4473 8.63261 13.5 8.5 13.5H8C7.86739 13.5 7.74022 13.4473 7.64645 13.3536C7.55268 13.2598 7.5 13.1326 7.5 13V12.5C7.5 11.4391 7.07857 10.4217 6.32843 9.67157C5.57828 8.92143 4.56087 8.5 3.5 8.5H3C2.86739 8.5 2.74021 8.44732 2.64645 8.35355C2.55268 8.25978 2.5 8.13261 2.5 8V7.5ZM2.5 12.5C2.5 12.2348 2.60536 11.9804 2.79289 11.7929C2.98043 11.6054 3.23478 11.5 3.5 11.5C3.76522 11.5 4.01957 11.6054 4.20711 11.7929C4.39464 11.9804 4.5 12.2348 4.5 12.5C4.5 12.7652 4.39464 13.0196 4.20711 13.2071C4.01957 13.3946 3.76522 13.5 3.5 13.5C3.23478 13.5 2.98043 13.3946 2.79289 13.2071C2.60536 13.0196 2.5 12.7652 2.5 12.5Z"
d="M0.5 1C0.5 0.867392 0.552678 0.740215 0.646447 0.646447C0.740215 0.552678 0.867392 0.5 1 0.5H1.5C7.02267 0.5 11.5 4.97733 11.5 10.5V11C11.5 11.1326 11.4473 11.2598 11.3536 11.3536C11.2598 11.4473 11.1326 11.5 11 11.5H10.5C10.3674 11.5 10.2402 11.4473 10.1464 11.3536C10.0527 11.2598 10 11.1326 10 11V10.5C10 5.80533 6.19467 2 1.5 2H1C0.867392 2 0.740215 1.94732 0.646447 1.85355C0.552678 1.75979 0.5 1.63261 0.5 1.5V1ZM0.5 5.5C0.5 5.36739 0.552678 5.24022 0.646447 5.14645C0.740215 5.05268 0.867392 5 1 5H1.5C2.22227 5 2.93747 5.14226 3.60476 5.41866C4.27205 5.69506 4.87837 6.10019 5.38909 6.61091C5.89981 7.12164 6.30494 7.72795 6.58134 8.39524C6.85774 9.06253 7 9.77773 7 10.5V11C7 11.1326 6.94732 11.2598 6.85355 11.3536C6.75978 11.4473 6.63261 11.5 6.5 11.5H6C5.86739 11.5 5.74022 11.4473 5.64645 11.3536C5.55268 11.2598 5.5 11.1326 5.5 11V10.5C5.5 9.43913 5.07857 8.42172 4.32843 7.67157C3.57828 6.92143 2.56087 6.5 1.5 6.5H1C0.867392 6.5 0.740215 6.44732 0.646447 6.35355C0.552678 6.25978 0.5 6.13261 0.5 6V5.5ZM0.5 10.5C0.5 10.2348 0.605357 9.98043 0.792893 9.79289C0.98043 9.60536 1.23478 9.5 1.5 9.5C1.76522 9.5 2.01957 9.60536 2.20711 9.79289C2.39464 9.98043 2.5 10.2348 2.5 10.5C2.5 10.7652 2.39464 11.0196 2.20711 11.2071C2.01957 11.3946 1.76522 11.5 1.5 11.5C1.23478 11.5 0.98043 11.3946 0.792893 11.2071C0.605357 11.0196 0.5 10.7652 0.5 10.5Z"
fill="currentColor"
/>
</svg>
+1
View File
@@ -30,3 +30,4 @@ export * from "./content-wrapper";
export * from "./card";
export * from "./tag";
export * from "./tabs";
export * from "./calendar";
@@ -1,6 +1,6 @@
import React from "react";
// editor
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TFileHandler } from "@plane/editor";
// components
import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor";
// helpers
@@ -14,7 +14,7 @@ interface LiteTextEditorWrapperProps
workspaceId: string;
isSubmitting?: boolean;
showSubmitButton?: boolean;
uploadFile: (file: File) => Promise<string>;
uploadFile: TFileHandler["upload"];
}
export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapperProps>((props, ref) => {
@@ -12,15 +12,17 @@ type LiteTextReadOnlyEditorWrapperProps = Omit<
"disabledExtensions" | "fileHandler" | "mentionHandler"
> & {
anchor: string;
workspaceId: string;
};
export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(
({ anchor, ...props }, ref) => (
({ anchor, workspaceId, ...props }, ref) => (
<LiteTextReadOnlyEditorWithRef
ref={ref}
disabledExtensions={[]}
fileHandler={getReadOnlyEditorFileHandlers({
anchor,
workspaceId,
})}
mentionHandler={{
renderComponent: (props) => <EditorMentionsRoot {...props} />,
@@ -1,6 +1,6 @@
import React, { forwardRef } from "react";
// editor
import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor";
import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef, TFileHandler } from "@plane/editor";
// components
import { EditorMentionsRoot } from "@/components/editor";
// helpers
@@ -8,11 +8,13 @@ import { getEditorFileHandlers } from "@/helpers/editor.helper";
interface RichTextEditorWrapperProps
extends Omit<IRichTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
uploadFile: (file: File) => Promise<string>;
anchor: string;
uploadFile: TFileHandler["upload"];
workspaceId: string;
}
export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {
const { containerClassName, uploadFile, ...rest } = props;
const { anchor, containerClassName, uploadFile, workspaceId, ...rest } = props;
return (
<RichTextEditorWithRef
@@ -22,9 +24,9 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
ref={ref}
disabledExtensions={[]}
fileHandler={getEditorFileHandlers({
anchor,
uploadFile,
workspaceId: "",
anchor: "",
workspaceId,
})}
{...rest}
containerClassName={containerClassName}
@@ -12,15 +12,17 @@ type RichTextReadOnlyEditorWrapperProps = Omit<
"disabledExtensions" | "fileHandler" | "mentionHandler"
> & {
anchor: string;
workspaceId: string;
};
export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, RichTextReadOnlyEditorWrapperProps>(
({ anchor, ...props }, ref) => (
({ anchor, workspaceId, ...props }, ref) => (
<RichTextReadOnlyEditorWithRef
ref={ref}
disabledExtensions={[]}
fileHandler={getReadOnlyEditorFileHandlers({
anchor,
workspaceId,
})}
mentionHandler={{
renderComponent: (props) => <EditorMentionsRoot {...props} />,
@@ -90,7 +90,7 @@ export const AddComment: React.FC<Props> = observer((props) => {
onChange={(comment_json, comment_html) => onChange(comment_html)}
isSubmitting={isSubmitting}
placeholder="Add comment..."
uploadFile={async (file) => {
uploadFile={async (blockId, file) => {
const { asset_id } = await uploadCommentAsset(file, anchor);
setUploadAssetIds((prev) => [...prev, asset_id]);
return asset_id;
@@ -112,7 +112,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
onChange={(comment_json, comment_html) => onChange(comment_html)}
isSubmitting={isSubmitting}
showSubmitButton={false}
uploadFile={async (file) => {
uploadFile={async (blockId, file) => {
const { asset_id } = await uploadCommentAsset(file, anchor, comment.id);
return asset_id;
}}
@@ -140,6 +140,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
<div className={`${isEditing ? "hidden" : ""}`}>
<LiteTextReadOnlyEditor
anchor={anchor}
workspaceId={workspaceID?.toString() ?? ""}
ref={showEditorRef}
id={comment.id}
initialValue={comment.comment_html}
@@ -13,9 +13,9 @@ type Props = {
export const PeekOverviewIssueDetails: React.FC<Props> = observer((props) => {
const { anchor, issueDetails } = props;
const { project_details } = usePublish(anchor);
// store hooks
const { project_details, workspace: workspaceID } = usePublish(anchor);
// derived values
const description = issueDetails.description_html;
return (
@@ -35,6 +35,7 @@ export const PeekOverviewIssueDetails: React.FC<Props> = observer((props) => {
? "<p></p>"
: description
}
workspaceId={workspaceID?.toString() ?? ""}
/>
)}
<IssueReactions anchor={anchor} />
+32 -37
View File
@@ -1,6 +1,6 @@
// plane internal
import { MAX_FILE_SIZE } from "@plane/constants";
import { TFileHandler } from "@plane/editor";
import { TFileHandler, TReadOnlyFileHandler } from "@plane/editor";
import { SitesFileService } from "@plane/services";
// helpers
import { getFileURL } from "@/helpers/file.helper";
@@ -18,10 +18,35 @@ export const getEditorAssetSrc = (anchor: string, assetId: string): string | und
type TArgs = {
anchor: string;
uploadFile: (file: File) => Promise<string>;
uploadFile: TFileHandler["upload"];
workspaceId: string;
};
/**
* @description this function returns the file handler required by the read-only editors
*/
export const getReadOnlyEditorFileHandlers = (args: Pick<TArgs, "anchor" | "workspaceId">): TReadOnlyFileHandler => {
const { anchor, workspaceId } = args;
return {
getAssetSrc: async (path) => {
if (!path) return "";
if (path?.startsWith("http")) {
return path;
} else {
return getEditorAssetSrc(anchor, path) ?? "";
}
},
restore: async (src: string) => {
if (src?.startsWith("http")) {
await sitesFileService.restoreOldEditorAsset(workspaceId, src);
} else {
await sitesFileService.restoreNewAsset(anchor, src);
}
},
};
};
/**
* @description this function returns the file handler required by the editors
* @param {TArgs} args
@@ -30,14 +55,11 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
const { anchor, uploadFile, workspaceId } = args;
return {
getAssetSrc: async (path) => {
if (!path) return "";
if (path?.startsWith("http")) {
return path;
} else {
return getEditorAssetSrc(anchor, path) ?? "";
}
},
...getReadOnlyEditorFileHandlers({
anchor,
workspaceId,
}),
assetsUploadStatus: {},
upload: uploadFile,
delete: async (src: string) => {
if (src?.startsWith("http")) {
@@ -46,36 +68,9 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
await sitesFileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? "");
}
},
restore: async (src: string) => {
if (src?.startsWith("http")) {
await sitesFileService.restoreOldEditorAsset(workspaceId, src);
} else {
await sitesFileService.restoreNewAsset(anchor, src);
}
},
cancel: sitesFileService.cancelUpload,
validation: {
maxFileSize: MAX_FILE_SIZE,
},
};
};
/**
* @description this function returns the file handler required by the read-only editors
*/
export const getReadOnlyEditorFileHandlers = (
args: Pick<TArgs, "anchor">
): { getAssetSrc: TFileHandler["getAssetSrc"] } => {
const { anchor } = args;
return {
getAssetSrc: async (path) => {
if (!path) return "";
if (path?.startsWith("http")) {
return path;
} else {
return getEditorAssetSrc(anchor, path) ?? "";
}
},
};
};
@@ -3,7 +3,8 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Briefcase } from "lucide-react";
// ui
// plane imports
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs, LayersIcon, Header, Logo } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
@@ -17,6 +18,7 @@ export const ProjectIssueDetailsHeader = observer(() => {
const router = useAppRouter();
const { workspaceSlug, workItem } = useParams();
// store hooks
const { t } = useTranslation();
const { getProjectById, loader } = useProject();
const {
issue: { getIssueById, getIssueIdByIdentifier },
@@ -61,7 +63,7 @@ export const ProjectIssueDetailsHeader = observer(() => {
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/issues`}
label="Issues"
label={t("common.work_items")}
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
@@ -14,6 +14,7 @@ import { CreateProjectModal } from "@/components/project";
import { SidebarProjectsListItem } from "@/components/workspace";
// hooks
import { orderJoinedProjects } from "@/helpers/project.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
import { useAppTheme, useProject, useUserPermissions } from "@/hooks/store";
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
import { TProject } from "@/plane-web/types";
@@ -85,6 +86,15 @@ export const ExtendedProjectSidebar = observer(() => {
"extended-project-sidebar-toggle"
);
const handleCopyText = (projectId: string) => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("link_copied"),
message: t("project_link_copied_to_clipboard"),
});
});
};
return (
<>
{workspaceSlug && (
@@ -98,16 +108,16 @@ export const ExtendedProjectSidebar = observer(() => {
<div
ref={extendedProjectSidebarRef}
className={cn(
"fixed top-0 h-full z-[19] flex flex-col gap-2 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md",
"fixed top-0 h-full z-[19] flex flex-col gap-2 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 shadow-md",
{
"translate-x-0 opacity-100": extendedProjectSidebarCollapsed,
"-translate-x-full opacity-0": !extendedProjectSidebarCollapsed,
"translate-x-0 opacity-100 pointer-events-auto": extendedProjectSidebarCollapsed,
"-translate-x-full opacity-0 pointer-events-none": !extendedProjectSidebarCollapsed,
"left-[70px]": sidebarCollapsed,
"left-[250px]": !sidebarCollapsed,
}
)}
>
<div className="flex flex-col gap-1 w-full">
<div className="flex flex-col gap-1 w-full sticky top-4 pt-0 px-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-custom-text-300 py-1.5">Projects</span>
{isAuthorizedUser && (
@@ -135,17 +145,18 @@ export const ExtendedProjectSidebar = observer(() => {
/>
</div>
</div>
<div className="flex flex-col gap-0.5">
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 px-4">
{filteredProjects.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => {}}
handleCopyText={() => handleCopyText(projectId)}
projectListType={"JOINED"}
disableDrag={false}
disableDrop={false}
isLastChild={index === joinedProjects.length - 1}
handleOnProjectDrop={handleOnProjectDrop}
renderInExtendedSidebar
/>
))}
</div>
@@ -104,10 +104,10 @@ export const ExtendedAppSidebar = observer(() => {
<div
ref={extendedSidebarRef}
className={cn(
"fixed top-0 h-full z-[19] flex flex-col w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md",
"fixed top-0 h-full z-[19] flex flex-col gap-0.5 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md pb-6",
{
"translate-x-0 opacity-100": extendedSidebarCollapsed,
"-translate-x-full opacity-0": !extendedSidebarCollapsed,
"translate-x-0 opacity-100 pointer-events-auto": extendedSidebarCollapsed,
"-translate-x-full opacity-0 pointer-events-none": !extendedSidebarCollapsed,
"left-[70px]": sidebarCollapsed,
"left-[250px]": !sidebarCollapsed,
}
@@ -29,7 +29,7 @@ const CycleDetailPage = observer(() => {
const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false");
useCyclesDetails({
workspaceSlug: workspaceSlug.toString(),
workspaceSlug: workspaceSlug?.toString(),
projectId: projectId.toString(),
cycleId: cycleId.toString(),
});
@@ -6,7 +6,7 @@ import Link from "next/link";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane types
import { TSearchEntityRequestPayload } from "@plane/types";
import { TSearchEntityRequestPayload, TWebhookConnectionQueryParams } from "@plane/types";
import { EFileAssetType } from "@plane/types/src/enums";
// plane ui
import { getButtonStyling } from "@plane/ui";
@@ -17,31 +17,32 @@ import { LogoSpinner } from "@/components/common";
import { PageHead } from "@/components/core";
import { IssuePeekOverview } from "@/components/issues";
import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages";
// helpers
import { getEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useProjectPage, useProjectPages, useWorkspace } from "@/hooks/store";
import { useEditorConfig } from "@/hooks/editor";
import { useEditorAsset, useWorkspace } from "@/hooks/store";
// plane web hooks
import { useFileSize } from "@/plane-web/hooks/use-file-size";
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { FileService } from "@/services/file.service";
import { ProjectPageService, ProjectPageVersionService } from "@/services/page";
const workspaceService = new WorkspaceService();
const fileService = new FileService();
const projectPageService = new ProjectPageService();
const projectPageVersionService = new ProjectPageVersionService();
const PageDetailsPage = observer(() => {
const { workspaceSlug, projectId, pageId } = useParams();
// store hooks
const { createPage, getPageById } = useProjectPages();
const page = useProjectPage(pageId?.toString() ?? "");
const { createPage, fetchPageDetails } = usePageStore(EPageStoreType.PROJECT);
const page = usePage({
pageId: pageId?.toString() ?? "",
storeType: EPageStoreType.PROJECT,
});
const { getWorkspaceBySlug } = useWorkspace();
const { uploadEditorAsset } = useEditorAsset();
// derived values
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
const { canCurrentUserAccessPage, id, name, updateDescription } = page;
const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {};
// entity search handler
const fetchEntityCallback = useCallback(
async (payload: TSearchEntityRequestPayload) =>
@@ -51,13 +52,13 @@ const PageDetailsPage = observer(() => {
}),
[projectId, workspaceSlug]
);
// file size
const { maxFileSize } = useFileSize();
// editor config
const { getEditorFileHandlers } = useEditorConfig();
// fetch page details
const { error: pageDetailsError } = useSWR(
workspaceSlug && projectId && pageId ? `PAGE_DETAILS_${pageId}` : null,
workspaceSlug && projectId && pageId
? () => getPageById(workspaceSlug?.toString(), projectId?.toString(), pageId.toString())
? () => fetchPageDetails(workspaceSlug?.toString(), projectId?.toString(), pageId.toString())
: null,
{
revalidateIfStale: true,
@@ -74,8 +75,8 @@ const PageDetailsPage = observer(() => {
return await projectPageVersionService.fetchAllVersions(workspaceSlug.toString(), projectId.toString(), pageId);
},
fetchDescriptionBinary: async () => {
if (!workspaceSlug || !projectId || !page.id) return;
return await projectPageService.fetchDescriptionBinary(workspaceSlug.toString(), projectId.toString(), page.id);
if (!workspaceSlug || !projectId || !id) return;
return await projectPageService.fetchDescriptionBinary(workspaceSlug.toString(), projectId.toString(), id);
},
fetchEntity: fetchEntityCallback,
fetchVersionDetails: async (pageId, versionId) => {
@@ -88,38 +89,42 @@ const PageDetailsPage = observer(() => {
);
},
getRedirectionLink: (pageId) => `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`,
updateDescription,
updateDescription: updateDescription ?? (async () => {}),
}),
[createPage, fetchEntityCallback, page.id, projectId, updateDescription, workspaceSlug]
[createPage, fetchEntityCallback, id, projectId, updateDescription, workspaceSlug]
);
// page root config
const pageRootConfig: TPageRootConfig = useMemo(
() => ({
fileHandler: getEditorFileHandlers({
maxFileSize,
projectId: projectId?.toString() ?? "",
uploadFile: async (file) => {
const { asset_id } = await fileService.uploadProjectAsset(
workspaceSlug?.toString() ?? "",
projectId?.toString() ?? "",
{
uploadFile: async (blockId, file) => {
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: id ?? "",
entity_type: EFileAssetType.PAGE_DESCRIPTION,
},
file
);
file,
projectId: projectId?.toString() ?? "",
workspaceSlug: workspaceSlug?.toString() ?? "",
});
return asset_id;
},
workspaceId,
workspaceSlug: workspaceSlug?.toString() ?? "",
}),
webhookConnectionParams: {
documentType: "project_page",
projectId: projectId?.toString() ?? "",
workspaceSlug: workspaceSlug?.toString() ?? "",
},
}),
[id, maxFileSize, projectId, workspaceId, workspaceSlug]
[getEditorFileHandlers, id, projectId, uploadEditorAsset, workspaceId, workspaceSlug]
);
const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo(
() => ({
documentType: "project_page",
projectId: projectId?.toString() ?? "",
workspaceSlug: workspaceSlug?.toString() ?? "",
}),
[projectId, workspaceSlug]
);
if ((!page || !id) && !pageDetailsError)
@@ -145,6 +150,8 @@ const PageDetailsPage = observer(() => {
</div>
);
if (!page) return null;
return (
<>
<PageHead title={name} />
@@ -154,6 +161,8 @@ const PageDetailsPage = observer(() => {
config={pageRootConfig}
handlers={pageRootHandlers}
page={page}
storeType={EPageStoreType.PROJECT}
webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
<IssuePeekOverview />
@@ -15,11 +15,13 @@ import { PageEditInformationPopover } from "@/components/pages";
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
import { getPageName } from "@/helpers/page.helper";
// hooks
import { useProjectPage, useProject } from "@/hooks/store";
import { useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
// plane web hooks
import { EPageStoreType, usePage } from "@/plane-web/hooks/store";
export interface IPagesHeaderProps {
showButton?: boolean;
@@ -32,7 +34,12 @@ export const PageDetailsHeader = observer(() => {
const [isOpen, setIsOpen] = useState(false);
// store hooks
const { currentProjectDetails, loader } = useProject();
const page = useProjectPage(pageId?.toString() ?? "");
const page = usePage({
pageId: pageId?.toString() ?? "",
storeType: EPageStoreType.PROJECT,
});
if (!page) return null;
// derived values
const { name, logo_props, updatePageLogo, isContentEditable } = page;
// use platform
const { isMobile } = usePlatformOS();
@@ -13,9 +13,11 @@ import { Breadcrumbs, Button, Header, setToast, TOAST_TYPE } from "@plane/ui";
// helpers
import { BreadcrumbLink } from "@/components/common";
// hooks
import { useEventTracker, useProject, useProjectPages } from "@/hooks/store";
import { useEventTracker, useProject } from "@/hooks/store";
// plane web
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
// plane web hooks
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
export const PagesListHeader = observer(() => {
// states
@@ -27,7 +29,7 @@ export const PagesListHeader = observer(() => {
const pageType = searchParams.get("type");
// store hooks
const { currentProjectDetails, loader } = useProject();
const { canCurrentUserCreatePage, createPage } = useProjectPages();
const { canCurrentUserCreatePage, createPage } = usePageStore(EPageStoreType.PROJECT);
const { setTrackElement } = useEventTracker();
// handle page create
const handleCreatePage = async () => {
@@ -14,6 +14,8 @@ import { PagesListRoot, PagesListView } from "@/components/pages";
import { useProject, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// plane web hooks
import { EPageStoreType } from "@/plane-web/hooks/store";
const ProjectPagesPage = observer(() => {
// router
@@ -63,11 +65,12 @@ const ProjectPagesPage = observer(() => {
<>
<PageHead title={pageTitle} />
<PagesListView
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
pageType={currentPageType()}
projectId={projectId.toString()}
storeType={EPageStoreType.PROJECT}
workspaceSlug={workspaceSlug.toString()}
>
<PagesListRoot pageType={currentPageType()} />
<PagesListRoot pageType={currentPageType()} storeType={EPageStoreType.PROJECT} />
</PagesListView>
</>
);
@@ -90,11 +90,9 @@ export const AppSidebar: FC = observer(() => {
})}
>
<SidebarMenuItems />
<hr
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
"opacity-0": !sidebarCollapsed,
})}
/>
{sidebarCollapsed && (
<hr className="flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1" />
)}
{/* Favorites Menu */}
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
{/* Teams List */}
@@ -1,6 +1,6 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import useSWR from "swr";
// components
import { BulkDeleteIssuesModal } from "@/components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
@@ -10,17 +10,25 @@ import { useCommandPalette, useIssueDetail, useUser } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
export const IssueLevelModals = observer(() => {
export type TIssueLevelModalsProps = {
projectId: string | undefined;
issueId: string | undefined;
};
export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) => {
const { projectId, issueId } = props;
// router
const pathname = usePathname();
const { workspaceSlug, projectId: paramsProjectId, workItem, cycleId, moduleId } = useParams();
const { workspaceSlug, cycleId, moduleId } = useParams();
const router = useAppRouter();
// store hooks
const { data: currentUser } = useUser();
const {
issue: { getIssueById },
} = useIssueDetail();
const {
issues: { removeIssue },
} = useIssuesStore();
const { fetchIssueWithIdentifier } = useIssueDetail();
const {
isCreateIssueModalOpen,
toggleCreateIssueModal,
@@ -30,21 +38,9 @@ export const IssueLevelModals = observer(() => {
toggleBulkDeleteIssueModal,
} = useCommandPalette();
// derived values
const issueDetails = issueId ? getIssueById(issueId) : undefined;
const isDraftIssue = pathname?.includes("draft-issues") || false;
const projectIdentifier = workItem?.toString().split("-")[0];
const sequence_id = workItem?.toString().split("-")[1];
const { data: issueDetails } = useSWR(
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
workspaceSlug && workItem
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
: null
);
const issueId = issueDetails?.id;
const projectId = paramsProjectId ?? issueDetails?.project_id;
return (
<>
<CreateUpdateIssueModal
@@ -6,6 +6,8 @@ import { CreatePageModal } from "@/components/pages";
import { CreateUpdateProjectViewModal } from "@/components/views";
// hooks
import { useCommandPalette } from "@/hooks/store";
// plane web hooks
import { EPageStoreType } from "@/plane-web/hooks/store";
export type TProjectLevelModalsProps = {
workspaceSlug: string;
@@ -53,6 +55,7 @@ export const ProjectLevelModals = observer((props: TProjectLevelModalsProps) =>
pageAccess={createPageModal.pageAccess}
handleModalClose={() => toggleCreatePageModal({ isOpen: false })}
redirectionEnabled
storeType={EPageStoreType.PROJECT}
/>
</>
);
@@ -6,6 +6,8 @@ import { Tooltip } from "@plane/ui";
import { RichTextReadOnlyEditor } from "@/components/editor";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useWorkspace } from "@/hooks/store";
type Props = {
handleInsertText: (insertOnNextLine: boolean) => void;
@@ -19,6 +21,10 @@ export const AskPiMenu: React.FC<Props> = (props) => {
const { handleInsertText, handleRegenerate, isRegenerating, response, workspaceSlug } = props;
// states
const [query, setQuery] = useState("");
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
return (
<>
@@ -40,6 +46,7 @@ export const AskPiMenu: React.FC<Props> = (props) => {
initialValue={response}
containerClassName="!p-0 border-none"
editorClassName="!pl-0"
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
/>
<div className="mt-3 flex items-center gap-4">
+3 -1
View File
@@ -21,6 +21,7 @@ type Props = {
editorRef: RefObject<EditorRefApi>;
isOpen: boolean;
onClose: () => void;
workspaceId: string;
workspaceSlug: string;
};
@@ -58,7 +59,7 @@ const TONES_LIST = [
];
export const EditorAIMenu: React.FC<Props> = (props) => {
const { editorRef, isOpen, onClose, workspaceSlug } = props;
const { editorRef, isOpen, onClose, workspaceId, workspaceSlug } = props;
// states
const [activeTask, setActiveTask] = useState<AI_EDITOR_TASKS | null>(null);
const [response, setResponse] = useState<string | undefined>(undefined);
@@ -215,6 +216,7 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
initialValue={response}
containerClassName="!p-0 border-none"
editorClassName="!pl-0"
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
/>
<div className="mt-3 flex items-center gap-4">
@@ -7,9 +7,12 @@ import { ProjectNavigation } from "@/components/workspace";
type TProjectItemsRootProps = {
workspaceSlug: string;
projectId: string;
isSidebarCollapsed: boolean;
};
export const ProjectNavigationRoot: FC<TProjectItemsRootProps> = (props) => {
const { workspaceSlug, projectId } = props;
return <ProjectNavigation workspaceSlug={workspaceSlug} projectId={projectId} />;
const { workspaceSlug, projectId, isSidebarCollapsed } = props;
return (
<ProjectNavigation workspaceSlug={workspaceSlug} projectId={projectId} isSidebarCollapsed={isSidebarCollapsed} />
);
};
@@ -169,7 +169,7 @@ export const ExtendedSidebarItem: FC<TExtendedSidebarItemProps> = observer((prop
<button
type="button"
className={cn(
"hidden group-hover/project-item:flex items-center justify-center absolute top-1/2 -left-3 -translate-y-1/2 rounded text-custom-sidebar-text-400 cursor-grab",
"flex items-center justify-center absolute top-1/2 -left-3 -translate-y-1/2 rounded text-custom-sidebar-text-400 cursor-grab",
{
// "cursor-not-allowed opacity-60": project.sort_order === null,
"cursor-grabbing": isDragging,
@@ -199,14 +199,14 @@ export const ExtendedSidebarItem: FC<TExtendedSidebarItemProps> = observer((prop
{isPinned ? (
<Tooltip tooltipContent="Hide tab">
<Eye
className="size-4 flex-shrink-0 invisible group-hover:visible text-custom-text-300 outline-none"
className="size-4 flex-shrink-0 hover:text-custom-text-200 text-custom-text-300 outline-none"
onClick={() => unPinNavigationItem(workspaceSlug.toString(), item.key)}
/>
</Tooltip>
) : (
<Tooltip tooltipContent="Show tab">
<EyeClosed
className="size-4 flex-shrink-0 invisible group-hover:visible text-custom-text-400 outline-none"
className="size-4 flex-shrink-0 hover:text-custom-text-200 text-custom-text-400 outline-none"
onClick={() => pinNavigationItem(workspaceSlug.toString(), item.key)}
/>
</Tooltip>
@@ -6,7 +6,7 @@ export const getSidebarNavigationItemIcon = (key: string, className: string = ""
switch (key) {
case "home":
return <Home className={cn("size-4 flex-shrink-0", className)} />;
case "notifications":
case "inbox":
return <Inbox className={cn("size-4 flex-shrink-0", className)} />;
case "projects":
return <Briefcase className={cn("size-4 flex-shrink-0", className)} />;
@@ -10,10 +10,9 @@ import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/ui";
// components
import { SidebarNavItem } from "@/components/sidebar";
import { NotificationAppSidebarOption } from "@/components/workspace-notifications";
// hooks
import { useAppTheme, useUser, useUserPermissions, useWorkspace } from "@/hooks/store";
// plane web imports
import { UpgradeBadge } from "@/plane-web/components/workspace";
// local imports
import { getSidebarNavigationItemIcon } from "./helper";
@@ -42,7 +41,7 @@ export const SidebarItem: FC<TSidebarItemProps> = observer((props) => {
if (extendedSidebarCollapsed) toggleExtendedSidebar();
};
const staticItems = ["home", "notifications", "pi-chat", "projects"];
const staticItems = ["home", "inbox", "pi-chat", "projects"];
if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) {
return null;
@@ -74,15 +73,15 @@ export const SidebarItem: FC<TSidebarItemProps> = observer((props) => {
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
isActive={isActive}
>
{/* <icon className="size-4" /> */}
<div className="flex items-center gap-1.5 py-[1px]">
{icon}
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>}
</div>
{!sidebarCollapsed && item.key === "active_cycles" && (
<div className="flex-shrink-0">
<UpgradeBadge />
</div>
{item.key === "inbox" && (
<NotificationAppSidebarOption
workspaceSlug={workspaceSlug?.toString()}
isSidebarCollapsed={sidebarCollapsed ?? false}
/>
)}
</SidebarNavItem>
</Link>
+2
View File
@@ -0,0 +1,2 @@
export * from "./use-page-store";
export * from "./use-page";
+24
View File
@@ -0,0 +1,24 @@
import { useContext } from "react";
// context
import { StoreContext } from "@/lib/store-context";
// mobx store
import { IProjectPageStore } from "@/store/pages/project-page.store";
export enum EPageStoreType {
PROJECT = "PROJECT_PAGE",
}
export type TReturnType = {
[EPageStoreType.PROJECT]: IProjectPageStore;
};
export const usePageStore = <T extends EPageStoreType>(storeType: T): TReturnType[T] => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("usePageStore must be used within StoreProvider");
if (storeType === EPageStoreType.PROJECT) {
return context.projectPages;
}
throw new Error(`Invalid store type: ${storeType}`);
};
+23
View File
@@ -0,0 +1,23 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// plane web hooks
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
export type TArgs = {
pageId: string;
storeType: EPageStoreType;
};
export const usePage = (args: TArgs) => {
const { pageId, storeType } = args;
// context
const context = useContext(StoreContext);
// store hooks
const pageStore = usePageStore(storeType);
if (context === undefined) throw new Error("usePage must be used within StoreProvider");
if (!pageId) throw new Error("pageId is required");
return pageStore.getPageById(pageId);
};
@@ -28,6 +28,7 @@ export const CommandPaletteSearchResults: React.FC<Props> = (props) => {
const section = (results.results as any)[key];
const currentSection = commandGroups[key];
if (!currentSection) return null;
if (section.length > 0) {
return (
<Command.Group key={key} heading={`${currentSection.title} search`}>
@@ -51,7 +51,7 @@ const workspaceService = new WorkspaceService();
export const CommandModal: React.FC = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, workItem } = useParams();
const { workspaceSlug, projectId: routerProjectId, workItem } = useParams();
// states
const [placeholder, setPlaceholder] = useState("Type a command or search...");
const [resultsCount, setResultsCount] = useState(0);
@@ -67,7 +67,10 @@ export const CommandModal: React.FC = observer(() => {
// plane hooks
const { t } = useTranslation();
// hooks
const { fetchIssueWithIdentifier } = useIssueDetail();
const {
issue: { getIssueById },
fetchIssueWithIdentifier,
} = useIssueDetail();
const { workspaceProjectIds } = useProject();
const { platform, isMobile } = usePlatformOS();
const { canPerformAnyCreateAction } = useUser();
@@ -75,11 +78,10 @@ export const CommandModal: React.FC = observer(() => {
useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { setTrackElement } = useEventTracker();
const projectIdentifier = workItem?.toString().split("-")[0];
const sequence_id = workItem?.toString().split("-")[1];
const { data: issueDetails } = useSWR(
// fetch work item details using identifier
const { data: workItemDetailsSWR } = useSWR(
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
workspaceSlug && workItem
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
@@ -87,8 +89,9 @@ export const CommandModal: React.FC = observer(() => {
);
// derived values
const issueDetails = workItemDetailsSWR ? getIssueById(workItemDetailsSWR?.id) : null;
const issueId = issueDetails?.id;
const projectId = issueDetails?.project_id;
const projectId = issueDetails?.project_id ?? routerProjectId;
const page = pages[pages.length - 1];
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const { baseTabIndex } = getTabIndex(undefined, isMobile);
@@ -474,6 +477,7 @@ export const CommandModal: React.FC = observer(() => {
<ToggleSwitch
value={isWorkspaceLevel}
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
disabled={!projectId}
size="sm"
/>
</div>
@@ -3,6 +3,7 @@
import React, { useCallback, useEffect, FC, useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// ui
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/ui";
@@ -11,7 +12,14 @@ import { CommandModal, ShortcutsModal } from "@/components/command-palette";
// helpers
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useUser, useAppTheme, useCommandPalette, useUserPermissions } from "@/hooks/store";
import {
useEventTracker,
useUser,
useAppTheme,
useCommandPalette,
useUserPermissions,
useIssueDetail,
} from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import {
@@ -30,8 +38,9 @@ import {
export const CommandPalette: FC = observer(() => {
// router params
const { workspaceSlug, projectId, workItem } = useParams();
const { workspaceSlug, projectId: paramsProjectId, workItem } = useParams();
// store hooks
const { fetchIssueWithIdentifier } = useIssueDetail();
const { toggleSidebar } = useAppTheme();
const { setTrackElement } = useEventTracker();
const { platform } = usePlatformOS();
@@ -40,15 +49,35 @@ export const CommandPalette: FC = observer(() => {
const { allowPermissions } = useUserPermissions();
// derived values
const projectIdentifier = workItem?.toString().split("-")[0];
const sequence_id = workItem?.toString().split("-")[1];
const { data: issueDetails } = useSWR(
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
workspaceSlug && workItem
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
: null
);
const issueId = issueDetails?.id;
const projectId = paramsProjectId?.toString() ?? issueDetails?.project_id;
const canPerformWorkspaceMemberActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const canPerformProjectMemberActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
EUserPermissionsLevel.PROJECT,
workspaceSlug?.toString(),
projectId
);
const canPerformProjectAdminActions = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
workspaceSlug?.toString(),
projectId
);
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
const copyIssueUrlToClipboard = useCallback(() => {
if (!workItem) return;
@@ -236,7 +265,7 @@ export const CommandPalette: FC = observer(() => {
{workspaceSlug && projectId && (
<ProjectLevelModals workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
)}
<IssueLevelModals />
<IssueLevelModals projectId={projectId} issueId={issueId} />
<CommandModal />
</>
);
+1 -1
View File
@@ -36,7 +36,7 @@ export const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
const { isMobile } = usePlatformOS();
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug.toString() ?? activity.workspace_detail?.slug,
workspaceSlug: workspaceSlug?.toString() ?? activity.workspace_detail?.slug,
projectId: activity?.project,
issueId: activity?.issue,
projectIdentifier: activity?.project_detail?.identifier,
@@ -1,17 +1,14 @@
"use client";
import { Fragment } from "react";
import { DayPicker, getDefaultClassNames } from "react-day-picker";
import { Controller, useForm } from "react-hook-form";
import { X } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
import { Button } from "@plane/ui";
import { Button, Calendar } from "@plane/ui";
import { renderFormattedPayloadDate, renderFormattedDate, getDate } from "@/helpers/date-time.helper";
import { DateFilterSelect } from "./date-filter-select";
type Props = {
title: string;
handleClose: () => void;
@@ -31,8 +28,6 @@ const defaultValues: TFormValues = {
date2: new Date(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()),
};
const defaultClassNames = getDefaultClassNames();
export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, onSelect }) => {
const { handleSubmit, watch, control } = useForm<TFormValues>({
defaultValues,
@@ -98,9 +93,9 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
const dateValue = getDate(value);
const date2Value = getDate(watch("date2"));
return (
<DayPicker
<Calendar
classNames={{
root: `${defaultClassNames.root} border border-custom-border-200 p-3 rounded-md`,
root: ` border border-custom-border-200 p-3 rounded-md`,
}}
captionLayout="dropdown"
selected={dateValue}
@@ -123,9 +118,9 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
const dateValue = getDate(value);
const date1Value = getDate(watch("date1"));
return (
<DayPicker
<Calendar
classNames={{
root: `${defaultClassNames.root} border border-custom-border-200 p-3 rounded-md`,
root: ` border border-custom-border-200 p-3 rounded-md`,
}}
captionLayout="dropdown"
selected={dateValue}
+7 -4
View File
@@ -8,6 +8,7 @@ import { cn } from "@/helpers/common.helper";
import { useAppRouter } from "@/hooks/use-app-router";
interface IListItemProps {
id?: string;
title: string;
itemLink: string;
onItemClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
@@ -22,10 +23,12 @@ interface IListItemProps {
actionItemContainerClassName?: string;
isSidebarOpen?: boolean;
quickActionElement?: JSX.Element;
preventDefaultNProgress?: boolean;
}
export const ListItem: FC<IListItemProps> = (props) => {
const {
id,
title,
prependTitleElement,
appendTitleElement,
@@ -40,6 +43,7 @@ export const ListItem: FC<IListItemProps> = (props) => {
isSidebarOpen = false,
quickActionElement,
itemClassName = "",
preventDefaultNProgress = false,
} = props;
// router
@@ -56,20 +60,19 @@ export const ListItem: FC<IListItemProps> = (props) => {
<Row
className={cn(
"group min-h-[52px] flex w-full flex-col items-center justify-between gap-3 py-4 text-sm border-b border-custom-border-200 bg-custom-background-100 hover:bg-custom-background-90 ",
{
"xl:gap-5 xl:py-0 xl:flex-row": isSidebarOpen,
"lg:gap-5 lg:py-0 lg:flex-row": !isSidebarOpen,
},
{ "xl:gap-5 xl:py-0 xl:flex-row": isSidebarOpen, "lg:gap-5 lg:py-0 lg:flex-row": !isSidebarOpen },
className
)}
>
<div className={cn("relative flex w-full items-center justify-between gap-3 overflow-hidden", itemClassName)}>
<ControlLink
id={id}
className="relative flex w-full items-center gap-3 overflow-hidden"
href={itemLink}
target="_self"
onClick={handleControlLinkClick}
disabled={disableLink}
data-prevent-nprogress={preventDefaultNProgress}
>
<div className="flex items-center gap-4 truncate">
{prependTitleElement && <span className="flex items-center flex-shrink-0">{prependTitleElement}</span>}
@@ -6,12 +6,15 @@ import { Controller, useForm } from "react-hook-form"; // services
import { usePopper } from "react-popper";
import { AlertCircle } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
// plane editor
import { EditorReadOnlyRefApi } from "@plane/editor";
// ui
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-editor";
// services
import { AIService } from "@/services/ai.service";
const aiService = new AIService();
type Props = {
isOpen: boolean;
@@ -22,6 +25,7 @@ type Props = {
prompt?: string;
button: JSX.Element;
className?: string;
workspaceId: string;
workspaceSlug: string;
projectId: string;
};
@@ -31,8 +35,6 @@ type FormData = {
task: string;
};
const aiService = new AIService();
export const GptAssistantPopover: React.FC<Props> = (props) => {
const {
isOpen,
@@ -43,6 +45,7 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
prompt,
button,
className = "",
workspaceId,
workspaceSlug,
projectId,
} = props;
@@ -51,7 +54,8 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
const [invalidResponse, setInvalidResponse] = useState(false);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const editorRef = useRef<any>(null);
// refs
const editorRef = useRef<EditorReadOnlyRefApi>(null);
const responseRef = useRef<any>(null);
// popper
const { styles, attributes } = usePopper(referenceElement, popperElement, {
@@ -218,6 +222,7 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
initialValue={prompt}
containerClassName="-m-3"
ref={editorRef}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
@@ -230,6 +235,7 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
id="ai-assistant-response"
initialValue={`<p>${response}</p>`}
ref={responseRef}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
@@ -1,20 +1,20 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
import { Menu } from "lucide-react";
import { useAppTheme } from "@/hooks/store";
export const SidebarHamburgerToggle: FC = observer(() => {
export const SidebarHamburgerToggle = observer(() => {
// store hooks
const { toggleSidebar } = useAppTheme();
return (
<div
className="group flex h-7 w-7 flex-shrink-0 cursor-pointer items-center justify-center rounded bg-custom-background-80 transition-all hover:bg-custom-background-90 md:hidden"
<button
type="button"
className="group flex-shrink-0 size-7 grid place-items-center rounded bg-custom-background-80 transition-all hover:bg-custom-background-90 md:hidden"
onClick={() => toggleSidebar()}
>
<Menu size={14} className="text-custom-text-200 transition-all group-hover:text-custom-text-100" />
</div>
<Menu className="size-3.5 text-custom-text-200 transition-all group-hover:text-custom-text-100" />
</button>
);
});
+8 -44
View File
@@ -2,12 +2,12 @@
import React, { useEffect, useRef, useState } from "react";
import { Placement } from "@popperjs/core";
import { DateRange, DayPicker, Matcher, getDefaultClassNames } from "react-day-picker";
import { DateRange, Matcher } from "react-day-picker";
import { usePopper } from "react-popper";
import { ArrowRight, CalendarCheck2, CalendarDays } from "lucide-react";
import { Combobox } from "@headlessui/react";
// ui
import { Button, ComboDropDown } from "@plane/ui";
import { ComboDropDown, Calendar } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate } from "@/helpers/date-time.helper";
@@ -52,18 +52,13 @@ type Props = {
renderPlaceholder?: boolean;
};
const defaultClassNames = getDefaultClassNames();
export const DateRangeDropdown: React.FC<Props> = (props) => {
const {
applyButtonText = "Apply changes",
bothRequired = true,
buttonClassName,
buttonContainerClassName,
buttonFromDateClassName,
buttonToDateClassName,
buttonVariant,
cancelButtonText = "Cancel",
className,
disabled = false,
hideIcon = {
@@ -78,7 +73,6 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
to: "Add date",
},
placement,
required = false,
showTooltip = false,
tabIndex,
value,
@@ -200,58 +194,28 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg overflow-hidden"
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg border-[0.5px] border-custom-border-300 rounded-md overflow-hidden"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<DayPicker
<Calendar
captionLayout="dropdown"
classNames={{ root: `${defaultClassNames.root} p-3 rounded-md` }}
classNames={{ root: `p-3 rounded-md` }}
selected={dateRange}
onSelect={(val) => {
// if both the dates are not required, immediately call onSelect
if (!bothRequired) onSelect(val);
onSelect(val);
setDateRange({
from: val?.from ?? undefined,
to: val?.to ?? undefined,
});
val?.from && val?.to && handleClose();
}}
mode="range"
disabled={disabledDays}
showOutsideDays
autoFocus
fixedWeeks
footer={
bothRequired && (
<div className="grid grid-cols-2 items-center gap-3.5 pt-6 relative">
<div className="absolute left-0 top-1 h-[0.5px] w-full border-t-[0.5px] border-custom-border-300" />
<Button
variant="neutral-primary"
onClick={() => {
setDateRange({
from: undefined,
to: undefined,
});
handleClose();
}}
>
{cancelButtonText}
</Button>
<Button
onClick={() => {
onSelect(dateRange);
handleClose();
}}
// if required, both the dates should be selected
// if not required, either both or none of the dates should be selected
disabled={required ? !(dateRange.from && dateRange.to) : !!dateRange.from !== !!dateRange.to}
>
{applyButtonText}
</Button>
</div>
)
}
initialFocus
/>
</div>
</Combobox.Options>
+6 -8
View File
@@ -1,11 +1,11 @@
import React, { useRef, useState } from "react";
import { DayPicker, Matcher, getDefaultClassNames } from "react-day-picker";
import { Matcher } from "react-day-picker";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
import { CalendarDays, X } from "lucide-react";
import { Combobox } from "@headlessui/react";
// ui
import { ComboDropDown } from "@plane/ui";
import { ComboDropDown, Calendar } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate, getDate } from "@/helpers/date-time.helper";
@@ -33,8 +33,6 @@ type Props = TDropdownProps & {
renderByDefault?: boolean;
};
const defaultClassNames = getDefaultClassNames();
export const DateDropdown: React.FC<Props> = (props) => {
const {
buttonClassName = "",
@@ -168,23 +166,23 @@ export const DateDropdown: React.FC<Props> = (props) => {
<Combobox.Options data-prevent-outside-click static>
<div
className={cn(
"my-1 bg-custom-background-100 shadow-custom-shadow-rg overflow-hidden z-20",
"my-1 bg-custom-background-100 shadow-custom-shadow-rg border-[0.5px] border-custom-border-300 rounded-md overflow-hidden z-20",
optionsClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<DayPicker
<Calendar
captionLayout="dropdown"
classNames={{ root: `${defaultClassNames.root} p-3 rounded-md` }}
classNames={{ root: `p-3 rounded-md` }}
selected={getDate(value)}
defaultMonth={getDate(value)}
onSelect={(date) => {
dropdownOnChange(date ?? null);
}}
showOutsideDays
autoFocus
initialFocus
disabled={disabledDays}
mode="single"
fixedWeeks
@@ -2,20 +2,18 @@ import React, { useState } from "react";
// plane constants
import { EIssueCommentAccessSpecifier } from "@plane/constants";
// plane editor
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TFileHandler } from "@plane/editor";
// i18n
import { useTranslation } from "@plane/i18n";
// components
import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor";
// helpers
import { cn } from "@/helpers/common.helper";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
import { isCommentEmpty } from "@/helpers/string.helper";
// hooks
import { useEditorMention } from "@/hooks/use-editor-mention";
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
const workspaceService = new WorkspaceService();
@@ -31,7 +29,7 @@ interface LiteTextEditorWrapperProps
showSubmitButton?: boolean;
isSubmitting?: boolean;
showToolbarInitially?: boolean;
uploadFile: (file: File) => Promise<string>;
uploadFile: TFileHandler["upload"];
issue_id?: string;
}
@@ -66,8 +64,8 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
issue_id: issue_id,
}),
});
// file size
const { maxFileSize } = useFileSize();
// editor config
const { getEditorFileHandlers } = useEditorConfig();
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
return !!ref && typeof ref === "object" && "current" in ref;
}
@@ -85,7 +83,6 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
ref={ref}
disabledExtensions={disabledExtensions}
fileHandler={getEditorFileHandlers({
maxFileSize,
projectId,
uploadFile,
workspaceId,
@@ -5,7 +5,8 @@ import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWi
import { EditorMentionsRoot } from "@/components/editor";
// helpers
import { cn } from "@/helpers/common.helper";
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useEditorConfig } from "@/hooks/editor";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
@@ -13,14 +14,17 @@ type LiteTextReadOnlyEditorWrapperProps = Omit<
ILiteTextReadOnlyEditor,
"disabledExtensions" | "fileHandler" | "mentionHandler"
> & {
workspaceId: string;
workspaceSlug: string;
projectId: string;
};
export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(
({ workspaceSlug, projectId, ...props }, ref) => {
({ workspaceId, workspaceSlug, projectId, ...props }, ref) => {
// editor flaggings
const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
// editor config
const { getReadOnlyEditorFileHandlers } = useEditorConfig();
return (
<LiteTextReadOnlyEditorWithRef
@@ -28,6 +32,7 @@ export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, Lit
disabledExtensions={disabledExtensions}
fileHandler={getReadOnlyEditorFileHandlers({
projectId,
workspaceId,
workspaceSlug,
})}
mentionHandler={{
@@ -1,18 +1,16 @@
import React, { forwardRef } from "react";
// editor
import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor";
import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef, TFileHandler } from "@plane/editor";
// plane types
import { TSearchEntityRequestPayload, TSearchResponse } from "@plane/types";
// components
import { EditorMentionsRoot } from "@/components/editor";
// helpers
import { cn } from "@/helpers/common.helper";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useEditorMention } from "@/hooks/use-editor-mention";
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
interface RichTextEditorWrapperProps
extends Omit<IRichTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
@@ -20,7 +18,7 @@ interface RichTextEditorWrapperProps
workspaceSlug: string;
workspaceId: string;
projectId?: string;
uploadFile: (file: File) => Promise<string>;
uploadFile: TFileHandler["upload"];
}
export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {
@@ -32,15 +30,14 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
const { fetchMentions } = useEditorMention({
searchEntity: async (payload) => await searchMentionCallback(payload),
});
// file size
const { maxFileSize } = useFileSize();
// editor config
const { getEditorFileHandlers } = useEditorConfig();
return (
<RichTextEditorWithRef
ref={ref}
disabledExtensions={disabledExtensions}
fileHandler={getEditorFileHandlers({
maxFileSize,
projectId,
uploadFile,
workspaceId,
@@ -5,7 +5,8 @@ import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWi
import { EditorMentionsRoot } from "@/components/editor";
// helpers
import { cn } from "@/helpers/common.helper";
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useEditorConfig } from "@/hooks/editor";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
@@ -13,14 +14,17 @@ type RichTextReadOnlyEditorWrapperProps = Omit<
IRichTextReadOnlyEditor,
"disabledExtensions" | "fileHandler" | "mentionHandler"
> & {
workspaceId: string;
workspaceSlug: string;
projectId?: string;
};
export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, RichTextReadOnlyEditorWrapperProps>(
({ workspaceSlug, projectId, ...props }, ref) => {
({ workspaceId, workspaceSlug, projectId, ...props }, ref) => {
// editor flaggings
const { richTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
// editor config
const { getReadOnlyEditorFileHandlers } = useEditorConfig();
return (
<RichTextReadOnlyEditorWithRef
@@ -28,6 +32,7 @@ export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, Ric
disabledExtensions={disabledExtensions}
fileHandler={getReadOnlyEditorFileHandlers({
projectId,
workspaceId,
workspaceSlug,
})}
mentionHandler={{
@@ -2,15 +2,15 @@ import React, { useState } from "react";
// plane constants
import { EIssueCommentAccessSpecifier } from "@plane/constants";
// plane editor
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
// plane types
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TFileHandler } from "@plane/editor";
// components
import { TSticky } from "@plane/types";
// helpers
import { cn } from "@/helpers/common.helper";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useEditorConfig } from "@/hooks/editor";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
import { StickyEditorToolbar } from "./toolbar";
interface StickyEditorWrapperProps
@@ -25,7 +25,7 @@ interface StickyEditorWrapperProps
isSubmitting?: boolean;
showToolbarInitially?: boolean;
showToolbar?: boolean;
uploadFile: (file: File) => Promise<string>;
uploadFile: TFileHandler["upload"];
parentClassName?: string;
handleColorChange: (data: Partial<TSticky>) => Promise<void>;
handleDelete: () => void;
@@ -49,8 +49,8 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
const [isFocused, setIsFocused] = useState(showToolbarInitially);
// editor flaggings
const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
// file size
const { maxFileSize } = useFileSize();
// editor config
const { getEditorFileHandlers } = useEditorConfig();
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
return !!ref && typeof ref === "object" && "current" in ref;
}
@@ -67,7 +67,6 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
ref={ref}
disabledExtensions={[...disabledExtensions, "enter-key"]}
fileHandler={getEditorFileHandlers({
maxFileSize,
projectId,
uploadFile,
workspaceId,
@@ -43,6 +43,7 @@ export const RecentIssue = (props: BlockProps) => {
return (
<ListItem
key={activity.id}
id={`issue-${issueDetails?.id}`}
itemLink={workItemLink}
title={issueDetails?.name}
prependTitleElement={
@@ -113,6 +114,7 @@ export const RecentIssue = (props: BlockProps) => {
e.stopPropagation();
setPeekIssue({ workspaceSlug, projectId: issueDetails?.project_id, issueId: activity.entity_data.id });
}}
preventDefaultNProgress
/>
);
};
@@ -19,13 +19,10 @@ import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-e
import { getDescriptionPlaceholderI18n } from "@/helpers/issue.helper";
import { getTabIndex } from "@/helpers/tab-indices.helper";
// hooks
import { useProjectInbox } from "@/hooks/store";
import { useEditorAsset, useProjectInbox } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { FileService } from "@/services/file.service";
const fileService = new FileService();
const workspaceService = new WorkspaceService();
type TInboxIssueDescription = {
@@ -53,11 +50,10 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
onEnterKeyPress,
onAssetUpload,
} = props;
// i18n
const { t } = useTranslation();
// hooks
// store hooks
const { uploadEditorAsset } = useEditorAsset();
const { loader } = useProjectInbox();
const { isMobile } = usePlatformOS();
@@ -90,17 +86,18 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
containerClassName={containerClassName}
onEnterKeyPress={onEnterKeyPress}
tabIndex={getIndex("description_html")}
uploadFile={async (file) => {
uploadFile={async (blockId, file) => {
try {
const { asset_id } = await fileService.uploadProjectAsset(
workspaceSlug,
projectId,
{
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: data.id ?? "",
entity_type: EFileAssetType.ISSUE_DESCRIPTION,
},
file
);
file,
projectId,
workspaceSlug,
});
onAssetUpload?.(asset_id);
return asset_id;
} catch (error) {
@@ -1,11 +1,10 @@
"use client";
import { FC, Fragment, useState } from "react";
import { DayPicker, getDefaultClassNames } from "react-day-picker";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/ui";
import { Button, Calendar } from "@plane/ui";
export type InboxIssueSnoozeModalProps = {
isOpen: boolean;
@@ -21,8 +20,6 @@ export const InboxIssueSnoozeModal: FC<InboxIssueSnoozeModalProps> = (props) =>
//hooks
const { t } = useTranslation();
const defaultClassNames = getDefaultClassNames();
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
@@ -50,9 +47,9 @@ export const InboxIssueSnoozeModal: FC<InboxIssueSnoozeModalProps> = (props) =>
>
<Dialog.Panel className="relative flex transform rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<div className="flex h-full w-full flex-col gap-y-1">
<DayPicker
<Calendar
captionLayout="dropdown"
classNames={{root: `${defaultClassNames.root} rounded-md border border-custom-border-200 p-3`}}
classNames={{ root: `rounded-md border border-custom-border-200 p-3` }}
selected={date ? new Date(date) : undefined}
defaultMonth={date ? new Date(date) : undefined}
onSelect={(date) => {
@@ -71,11 +71,11 @@ export const IssueAttachmentsListItem: FC<TIssueAttachmentsListItem> = observer(
<Tooltip
isMobile={isMobile}
tooltipContent={`${
getUserDetails(attachment.updated_by)?.display_name ?? ""
getUserDetails(attachment?.created_by)?.display_name ?? ""
} uploaded on ${renderFormattedDate(attachment.updated_at)}`}
>
<div className="flex items-center justify-center">
<ButtonAvatars showTooltip userIds={attachment?.updated_by} />
<ButtonAvatars showTooltip userIds={attachment?.created_by} />
</div>
</Tooltip>
</>
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
import { PROJECT_ERROR_MESSAGES, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
@@ -22,11 +24,12 @@ type Props = {
isEpic?: boolean;
};
export const DeleteIssueModal: React.FC<Props> = (props) => {
export const DeleteIssueModal: React.FC<Props> = observer((props) => {
const { dataId, data, isOpen, handleClose, isSubIssue = false, onSubmit, isEpic = false } = props;
// states
const [isDeleting, setIsDeleting] = useState(false);
// store hooks
const { workspaceSlug } = useParams();
const { issueMap } = useIssues();
const { getProjectById } = useProject();
const { allowPermissions } = useUserPermissions();
@@ -34,9 +37,6 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
const { data: currentUser } = useUser();
// derived values
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
useEffect(() => {
setIsDeleting(false);
}, [isOpen]);
@@ -47,6 +47,14 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
const issue = data ? data : issueMap[dataId!];
const projectDetails = getProjectById(issue?.project_id);
const isIssueCreator = issue?.created_by === currentUser?.id;
const canPerformProjectAdminActions = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
workspaceSlug?.toString(),
projectDetails?.id
);
const authorized = isIssueCreator || canPerformProjectAdminActions;
const onClose = () => {
@@ -114,4 +122,4 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
}
/>
);
};
});
@@ -17,13 +17,10 @@ import { TIssueOperations } from "@/components/issues/issue-detail";
// helpers
import { getDescriptionPlaceholderI18n } from "@/helpers/issue.helper";
// hooks
import { useWorkspace } from "@/hooks/store";
import { useEditorAsset, useWorkspace } from "@/hooks/store";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { FileService } from "@/services/file.service";
const workspaceService = new WorkspaceService();
const fileService = new FileService();
export type IssueDescriptionInputProps = {
containerClassName?: string;
@@ -51,6 +48,14 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
setIsSubmitting,
placeholder,
} = props;
// states
const [localIssueDescription, setLocalIssueDescription] = useState({
id: issueId,
description_html: initialValue,
});
// store hooks
const { uploadEditorAsset } = useEditorAsset();
// form info
// i18n
const { t } = useTranslation();
@@ -61,11 +66,6 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
},
});
const [localIssueDescription, setLocalIssueDescription] = useState({
id: issueId,
description_html: initialValue,
});
const handleDescriptionFormSubmit = useCallback(
async (formData: Partial<TIssue>) => {
await issueOperations.update(workspaceSlug, projectId, issueId, {
@@ -136,17 +136,18 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
})
}
containerClassName={containerClassName}
uploadFile={async (file) => {
uploadFile={async (blockId, file) => {
try {
const { asset_id } = await fileService.uploadProjectAsset(
workspaceSlug,
projectId,
{
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: issueId,
entity_type: EFileAssetType.ISSUE_DESCRIPTION,
},
file
);
file,
projectId,
workspaceSlug,
});
return asset_id;
} catch (error) {
console.log("Error in uploading work item asset:", error);
@@ -159,6 +160,7 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
id={issueId}
initialValue={localIssueDescription.description_html ?? ""}
containerClassName={containerClassName}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
@@ -170,8 +170,8 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
}
}}
showSubmitButton={false}
uploadFile={async (file) => {
const { asset_id } = await activityOperations.uploadCommentAsset(file, comment.id);
uploadFile={async (blockId, file) => {
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id);
return asset_id;
}}
/>
@@ -215,6 +215,7 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
ref={showEditorRef}
id={comment.id}
initialValue={comment.comment_html ?? ""}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
@@ -110,8 +110,8 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
handleAccessChange={onAccessChange}
showAccessSpecifier={showAccessSpecifier}
isSubmitting={isSubmitting}
uploadFile={async (file) => {
const { asset_id } = await activityOperations.uploadCommentAsset(file);
uploadFile={async (blockId, file) => {
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file);
setUploadedAssetIds((prev) => [...prev, asset_id]);
return asset_id;
}}
@@ -3,7 +3,7 @@
import { FC, useMemo } from "react";
import { observer } from "mobx-react";
// plane package imports
import { E_SORT_ORDER, TActivityFilters, defaultActivityFilters,EUserPermissions } from "@plane/constants";
import { E_SORT_ORDER, TActivityFilters, defaultActivityFilters, EUserPermissions } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
// i18n
import { useTranslation } from "@plane/i18n";
@@ -16,12 +16,9 @@ import { IssueCommentCreate } from "@/components/issues";
import { ActivitySortRoot, IssueActivityCommentRoot } from "@/components/issues/issue-detail";
// constants
// hooks
import { useIssueDetail, useProject, useUser, useUserPermissions } from "@/hooks/store";
import { useEditorAsset, useIssueDetail, useProject, useUser, useUserPermissions } from "@/hooks/store";
// plane web components
import { ActivityFilterRoot, IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog";
// services
import { FileService } from "@/services/file.service";
const fileService = new FileService();
type TIssueActivity = {
workspaceSlug: string;
@@ -35,7 +32,7 @@ export type TActivityOperations = {
createComment: (data: Partial<TIssueComment>) => Promise<TIssueComment>;
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
removeComment: (commentId: string) => Promise<void>;
uploadCommentAsset: (file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
};
export const IssueActivity: FC<TIssueActivity> = observer((props) => {
@@ -48,6 +45,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
defaultActivityFilters
);
const { setValue: setSortOrder, storedValue: sortOrder } = useLocalStorage("activity_sort_order", E_SORT_ORDER.ASC);
// store hooks
const {
issue: { getIssueById },
createComment,
@@ -57,7 +55,8 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
const { projectPermissionsByWorkspaceSlugAndProjectId } = useUserPermissions();
const { getProjectById } = useProject();
const { data: currentUser } = useUser();
//derived values
const { uploadEditorAsset } = useEditorAsset();
// derived values
const issue = issueId ? getIssueById(issueId) : undefined;
const currentUserProjectRole = projectPermissionsByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const isAdmin = (currentUserProjectRole ?? EUserPermissions.GUEST) === EUserPermissions.ADMIN;
@@ -94,7 +93,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
message: t("issue.comments.create.success"),
});
return comment;
} catch (error) {
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
@@ -111,7 +110,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.update.success"),
});
} catch (error) {
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
@@ -128,7 +127,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.remove.success"),
});
} catch (error) {
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
@@ -136,18 +135,19 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
});
}
},
uploadCommentAsset: async (file, commentId) => {
uploadCommentAsset: async (blockId, file, commentId) => {
try {
if (!workspaceSlug || !projectId) throw new Error("Missing fields");
const res = await fileService.uploadProjectAsset(
workspaceSlug,
projectId,
{
const res = await uploadEditorAsset({
blockId,
data: {
entity_identifier: commentId ?? "",
entity_type: EFileAssetType.COMMENT_DESCRIPTION,
},
file
);
file,
projectId,
workspaceSlug,
});
return res;
} catch (error) {
console.log("Error in uploading comment asset:", error);
@@ -155,7 +155,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
}
},
}),
[workspaceSlug, projectId, issueId, createComment, updateComment, removeComment]
[workspaceSlug, projectId, issueId, createComment, updateComment, uploadEditorAsset, removeComment]
);
const project = getProjectById(projectId);
@@ -53,7 +53,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug.toString(),
workspaceSlug: workspaceSlug?.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier,

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