Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1053ff4684 |
@@ -61,9 +61,3 @@ temp/
|
||||
# Misc
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# React Router - https://github.com/remix-run/react-router-templates/blob/dc79b1a065f59f3bfd840d4ef75cc27689b611e6/default/.dockerignore
|
||||
.react-router/
|
||||
build/
|
||||
node_modules/
|
||||
README.md
|
||||
+1
-2
@@ -16,10 +16,9 @@ node_modules
|
||||
/out/
|
||||
|
||||
# Production
|
||||
/build
|
||||
dist/
|
||||
out/
|
||||
build/
|
||||
.react-router/
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "18.16.1",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
|
||||
@@ -4,7 +4,7 @@ from rest_framework import serializers
|
||||
# Module import
|
||||
from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite
|
||||
from plane.utils.url import contains_url
|
||||
|
||||
from plane.db.models.user import get_default_tour_completed_features
|
||||
from .base import BaseSerializer
|
||||
|
||||
|
||||
@@ -194,6 +194,37 @@ class ResetPasswordSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class ProfileSerializer(BaseSerializer):
|
||||
|
||||
def validate_tour_completed_features(self, value):
|
||||
"""
|
||||
Clean tour_completed_features by removing invalid keys and keeping only
|
||||
the keys present in get_default_tour_completed_features function.
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
return get_default_tour_completed_features()
|
||||
|
||||
expected_structure = get_default_tour_completed_features()
|
||||
|
||||
# Use dict comprehension for cleaner, more efficient code
|
||||
cleaned_data = {}
|
||||
|
||||
# Process valid top-level keys
|
||||
for key in expected_structure:
|
||||
if key in value:
|
||||
val = value[key]
|
||||
if isinstance(val, dict) and key in ['workspace_features', 'project_features']:
|
||||
# Clean nested structure using intersection
|
||||
expected_nested = expected_structure[key]
|
||||
cleaned_data[key] = {k: v for k, v in val.items() if k in expected_nested}
|
||||
else:
|
||||
cleaned_data[key] = val
|
||||
else:
|
||||
# Add missing key with default
|
||||
default_value = expected_structure[key]
|
||||
cleaned_data[key] = default_value.copy() if isinstance(default_value, dict) else default_value
|
||||
|
||||
return cleaned_data
|
||||
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = "__all__"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.24 on 2025-10-08 10:46
|
||||
|
||||
from django.db import migrations, models
|
||||
import plane.db.models.user
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0107_migrate_filters_to_rich_filters'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='tour_completed_features',
|
||||
field=models.JSONField(default=plane.db.models.user.get_default_tour_completed_features),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='exporterhistory',
|
||||
name='url',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -42,7 +42,7 @@ class ExporterHistory(BaseModel):
|
||||
)
|
||||
reason = models.TextField(blank=True)
|
||||
key = models.TextField(blank=True)
|
||||
url = models.URLField(max_length=800, blank=True, null=True)
|
||||
url = models.TextField(blank=True, null=True)
|
||||
token = models.CharField(max_length=255, default=generate_token, unique=True)
|
||||
initiated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
|
||||
@@ -34,6 +34,20 @@ def get_mobile_default_onboarding():
|
||||
"workspace_join": False,
|
||||
}
|
||||
|
||||
def get_default_tour_completed_features():
|
||||
return {
|
||||
"workspace_features": {
|
||||
"projects": False
|
||||
},
|
||||
"project_features": {
|
||||
"workitems": False,
|
||||
"cycles": False,
|
||||
"modules": False,
|
||||
"views": False,
|
||||
"pages": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True)
|
||||
@@ -214,6 +228,9 @@ class Profile(TimeAuditModel):
|
||||
goals = models.JSONField(default=dict)
|
||||
background_color = models.CharField(max_length=255, default=get_random_color)
|
||||
|
||||
# product tour features
|
||||
tour_completed_features = models.JSONField(default=get_default_tour_completed_features)
|
||||
|
||||
# marketing
|
||||
has_marketing_email_consent = models.BooleanField(default=False)
|
||||
|
||||
|
||||
@@ -20,11 +20,10 @@
|
||||
"author": "Plane Software Inc.",
|
||||
"dependencies": {
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@hocuspocus/extension-database": "3.2.5",
|
||||
"@hocuspocus/extension-logger": "3.2.5",
|
||||
"@hocuspocus/extension-redis": "3.2.5",
|
||||
"@hocuspocus/server": "3.2.5",
|
||||
"@hocuspocus/transformer": "3.2.5",
|
||||
"@hocuspocus/extension-database": "^3.0.0",
|
||||
"@hocuspocus/extension-logger": "^3.0.0",
|
||||
"@hocuspocus/extension-redis": "^3.0.0",
|
||||
"@hocuspocus/server": "^3.0.0",
|
||||
"@plane/decorators": "workspace:*",
|
||||
"@plane/editor": "workspace:*",
|
||||
"@plane/logger": "workspace:*",
|
||||
@@ -56,7 +55,7 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/express-ws": "^3.0.5",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/pino-http": "^5.8.4",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
|
||||
|
||||
/**
|
||||
* @description from the provided signed URL response, generate a payload to be used to upload the file
|
||||
* @param {TFileSignedURLResponse} signedURLResponse
|
||||
* @param {File} file
|
||||
* @returns {FormData} file upload request payload
|
||||
*/
|
||||
export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value));
|
||||
formData.append("file", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description combine the file path with the base URL
|
||||
@@ -13,6 +26,17 @@ export const getFileURL = (path: string): string | undefined => {
|
||||
return `${API_BASE_URL}${path}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description returns the necessary file meta data to upload a file
|
||||
* @param {File} file
|
||||
* @returns {TFileMetaDataLite} payload with file info
|
||||
*/
|
||||
export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
});
|
||||
|
||||
/**
|
||||
* @description this function returns the assetId from the asset source
|
||||
* @param {string} src
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "18.14.1",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
|
||||
@@ -152,17 +152,10 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
const isSecureEnvironment = window.location.protocol === "https:";
|
||||
WS_LIVE_URL.protocol = isSecureEnvironment ? "wss" : "ws";
|
||||
WS_LIVE_URL.pathname = `${LIVE_BASE_PATH}/collaboration`;
|
||||
|
||||
// Append query parameters to the URL
|
||||
Object.entries(webhookConnectionParams)
|
||||
.filter(([_, value]) => value !== undefined && value !== null)
|
||||
.forEach(([key, value]) => {
|
||||
WS_LIVE_URL.searchParams.set(key, String(value));
|
||||
});
|
||||
|
||||
// Construct realtime config
|
||||
return {
|
||||
url: WS_LIVE_URL.toString(),
|
||||
queryParams: webhookConnectionParams,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error creating realtime config", error);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { AxiosRequestConfig } from "axios";
|
||||
// plane types
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { getFileMetaDataForUpload, generateFileUploadPayload } from "@plane/services";
|
||||
import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
|
||||
import { getAssetIdFromUrl } from "@plane/utils";
|
||||
// helpers
|
||||
import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@plane/utils";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
import { FileUploadService } from "@/services/file-upload.service";
|
||||
@@ -69,7 +68,7 @@ export class FileService extends APIService {
|
||||
file: File,
|
||||
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
|
||||
): Promise<TFileSignedURLResponse> {
|
||||
const fileMetaData = await getFileMetaDataForUpload(file);
|
||||
const fileMetaData = getFileMetaDataForUpload(file);
|
||||
return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/`, {
|
||||
...data,
|
||||
...fileMetaData,
|
||||
@@ -146,7 +145,7 @@ export class FileService extends APIService {
|
||||
file: File,
|
||||
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
|
||||
): Promise<TFileSignedURLResponse> {
|
||||
const fileMetaData = await getFileMetaDataForUpload(file);
|
||||
const fileMetaData = getFileMetaDataForUpload(file);
|
||||
return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/`, {
|
||||
...data,
|
||||
...fileMetaData,
|
||||
@@ -176,7 +175,7 @@ export class FileService extends APIService {
|
||||
}
|
||||
|
||||
async uploadUserAsset(data: TFileEntityInfo, file: File): Promise<TFileSignedURLResponse> {
|
||||
const fileMetaData = await getFileMetaDataForUpload(file);
|
||||
const fileMetaData = getFileMetaDataForUpload(file);
|
||||
return this.post(`/api/assets/v2/user-assets/`, {
|
||||
...data,
|
||||
...fileMetaData,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { AxiosRequestConfig } from "axios";
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
// plane types
|
||||
import { getFileMetaDataForUpload, generateFileUploadPayload } from "@plane/services";
|
||||
import { EIssueServiceType, TIssueAttachment, TIssueAttachmentUploadResponse, TIssueServiceType } from "@plane/types";
|
||||
// helpers
|
||||
import { generateFileUploadPayload, getFileMetaDataForUpload } from "@plane/utils";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
import { FileUploadService } from "@/services/file-upload.service";
|
||||
@@ -40,7 +41,7 @@ export class IssueAttachmentService extends APIService {
|
||||
file: File,
|
||||
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
|
||||
): Promise<TIssueAttachment> {
|
||||
const fileMetaData = await getFileMetaDataForUpload(file);
|
||||
const fileMetaData = getFileMetaDataForUpload(file);
|
||||
return this.post(
|
||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,
|
||||
fileMetaData
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "18.16.1",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-color": "^3.0.6",
|
||||
"@types/react-dom": "catalog:",
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
"@types/express": "4.17.23",
|
||||
"typescript": "catalog:",
|
||||
"sharp": "catalog:",
|
||||
"vite": "catalog:"
|
||||
"vite": "7.0.7"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@swc/core",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/ws": "^8.5.10",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"tsdown": "catalog:",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@floating-ui/dom": "^1.7.1",
|
||||
"@floating-ui/react": "^0.26.4",
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@hocuspocus/provider": "3.2.5",
|
||||
"@hocuspocus/provider": "^2.15.0",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
@@ -81,7 +81,7 @@
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"postcss": "^8.4.38",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { FloatingOverlay } from "@floating-ui/react";
|
||||
import { SuggestionKeyDownProps, type SuggestionProps } from "@tiptap/suggestion";
|
||||
import { computePosition, flip, shift } from "@floating-ui/dom";
|
||||
import { type Editor, posToDOMRect } from "@tiptap/react";
|
||||
import { SuggestionKeyDownProps } from "@tiptap/suggestion";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
// plane imports
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export type EmojiItem = {
|
||||
@@ -13,21 +13,41 @@ export type EmojiItem = {
|
||||
fallbackImage?: string;
|
||||
};
|
||||
|
||||
const updatePosition = (editor: Editor, element: HTMLElement) => {
|
||||
const virtualElement = {
|
||||
getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
|
||||
};
|
||||
|
||||
computePosition(virtualElement, element, {
|
||||
placement: "bottom-start",
|
||||
strategy: "absolute",
|
||||
middleware: [shift(), flip()],
|
||||
}).then(({ x, y, strategy }) => {
|
||||
Object.assign(element.style, {
|
||||
width: "max-content",
|
||||
position: strategy,
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export type EmojiListRef = {
|
||||
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
|
||||
};
|
||||
|
||||
export type EmojisListDropdownProps = SuggestionProps<EmojiItem, { name: string }> & {
|
||||
onClose: () => void;
|
||||
type Props = {
|
||||
items: EmojiItem[];
|
||||
command: (item: { name: string }) => void;
|
||||
editor: Editor;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export const EmojisListDropdown = forwardRef<EmojiListRef, EmojisListDropdownProps>((props, ref) => {
|
||||
const { items, command, query, onClose } = props;
|
||||
// states
|
||||
export const EmojiList = forwardRef<EmojiListRef, Props>((props, ref) => {
|
||||
const { items, command, editor, query } = props;
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(0);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
// refs
|
||||
const dropdownContainerRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number): void => {
|
||||
@@ -72,6 +92,25 @@ export const EmojisListDropdown = forwardRef<EmojiListRef, EmojisListDropdownPro
|
||||
[query.length, items.length, selectItem, selectedIndex]
|
||||
);
|
||||
|
||||
// Update position when items change
|
||||
useEffect(() => {
|
||||
if (containerRef.current && editor) {
|
||||
updatePosition(editor, containerRef.current);
|
||||
}
|
||||
}, [items, editor]);
|
||||
|
||||
// Handle scroll events
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (containerRef.current && editor) {
|
||||
updatePosition(editor, containerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("scroll", handleScroll, true);
|
||||
return () => document.removeEventListener("scroll", handleScroll, true);
|
||||
}, [editor]);
|
||||
|
||||
// Show animation
|
||||
useEffect(() => {
|
||||
setIsVisible(false);
|
||||
@@ -84,7 +123,7 @@ export const EmojisListDropdown = forwardRef<EmojiListRef, EmojisListDropdownPro
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
const container = dropdownContainerRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const item = container.querySelector(`#emoji-item-${selectedIndex}`) as HTMLElement;
|
||||
@@ -106,31 +145,20 @@ export const EmojisListDropdown = forwardRef<EmojiListRef, EmojisListDropdownPro
|
||||
[handleKeyDown]
|
||||
);
|
||||
|
||||
useOutsideClickDetector(dropdownContainerRef, onClose);
|
||||
|
||||
if (query.length <= 0) return null;
|
||||
if (query.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<FloatingOverlay
|
||||
style={{
|
||||
zIndex: 99,
|
||||
}}
|
||||
lockScroll
|
||||
/>
|
||||
<div
|
||||
ref={dropdownContainerRef}
|
||||
className={cn(
|
||||
"relative max-h-80 w-[14rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2 opacity-0 invisible transition-opacity",
|
||||
{
|
||||
"opacity-100 visible": isVisible,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: 100,
|
||||
}}
|
||||
className={`transition-all duration-200 transform ${isVisible ? "opacity-100 scale-100" : "opacity-0 scale-95"}`}
|
||||
>
|
||||
<div className="z-10 max-h-[90vh] w-[16rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-1">
|
||||
{items.length ? (
|
||||
items.map((item, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
@@ -167,8 +195,8 @@ export const EmojisListDropdown = forwardRef<EmojiListRef, EmojisListDropdownPro
|
||||
<div className="text-center text-sm text-custom-text-400 py-2">No emojis found</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
EmojisListDropdown.displayName = "EmojisListDropdown";
|
||||
EmojiList.displayName = "EmojiList";
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { EmojiOptions } from "@tiptap/extension-emoji";
|
||||
import { ReactRenderer, type Editor } from "@tiptap/react";
|
||||
import type { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { updateFloatingUIFloaterPosition } from "@/helpers/floating-ui";
|
||||
import { CommandListInstance } from "@/helpers/tippy";
|
||||
// local imports
|
||||
import { type EmojiItem, EmojisListDropdown, EmojisListDropdownProps } from "./components/emojis-list";
|
||||
import { type EmojiItem, EmojiList, type EmojiListRef } from "./components/emojis-list";
|
||||
|
||||
const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"];
|
||||
|
||||
@@ -46,52 +44,71 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
|
||||
allowSpaces: false,
|
||||
|
||||
render: () => {
|
||||
let component: ReactRenderer<CommandListInstance, EmojisListDropdownProps> | null = null;
|
||||
let cleanup: () => void = () => {};
|
||||
let editorRef: Editor | null = null;
|
||||
|
||||
const handleClose = (editor?: Editor) => {
|
||||
component?.destroy();
|
||||
component = null;
|
||||
(editor || editorRef)?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.EMOJI);
|
||||
cleanup();
|
||||
};
|
||||
let component: ReactRenderer<EmojiListRef>;
|
||||
let editor: Editor;
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
editorRef = props.editor;
|
||||
component = new ReactRenderer<CommandListInstance, EmojisListDropdownProps>(EmojisListDropdown, {
|
||||
props: {
|
||||
...props,
|
||||
onClose: () => handleClose(props.editor),
|
||||
} satisfies EmojisListDropdownProps,
|
||||
editor: props.editor,
|
||||
className: "fixed z-[100]",
|
||||
});
|
||||
onStart: (props: SuggestionProps): void => {
|
||||
if (!props.clientRect) return;
|
||||
props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.EMOJI);
|
||||
const element = component.element as HTMLElement;
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, element).cleanup;
|
||||
|
||||
editor = props.editor;
|
||||
|
||||
// Track active dropdown
|
||||
editor.storage.utility.activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI);
|
||||
|
||||
component = new ReactRenderer(EmojiList, {
|
||||
props: {
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
editor: props.editor,
|
||||
query: props.query,
|
||||
},
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
// Append to editor container
|
||||
const targetElement =
|
||||
(props.editor.options.element as HTMLElement) || props.editor.view.dom.parentElement || document.body;
|
||||
targetElement.appendChild(component.element);
|
||||
},
|
||||
|
||||
onUpdate: (props) => {
|
||||
if (!component || !component.element) return;
|
||||
component.updateProps(props);
|
||||
if (!props.clientRect) return;
|
||||
cleanup();
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element).cleanup;
|
||||
onUpdate: (props: SuggestionProps): void => {
|
||||
if (!component) return;
|
||||
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
editor: props.editor,
|
||||
query: props.query,
|
||||
});
|
||||
},
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === "Escape") {
|
||||
handleClose();
|
||||
|
||||
onKeyDown: (props: SuggestionKeyDownProps): boolean => {
|
||||
if (props.event.key === "Escape") {
|
||||
if (component) {
|
||||
component.destroy();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return component?.ref?.onKeyDown({ event }) || false;
|
||||
|
||||
// Delegate to EmojiList
|
||||
return component?.ref?.onKeyDown(props) || false;
|
||||
},
|
||||
|
||||
onExit: ({ editor }) => {
|
||||
component?.element.remove();
|
||||
handleClose(editor);
|
||||
onExit: (): void => {
|
||||
// Remove from active dropdowns
|
||||
if (editor) {
|
||||
const { activeDropbarExtensions } = editor.storage.utility;
|
||||
const index = activeDropbarExtensions.indexOf(CORE_EXTENSIONS.EMOJI);
|
||||
if (index > -1) {
|
||||
activeDropbarExtensions.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if (component) {
|
||||
component.destroy();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { FloatingOverlay } from "@floating-ui/react";
|
||||
import type { SuggestionProps } from "@tiptap/suggestion";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// plane utils
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy";
|
||||
// types
|
||||
import { TMentionHandler, TMentionSection, TMentionSuggestion } from "@/types";
|
||||
|
||||
export type MentionsListDropdownProps = SuggestionProps<TMentionSection, TMentionSuggestion> &
|
||||
Pick<TMentionHandler, "searchCallback"> & {
|
||||
onClose: () => void;
|
||||
};
|
||||
export type MentionsListDropdownProps = {
|
||||
command: (item: TMentionSuggestion) => void;
|
||||
query: string;
|
||||
editor: Editor;
|
||||
} & Pick<TMentionHandler, "searchCallback">;
|
||||
|
||||
export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps, ref) => {
|
||||
const { command, query, searchCallback, onClose } = props;
|
||||
const { command, query, searchCallback } = props;
|
||||
// states
|
||||
const [sections, setSections] = useState<TMentionSection[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState({
|
||||
@@ -27,7 +26,7 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// refs
|
||||
const dropdownContainer = useRef<HTMLDivElement>(null);
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(sectionIndex: number, itemIndex: number) => {
|
||||
@@ -98,7 +97,7 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps
|
||||
|
||||
// scroll to the dropdown item when navigating via keyboard
|
||||
useLayoutEffect(() => {
|
||||
const container = dropdownContainer?.current;
|
||||
const container = commandListContainer?.current;
|
||||
if (!container) return;
|
||||
|
||||
const item = container.querySelector(`#mention-item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement;
|
||||
@@ -114,77 +113,63 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
useOutsideClickDetector(dropdownContainer, onClose);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<FloatingOverlay
|
||||
style={{
|
||||
zIndex: 99,
|
||||
}}
|
||||
lockScroll
|
||||
/>
|
||||
<div
|
||||
ref={dropdownContainer}
|
||||
className="relative max-h-80 w-[14rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2"
|
||||
style={{
|
||||
zIndex: 100,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-custom-text-400">Loading...</div>
|
||||
) : sections.length ? (
|
||||
sections.map((section, sectionIndex) => (
|
||||
<div key={section.key} className="space-y-2">
|
||||
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
|
||||
{section.items.map((item, itemIndex) => {
|
||||
const isSelected = sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item;
|
||||
<div
|
||||
ref={commandListContainer}
|
||||
className="z-10 max-h-[90vh] w-[14rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-custom-text-400">Loading...</div>
|
||||
) : sections.length ? (
|
||||
sections.map((section, sectionIndex) => (
|
||||
<div key={section.key} className="space-y-2">
|
||||
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
|
||||
{section.items.map((item, itemIndex) => {
|
||||
const isSelected = sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
id={`mention-item-${sectionIndex}-${itemIndex}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200",
|
||||
{
|
||||
"bg-custom-background-80": isSelected,
|
||||
}
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectItem(sectionIndex, itemIndex);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex({
|
||||
section: sectionIndex,
|
||||
item: itemIndex,
|
||||
})
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
id={`mention-item-${sectionIndex}-${itemIndex}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200",
|
||||
{
|
||||
"bg-custom-background-80": isSelected,
|
||||
}
|
||||
>
|
||||
<span className="size-5 grid place-items-center flex-shrink-0">{item.icon}</span>
|
||||
{item.subTitle && (
|
||||
<h5 className="whitespace-nowrap text-xs text-custom-text-300 flex-shrink-0">{item.subTitle}</h5>
|
||||
)}
|
||||
<p className="flex-grow truncate">{item.title}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-sm text-custom-text-400">No results</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectItem(sectionIndex, itemIndex);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex({
|
||||
section: sectionIndex,
|
||||
item: itemIndex,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="size-5 grid place-items-center flex-shrink-0">{item.icon}</span>
|
||||
{item.subTitle && (
|
||||
<h5 className="whitespace-nowrap text-xs text-custom-text-300 flex-shrink-0">{item.subTitle}</h5>
|
||||
)}
|
||||
<p className="flex-grow truncate">{item.title}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-sm text-custom-text-400">No results</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Editor, ReactRenderer } from "@tiptap/react";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import type { SuggestionOptions } from "@tiptap/suggestion";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
@@ -15,52 +15,43 @@ export const renderMentionsDropdown =
|
||||
() => {
|
||||
const { searchCallback } = args;
|
||||
let component: ReactRenderer<CommandListInstance, MentionsListDropdownProps> | null = null;
|
||||
let cleanup: () => void = () => {};
|
||||
let editorRef: Editor | null = null;
|
||||
|
||||
const handleClose = (editor?: Editor) => {
|
||||
component?.destroy();
|
||||
component = null;
|
||||
(editor || editorRef)?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.MENTION);
|
||||
cleanup();
|
||||
};
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
if (!searchCallback) return;
|
||||
editorRef = props.editor;
|
||||
component = new ReactRenderer<CommandListInstance, MentionsListDropdownProps>(MentionsListDropdown, {
|
||||
props: {
|
||||
...props,
|
||||
searchCallback,
|
||||
onClose: () => handleClose(props.editor),
|
||||
} satisfies MentionsListDropdownProps,
|
||||
},
|
||||
editor: props.editor,
|
||||
className: "fixed z-[100]",
|
||||
});
|
||||
if (!props.clientRect) return;
|
||||
props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.MENTION);
|
||||
const element = component.element as HTMLElement;
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, element).cleanup;
|
||||
element.style.position = "absolute";
|
||||
element.style.zIndex = "100";
|
||||
updateFloatingUIFloaterPosition(props.editor, element);
|
||||
},
|
||||
onUpdate: (props) => {
|
||||
if (!component || !component.element) return;
|
||||
component.updateProps(props);
|
||||
if (!props.clientRect) return;
|
||||
cleanup();
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element).cleanup;
|
||||
updateFloatingUIFloaterPosition(props.editor, component.element);
|
||||
},
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === "Escape") {
|
||||
handleClose();
|
||||
component?.destroy();
|
||||
component = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
return component?.ref?.onKeyDown({ event }) ?? false;
|
||||
},
|
||||
onExit: ({ editor }) => {
|
||||
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.MENTION);
|
||||
component?.element.remove();
|
||||
handleClose(editor);
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,38 +10,10 @@ type Props = {
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onMouseEnter: () => void;
|
||||
sectionIndex: number;
|
||||
query?: string;
|
||||
};
|
||||
|
||||
// Utility to highlight matched text in a string
|
||||
const highlightMatch = (text: string, query: string): React.ReactNode => {
|
||||
if (!query || query.trim() === "") return text;
|
||||
|
||||
const queryLower = query.toLowerCase().trim();
|
||||
const textLower = text.toLowerCase();
|
||||
|
||||
// Check for direct substring match
|
||||
const index = textLower.indexOf(queryLower);
|
||||
if (index >= 0) {
|
||||
const before = text.substring(0, index);
|
||||
const match = text.substring(index, index + queryLower.length);
|
||||
const after = text.substring(index + queryLower.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<span className="font-medium text-custom-text-100">{match}</span>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise just return the text
|
||||
return text;
|
||||
};
|
||||
|
||||
export const CommandMenuItem: React.FC<Props> = (props) => {
|
||||
const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex, query } = props;
|
||||
const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -59,7 +31,7 @@ export const CommandMenuItem: React.FC<Props> = (props) => {
|
||||
<span className="size-5 grid place-items-center flex-shrink-0" style={item.iconContainerStyle}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<p className="flex-grow truncate">{query ? highlightMatch(item.title, query) : item.title}</p>
|
||||
<p className="flex-grow truncate">{item.title}</p>
|
||||
{item.badge}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { FloatingOverlay } from "@floating-ui/react";
|
||||
import type { SuggestionProps } from "@tiptap/suggestion";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
// plane imports
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// helpers
|
||||
import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy";
|
||||
// types
|
||||
import type { ISlashCommandItem } from "@/types";
|
||||
// components
|
||||
import { ISlashCommandItem } from "@/types";
|
||||
import { TSlashCommandSection } from "./command-items-list";
|
||||
import { CommandMenuItem } from "./command-menu-item";
|
||||
|
||||
export type SlashCommandsMenuProps = SuggestionProps<TSlashCommandSection, ISlashCommandItem> & {
|
||||
onClose: () => void;
|
||||
export type SlashCommandsMenuProps = {
|
||||
editor: Editor;
|
||||
items: TSlashCommandSection[];
|
||||
command: (item: ISlashCommandItem) => void;
|
||||
};
|
||||
|
||||
export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) => {
|
||||
const { items: sections, command, query, onClose } = props;
|
||||
const { items: sections, command } = props;
|
||||
// states
|
||||
const [selectedIndex, setSelectedIndex] = useState({
|
||||
section: 0,
|
||||
@@ -114,58 +112,43 @@ export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref)
|
||||
},
|
||||
}));
|
||||
|
||||
useOutsideClickDetector(commandListContainer, onClose);
|
||||
|
||||
const areSearchResultsEmpty = sections.map((s) => s.items?.length).reduce((acc, curr) => acc + curr, 0) === 0;
|
||||
|
||||
if (areSearchResultsEmpty) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<FloatingOverlay
|
||||
style={{
|
||||
zIndex: 99,
|
||||
}}
|
||||
lockScroll
|
||||
/>
|
||||
<div
|
||||
id="slash-command"
|
||||
ref={commandListContainer}
|
||||
className="relative max-h-80 min-w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2"
|
||||
style={{
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<div key={section.key} className="space-y-2">
|
||||
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
|
||||
<div>
|
||||
{section.items?.map((item, itemIndex) => (
|
||||
<CommandMenuItem
|
||||
key={item.key}
|
||||
isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item}
|
||||
item={item}
|
||||
itemIndex={itemIndex}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectItem(sectionIndex, itemIndex);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex({
|
||||
section: sectionIndex,
|
||||
item: itemIndex,
|
||||
})
|
||||
}
|
||||
sectionIndex={sectionIndex}
|
||||
query={query}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
id="slash-command"
|
||||
ref={commandListContainer}
|
||||
className="z-10 max-h-80 min-w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2"
|
||||
>
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<div key={section.key} className="space-y-2">
|
||||
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
|
||||
<div>
|
||||
{section.items?.map((item, itemIndex) => (
|
||||
<CommandMenuItem
|
||||
key={item.key}
|
||||
isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item}
|
||||
item={item}
|
||||
itemIndex={itemIndex}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectItem(sectionIndex, itemIndex);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex({
|
||||
section: sectionIndex,
|
||||
item: itemIndex,
|
||||
})
|
||||
}
|
||||
sectionIndex={sectionIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Editor, Extension } from "@tiptap/core";
|
||||
import { type Editor, type Range, Extension } from "@tiptap/core";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
|
||||
// constants
|
||||
@@ -27,7 +27,7 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
command: ({ editor, range, props }) => {
|
||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
allow({ editor }: { editor: Editor }) {
|
||||
@@ -50,32 +50,20 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
editor: this.editor,
|
||||
render: () => {
|
||||
let component: ReactRenderer<CommandListInstance, SlashCommandsMenuProps> | null = null;
|
||||
let cleanup: () => void = () => {};
|
||||
let editorRef: Editor | null = null;
|
||||
|
||||
const handleClose = (editor?: Editor) => {
|
||||
component?.destroy();
|
||||
component = null;
|
||||
(editor || editorRef)?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS);
|
||||
cleanup();
|
||||
};
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
editorRef = props.editor;
|
||||
// React renderer component, which wraps the actual dropdown component
|
||||
// Track active dropdown
|
||||
component = new ReactRenderer<CommandListInstance, SlashCommandsMenuProps>(SlashCommandsMenu, {
|
||||
props: {
|
||||
...props,
|
||||
onClose: () => handleClose(props.editor),
|
||||
} satisfies SlashCommandsMenuProps,
|
||||
props,
|
||||
editor: props.editor,
|
||||
className: "fixed z-[100]",
|
||||
});
|
||||
if (!props.clientRect) return;
|
||||
props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS);
|
||||
const element = component.element as HTMLElement;
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, element).cleanup;
|
||||
element.style.position = "absolute";
|
||||
element.style.zIndex = "100";
|
||||
updateFloatingUIFloaterPosition(props.editor, element);
|
||||
},
|
||||
|
||||
onUpdate: (props) => {
|
||||
@@ -83,22 +71,24 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
component.updateProps(props);
|
||||
if (!props.clientRect) return;
|
||||
const element = component.element as HTMLElement;
|
||||
cleanup();
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, element).cleanup;
|
||||
updateFloatingUIFloaterPosition(props.editor, element);
|
||||
},
|
||||
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === "Escape") {
|
||||
handleClose(this.editor);
|
||||
onKeyDown: (props) => {
|
||||
if (props.event.key === "Escape") {
|
||||
component?.destroy();
|
||||
component = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
return component?.ref?.onKeyDown({ event }) ?? false;
|
||||
return component?.ref?.onKeyDown(props) ?? false;
|
||||
},
|
||||
|
||||
onExit: ({ editor }) => {
|
||||
component?.element.remove();
|
||||
handleClose(editor);
|
||||
// Remove from active dropdowns
|
||||
editor?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS);
|
||||
component?.destroy();
|
||||
component = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,52 +1,46 @@
|
||||
import {
|
||||
computePosition,
|
||||
flip,
|
||||
type Strategy,
|
||||
type Placement,
|
||||
shift,
|
||||
ReferenceElement,
|
||||
autoUpdate,
|
||||
} from "@floating-ui/dom";
|
||||
import { computePosition, flip, type Middleware, type Strategy, type Placement, shift } from "@floating-ui/dom";
|
||||
import { type Editor, posToDOMRect } from "@tiptap/core";
|
||||
|
||||
export type UpdateFloatingUIFloaterPosition = (
|
||||
export const updateFloatingUIFloaterPosition = (
|
||||
editor: Editor,
|
||||
element: HTMLElement,
|
||||
options?: {
|
||||
elementStyle?: Partial<CSSStyleDeclaration>;
|
||||
middleware?: Middleware[];
|
||||
placement?: Placement;
|
||||
strategy?: Strategy;
|
||||
}
|
||||
) => {
|
||||
cleanup: () => void;
|
||||
};
|
||||
const editorElement = editor.options.element;
|
||||
let container: Element | HTMLElement = document.body;
|
||||
|
||||
export const updateFloatingUIFloaterPosition: UpdateFloatingUIFloaterPosition = (editor, element, options) => {
|
||||
document.body.appendChild(element);
|
||||
if (editorElement instanceof Element) {
|
||||
container = editorElement;
|
||||
} else if (editorElement && typeof editorElement === "object" && "mount" in editorElement) {
|
||||
container = editorElement.mount;
|
||||
} else if (typeof editorElement === "function") {
|
||||
container = document.body;
|
||||
}
|
||||
|
||||
const virtualElement: ReferenceElement = {
|
||||
container.appendChild(element);
|
||||
|
||||
const virtualElement = {
|
||||
getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
|
||||
};
|
||||
|
||||
const cleanup = autoUpdate(virtualElement, element, () => {
|
||||
computePosition(virtualElement, element, {
|
||||
placement: options?.placement ?? "bottom-start",
|
||||
strategy: options?.strategy ?? "fixed",
|
||||
middleware: [shift(), flip()],
|
||||
computePosition(virtualElement, element, {
|
||||
placement: options?.placement ?? "bottom-start",
|
||||
strategy: options?.strategy ?? "absolute",
|
||||
middleware: options?.middleware ?? [shift(), flip()],
|
||||
})
|
||||
.then(({ x, y, strategy }) => {
|
||||
Object.assign(element.style, {
|
||||
width: "max-content",
|
||||
position: strategy,
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
...options?.elementStyle,
|
||||
});
|
||||
})
|
||||
.then(({ x, y, strategy }) => {
|
||||
Object.assign(element.style, {
|
||||
width: "max-content",
|
||||
position: strategy,
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
...options?.elementStyle,
|
||||
});
|
||||
})
|
||||
.catch((error) => console.error("An error occurred while updating floating UI floater position:", error));
|
||||
});
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
};
|
||||
.catch((error) => console.error("An error occurred while updating floating UI floter position:", error));
|
||||
};
|
||||
|
||||
@@ -45,6 +45,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||
() =>
|
||||
new HocuspocusProvider({
|
||||
name: id,
|
||||
parameters: realtimeConfig.queryParams,
|
||||
// using user id as a token to verify the user on the server
|
||||
token: JSON.stringify(user),
|
||||
url: realtimeConfig.url,
|
||||
|
||||
@@ -42,6 +42,7 @@ export type TUserDetails = {
|
||||
|
||||
export type TRealtimeConfig = {
|
||||
url: string;
|
||||
queryParams: TWebhookConnectionQueryParams;
|
||||
};
|
||||
|
||||
export type IMarking = {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "^20.14.9",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
"./styles/react-day-picker": "./dist/styles/react-day-picker.css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui-components/react": "1.0.0-beta.3",
|
||||
"@base-ui-components/react": "^1.0.0-beta.2",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import * as React from "react";
|
||||
import { Combobox as BaseCombobox } from "@base-ui-components/react/combobox";
|
||||
import { Search } from "lucide-react";
|
||||
import { Command } from "../command/command";
|
||||
import { Popover } from "../popover/root";
|
||||
import { cn } from "../utils/classname";
|
||||
|
||||
// Type definitions
|
||||
type TMaxHeight = "lg" | "md" | "rg" | "sm";
|
||||
export interface ComboboxOption {
|
||||
value: unknown;
|
||||
query: string;
|
||||
content: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
tooltip?: string | React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ComboboxProps {
|
||||
value?: string | string[];
|
||||
@@ -22,21 +27,19 @@ export interface ComboboxButtonProps {
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
ref?: React.Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export interface ComboboxOptionsProps {
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
showSearch?: boolean;
|
||||
showCheckIcon?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
maxHeight?: TMaxHeight;
|
||||
maxHeight?: "lg" | "md" | "rg" | "sm";
|
||||
inputClassName?: string;
|
||||
optionsContainerClassName?: string;
|
||||
positionerClassName?: string;
|
||||
searchQuery?: string;
|
||||
onSearchQueryChange?: (query: string) => void;
|
||||
}
|
||||
|
||||
export interface ComboboxOptionProps {
|
||||
@@ -46,173 +49,249 @@ export interface ComboboxOptionProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Constants
|
||||
const MAX_HEIGHT_CLASSES: Record<TMaxHeight, string> = {
|
||||
lg: "max-h-60",
|
||||
md: "max-h-48",
|
||||
rg: "max-h-36",
|
||||
sm: "max-h-28",
|
||||
} as const;
|
||||
// Context for sharing state between components
|
||||
interface ComboboxContextType {
|
||||
value: string | string[];
|
||||
onValueChange?: (value: string | string[]) => void;
|
||||
multiSelect: boolean;
|
||||
maxSelections?: number;
|
||||
disabled: boolean;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
handleValueChange: (newValue: string) => void;
|
||||
handleRemoveSelection: (valueToRemove: string) => void;
|
||||
}
|
||||
|
||||
// Root component
|
||||
function ComboboxRoot({
|
||||
const ComboboxContext = React.createContext<ComboboxContextType | null>(null);
|
||||
|
||||
function useComboboxContext() {
|
||||
const context = React.useContext(ComboboxContext);
|
||||
if (!context) {
|
||||
throw new Error("Combobox components must be used within a Combobox");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function ComboboxComponent({
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
multiSelect = false,
|
||||
maxSelections,
|
||||
disabled = false,
|
||||
open,
|
||||
open: openProp,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: ComboboxProps) {
|
||||
const handleValueChange = React.useCallback(
|
||||
(newValue: string | string[]) => {
|
||||
onValueChange?.(newValue);
|
||||
// Controlled/uncontrolled value
|
||||
const isControlledValue = value !== undefined;
|
||||
const [internalValue, setInternalValue] = React.useState<string | string[]>(
|
||||
(isControlledValue ? (value as string | string[]) : defaultValue) ?? (multiSelect ? [] : "")
|
||||
);
|
||||
|
||||
// Controlled/uncontrolled open state
|
||||
const isControlledOpen = openProp !== undefined;
|
||||
const [internalOpen, setInternalOpen] = React.useState(false);
|
||||
const open = isControlledOpen ? (openProp as boolean) : internalOpen;
|
||||
|
||||
const setOpen = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!isControlledOpen) {
|
||||
setInternalOpen(nextOpen);
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
[onValueChange]
|
||||
[isControlledOpen, onOpenChange]
|
||||
);
|
||||
|
||||
// Update internal value when prop changes
|
||||
React.useEffect(() => {
|
||||
if (isControlledValue) {
|
||||
setInternalValue(value as string | string[]);
|
||||
}
|
||||
}, [isControlledValue, value]);
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(newValue: string) => {
|
||||
if (multiSelect) {
|
||||
// Functional update to avoid stale closures
|
||||
if (!isControlledValue) {
|
||||
setInternalValue((prev) => {
|
||||
const currentValues = Array.isArray(prev) ? (prev as string[]) : [];
|
||||
const isSelected = currentValues.includes(newValue);
|
||||
|
||||
if (!isSelected) {
|
||||
if (maxSelections && currentValues.length >= maxSelections) {
|
||||
return currentValues; // limit reached
|
||||
}
|
||||
const updated = [...currentValues, newValue];
|
||||
onValueChange?.(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
const updated = currentValues.filter((v) => v !== newValue);
|
||||
onValueChange?.(updated);
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
// Controlled value: compute next and notify only
|
||||
const currentValues = Array.isArray(internalValue) ? (internalValue as string[]) : [];
|
||||
const isSelected = currentValues.includes(newValue);
|
||||
let updated: string[];
|
||||
if (isSelected) {
|
||||
updated = currentValues.filter((v) => v !== newValue);
|
||||
} else {
|
||||
if (maxSelections && currentValues.length >= maxSelections) {
|
||||
return;
|
||||
}
|
||||
updated = [...currentValues, newValue];
|
||||
}
|
||||
onValueChange?.(updated);
|
||||
}
|
||||
} else {
|
||||
if (!isControlledValue) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
onValueChange?.(newValue);
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[multiSelect, isControlledValue, internalValue, maxSelections, onValueChange, setOpen]
|
||||
);
|
||||
|
||||
const handleRemoveSelection = React.useCallback(
|
||||
(valueToRemove: string) => {
|
||||
if (!multiSelect) return;
|
||||
|
||||
if (!isControlledValue) {
|
||||
setInternalValue((prev) => {
|
||||
const currentValues = Array.isArray(prev) ? (prev as string[]) : [];
|
||||
const updated = currentValues.filter((v) => v !== valueToRemove);
|
||||
onValueChange?.(updated);
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
const currentValues = Array.isArray(internalValue) ? (internalValue as string[]) : [];
|
||||
const updated = currentValues.filter((v) => v !== valueToRemove);
|
||||
onValueChange?.(updated);
|
||||
}
|
||||
},
|
||||
[multiSelect, isControlledValue, internalValue, onValueChange]
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo<ComboboxContextType>(
|
||||
() => ({
|
||||
value: internalValue,
|
||||
onValueChange,
|
||||
multiSelect,
|
||||
maxSelections,
|
||||
disabled,
|
||||
open,
|
||||
setOpen,
|
||||
handleValueChange,
|
||||
handleRemoveSelection,
|
||||
}),
|
||||
[
|
||||
internalValue,
|
||||
onValueChange,
|
||||
multiSelect,
|
||||
maxSelections,
|
||||
disabled,
|
||||
open,
|
||||
setOpen,
|
||||
handleValueChange,
|
||||
handleRemoveSelection,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseCombobox.Root
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={handleValueChange}
|
||||
multiple={multiSelect}
|
||||
disabled={disabled}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
{children}
|
||||
</BaseCombobox.Root>
|
||||
<ComboboxContext.Provider value={contextValue}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{children}
|
||||
</Popover>
|
||||
</ComboboxContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger button component
|
||||
const ComboboxButton = React.forwardRef<HTMLButtonElement, ComboboxButtonProps>(
|
||||
({ className, children, disabled = false }, ref) => (
|
||||
<BaseCombobox.Trigger ref={ref} disabled={disabled} className={className}>
|
||||
function ComboboxButton({ className, children, disabled = false }: ComboboxButtonProps) {
|
||||
const { disabled: ctxDisabled, open } = useComboboxContext();
|
||||
const isDisabled = disabled || ctxDisabled;
|
||||
return (
|
||||
<Popover.Button
|
||||
disabled={isDisabled}
|
||||
aria-disabled={isDisabled || undefined}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</BaseCombobox.Trigger>
|
||||
)
|
||||
);
|
||||
</Popover.Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Options popup component
|
||||
function ComboboxOptions({
|
||||
children,
|
||||
showSearch = false,
|
||||
searchPlaceholder,
|
||||
maxHeight = "lg",
|
||||
maxHeight,
|
||||
className,
|
||||
inputClassName,
|
||||
optionsContainerClassName,
|
||||
emptyMessage = "No results found",
|
||||
emptyMessage,
|
||||
positionerClassName,
|
||||
searchQuery: controlledSearchQuery,
|
||||
onSearchQueryChange,
|
||||
}: ComboboxOptionsProps) {
|
||||
// const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [internalSearchQuery, setInternalSearchQuery] = React.useState("");
|
||||
|
||||
const searchQuery = controlledSearchQuery !== undefined ? controlledSearchQuery : internalSearchQuery;
|
||||
|
||||
const setSearchQuery = React.useCallback(
|
||||
(query: string) => {
|
||||
if (onSearchQueryChange) {
|
||||
onSearchQueryChange(query);
|
||||
} else {
|
||||
setInternalSearchQuery(query);
|
||||
}
|
||||
},
|
||||
[onSearchQueryChange]
|
||||
);
|
||||
|
||||
// Filter children based on search query
|
||||
const filteredChildren = React.useMemo(() => {
|
||||
if (!showSearch || !searchQuery) return children;
|
||||
|
||||
return React.Children.toArray(children).filter((child) => {
|
||||
if (!React.isValidElement(child)) return true;
|
||||
|
||||
// Only filter ComboboxOption components, leave other elements (like additional content) unfiltered
|
||||
if (child.type !== ComboboxOption) return true;
|
||||
|
||||
// Extract text content from child to search against
|
||||
const getTextContent = (node: React.ReactNode): string => {
|
||||
if (typeof node === "string") return node;
|
||||
if (typeof node === "number") return String(node);
|
||||
if (React.isValidElement(node) && node.props.children) {
|
||||
return getTextContent(node.props.children);
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(getTextContent).join(" ");
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const textContent = getTextContent(child.props.children);
|
||||
const value = child.props.value || "";
|
||||
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return textContent.toLowerCase().includes(searchLower) || String(value).toLowerCase().includes(searchLower);
|
||||
});
|
||||
}, [children, searchQuery, showSearch]);
|
||||
|
||||
const { multiSelect } = useComboboxContext();
|
||||
return (
|
||||
<BaseCombobox.Portal>
|
||||
<BaseCombobox.Positioner sideOffset={8} className={positionerClassName}>
|
||||
<BaseCombobox.Popup
|
||||
className={cn("rounded-md border border-custom-border-200 bg-custom-background-100 p-1 shadow-lg", className)}
|
||||
<Popover.Panel sideOffset={8} className={cn(className)} positionerClassName={positionerClassName}>
|
||||
<Command>
|
||||
{showSearch && <Command.Input placeholder={searchPlaceholder} className={cn(inputClassName)} />}
|
||||
<Command.List
|
||||
className={cn(
|
||||
{
|
||||
"max-h-60": maxHeight === "lg",
|
||||
"max-h-48": maxHeight === "md",
|
||||
"max-h-36": maxHeight === "rg",
|
||||
"max-h-28": maxHeight === "sm",
|
||||
},
|
||||
optionsContainerClassName
|
||||
)}
|
||||
role="listbox"
|
||||
aria-multiselectable={multiSelect || undefined}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{showSearch && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-custom-text-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn(
|
||||
"w-full rounded border border-custom-border-100 bg-custom-background-90 py-1.5 pl-8 pr-2 text-sm outline-none placeholder:text-custom-text-400",
|
||||
inputClassName
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<BaseCombobox.List
|
||||
className={cn("overflow-auto outline-none", MAX_HEIGHT_CLASSES[maxHeight], optionsContainerClassName)}
|
||||
>
|
||||
{filteredChildren}
|
||||
{showSearch &&
|
||||
emptyMessage &&
|
||||
React.Children.count(
|
||||
React.Children.toArray(filteredChildren).filter(
|
||||
(child) => React.isValidElement(child) && child.type === ComboboxOption
|
||||
)
|
||||
) === 0 && <div className="px-2 py-1.5 text-sm text-custom-text-400">{emptyMessage}</div>}
|
||||
</BaseCombobox.List>
|
||||
</div>
|
||||
</BaseCombobox.Popup>
|
||||
</BaseCombobox.Positioner>
|
||||
</BaseCombobox.Portal>
|
||||
{children}
|
||||
</Command.List>
|
||||
<Command.Empty>{emptyMessage ?? "No options found."}</Command.Empty>
|
||||
</Command>
|
||||
</Popover.Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// Individual option component
|
||||
function ComboboxOption({ value, children, disabled, className }: ComboboxOptionProps) {
|
||||
const { handleValueChange, multiSelect, maxSelections, value: selectedValue } = useComboboxContext();
|
||||
|
||||
const stringValue = value;
|
||||
const isSelected = React.useMemo(() => {
|
||||
if (!multiSelect) return false;
|
||||
return Array.isArray(selectedValue) ? (selectedValue as string[]).includes(stringValue) : false;
|
||||
}, [multiSelect, selectedValue, stringValue]);
|
||||
|
||||
const reachedMax = React.useMemo(() => {
|
||||
if (!multiSelect || !maxSelections) return false;
|
||||
const currentLength = Array.isArray(selectedValue) ? (selectedValue as string[]).length : 0;
|
||||
return currentLength >= maxSelections && !isSelected;
|
||||
}, [multiSelect, maxSelections, selectedValue, isSelected]);
|
||||
|
||||
const isDisabled = disabled || reachedMax;
|
||||
|
||||
return (
|
||||
<BaseCombobox.Item
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className={cn("cursor-pointer rounded px-2 py-1.5 text-sm outline-none transition-colors", className)}
|
||||
>
|
||||
<Command.Item value={stringValue} disabled={isDisabled} onSelect={handleValueChange} className={className}>
|
||||
{children}
|
||||
</BaseCombobox.Item>
|
||||
</Command.Item>
|
||||
);
|
||||
}
|
||||
|
||||
// Compound component export
|
||||
const Combobox = Object.assign(ComboboxRoot, {
|
||||
// compound component
|
||||
const Combobox = Object.assign(ComboboxComponent, {
|
||||
Button: ComboboxButton,
|
||||
Options: ComboboxOptions,
|
||||
Option: ComboboxOption,
|
||||
|
||||
@@ -9,7 +9,7 @@ export const CycleVerticalStackIllustration = ({ className }: TIllustrationAsset
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g clipPath="url(#clip0_446_128653)">
|
||||
<g clip-path="url(#clip0_446_128653)">
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M10.364 131.59C10.364 133.53 11.8174 135.464 14.7191 136.947L52.0819 155.967C57.8905 158.923 67.3043 158.923 73.1129 155.967L151.624 116.002C154.525 114.525 155.979 112.59 155.979 110.651V116.844C155.979 118.783 154.525 120.718 151.624 122.195L73.1129 162.16C67.3043 165.116 57.8905 165.116 52.0819 162.16L14.7191 143.14C11.8123 141.662 10.364 139.723 10.364 137.783V131.59Z"
|
||||
|
||||
@@ -9,7 +9,7 @@ export const ProjectVerticalStackIllustration = ({ className }: TIllustrationAss
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g clipPath="url(#clip0_145_15535)">
|
||||
<g clip-path="url(#clip0_145_15535)">
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M16.9216 113.94C16.9216 115.622 18.1809 117.299 20.6948 118.585L53.0651 135.079C58.0975 137.642 66.2534 137.642 71.2858 135.079L139.306 100.422C141.82 99.1406 143.079 97.4632 143.079 95.7812V101.152C143.079 102.834 141.82 104.511 139.306 105.793L71.2858 140.45C66.2534 143.013 58.0975 143.013 53.0651 140.45L20.6948 123.956C18.1764 122.674 16.9216 120.992 16.9216 119.31V113.94Z"
|
||||
|
||||
@@ -16,7 +16,7 @@ export const PlaneLockup: React.FC<ISvgIcons> = ({
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g clipPath="url(#clip0_27_76)">
|
||||
<g clip-path="url(#clip0_27_76)">
|
||||
<path
|
||||
d="M217.077 51.4468H210.972L210.98 31.4317C210.717 27.7545 208.786 25.1545 205.03 24.6199C198.587 23.7006 194.663 27.8828 194.21 34.0258L194.218 51.4468H188.041V20.2543H192.158L194.255 26.8846H194.6C195.825 22.8701 198.684 20.4831 202.898 19.5757C210.126 18.0231 216.643 22.2724 217.079 29.8239V51.4468H217.077Z"
|
||||
fill={color}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Toast, setToast, updateToast, setPromiseToast, TOAST_TYPE } from "./toast";
|
||||
import { useState } from "react";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Toast",
|
||||
@@ -154,19 +153,9 @@ export const WithActionItems: Story = {
|
||||
title: "File uploaded",
|
||||
message: "Your file has been uploaded successfully.",
|
||||
actionItems: (
|
||||
<div className="flex items-center gap-1 text-xs text-custom-text-200">
|
||||
<a
|
||||
href="#"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-custom-primary px-2 py-1 hover:bg-custom-background-90 font-medium rounded"
|
||||
>
|
||||
{`View work item`}
|
||||
</a>
|
||||
<button className="cursor-pointer hidden group-hover:flex px-2 py-1 text-custom-text-300 hover:text-custom-text-200 hover:bg-custom-background-90 rounded">
|
||||
Copy link
|
||||
</button>
|
||||
</div>
|
||||
<button className="rounded bg-blue-500 px-3 py-1 text-xs text-white hover:bg-blue-600">
|
||||
View File
|
||||
</button>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -111,35 +111,7 @@ const ToastRender = ({ id, toast }: { id: React.Key; toast: BaseToast.Root.Toast
|
||||
toast={toast}
|
||||
key={id}
|
||||
className={cn(
|
||||
// Base layout and positioning
|
||||
"flex group items-center rounded-lg border shadow-sm p-2 w-[350px]",
|
||||
"absolute right-3 bottom-3 z-[calc(1000-var(--toast-index))]",
|
||||
"select-none transition-[opacity,transform] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||
|
||||
// Default transform with stacking and scaling
|
||||
"[transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+calc(min(var(--toast-index),10)*-10px)))_scale(calc(max(0,1-(var(--toast-index)*0.1))))]",
|
||||
|
||||
// Pseudo-element for gap spacing
|
||||
"after:absolute after:bottom-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
|
||||
|
||||
// State-based opacity
|
||||
"data-[ending-style]:opacity-0 data-[limited]:opacity-0",
|
||||
|
||||
// Starting animation
|
||||
"data-[starting-style]:[transform:translateY(150%)]",
|
||||
|
||||
// Expanded state transform
|
||||
"data-[expanded]:[transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y)))]",
|
||||
|
||||
// Swipe direction endings - consolidated
|
||||
"data-[ending-style]:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
|
||||
"data-[ending-style]:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
|
||||
"data-[ending-style]:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
|
||||
"data-[ending-style]:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
|
||||
|
||||
// Default ending transform for non-limited toasts
|
||||
"data-[ending-style]:[&:not([data-limited])]:[transform:translateY(150%)]",
|
||||
|
||||
"flex items-center rounded-lg border shadow-sm p-2 w-[350px] absolute right-3 bottom-3 z-[calc(1000-var(--toast-index))] [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+calc(min(var(--toast-index),10)*-10px)))_scale(calc(max(0,1-(var(--toast-index)*0.1))))] transition-[opacity,transform] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] select-none after:absolute after:bottom-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-[''] data-[ending-style]:opacity-0 data-[expanded]:[transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y)))] data-[limited]:opacity-0 data-[starting-style]:[transform:translateY(150%)] data-[ending-style]:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))] data-[expanded]:data-[ending-style]:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))] data-[ending-style]:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))] data-[expanded]:data-[ending-style]:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))] data-[ending-style]:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))] data-[expanded]:data-[ending-style]:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))] data-[ending-style]:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))] data-[expanded]:data-[ending-style]:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))] data-[ending-style]:[&:not([data-limited])]:[transform:translateY(150%)]",
|
||||
data.backgroundColorClassName,
|
||||
data.borderColorClassName
|
||||
)}
|
||||
|
||||
@@ -23,8 +23,7 @@
|
||||
"dependencies": {
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"axios": "catalog:",
|
||||
"file-type": "^21.0.0"
|
||||
"axios": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// external imports
|
||||
import { fileTypeFromBuffer } from "file-type";
|
||||
// plane imports
|
||||
import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
|
||||
|
||||
/**
|
||||
@@ -16,63 +13,16 @@ export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLRespo
|
||||
return formData;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Detect MIME type from file signature using file-type library
|
||||
* @param {File} file
|
||||
* @returns {Promise<string>} detected MIME type or empty string if unknown
|
||||
*/
|
||||
const detectMimeTypeFromSignature = async (file: File): Promise<string> => {
|
||||
try {
|
||||
// Read first 4KB which is usually sufficient for most file type detection
|
||||
const chunk = file.slice(0, 4096);
|
||||
const buffer = await chunk.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(buffer);
|
||||
|
||||
const fileType = await fileTypeFromBuffer(uint8Array);
|
||||
return fileType?.mime || "";
|
||||
} catch (_error) {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Determine the MIME type of a file using multiple detection methods
|
||||
* @param {File} file
|
||||
* @returns {Promise<string>} detected MIME type
|
||||
*/
|
||||
const detectFileType = async (file: File): Promise<string> => {
|
||||
// check if the file has a MIME type
|
||||
if (file.type && file.type.trim() !== "") {
|
||||
return file.type;
|
||||
}
|
||||
|
||||
// detect from file signature using file-type library
|
||||
try {
|
||||
const signatureType = await detectMimeTypeFromSignature(file);
|
||||
if (signatureType) {
|
||||
return signatureType;
|
||||
}
|
||||
} catch (_error) {
|
||||
console.error("Error detecting file type from signature:", _error);
|
||||
}
|
||||
|
||||
// fallback for unknown files
|
||||
return "application/octet-stream";
|
||||
};
|
||||
|
||||
/**
|
||||
* @description returns the necessary file meta data to upload a file
|
||||
* @param {File} file
|
||||
* @returns {Promise<TFileMetaDataLite>} payload with file info
|
||||
* @returns {TFileMetaDataLite} payload with file info
|
||||
*/
|
||||
export const getFileMetaDataForUpload = async (file: File): Promise<TFileMetaDataLite> => {
|
||||
const fileType = await detectFileType(file);
|
||||
return {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: fileType,
|
||||
};
|
||||
};
|
||||
export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
});
|
||||
|
||||
/**
|
||||
* @description this function returns the assetId from the asset source
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./file-upload.service";
|
||||
export * from "./sites-file.service";
|
||||
export * from "./file.service";
|
||||
export * from "./helper";
|
||||
|
||||
@@ -74,7 +74,7 @@ export class SitesFileService extends FileService {
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async uploadAsset(anchor: string, data: TFileEntityInfo, file: File): Promise<TFileSignedURLResponse> {
|
||||
const fileMetaData = await getFileMetaDataForUpload(file);
|
||||
const fileMetaData = getFileMetaDataForUpload(file);
|
||||
return this.post(`/api/public/assets/v2/anchor/${anchor}/`, {
|
||||
...data,
|
||||
...fileMetaData,
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"target": "ES2022",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@
|
||||
"@storybook/react-webpack5": "^8.1.1",
|
||||
"@storybook/test": "^8.1.1",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "^20.5.2",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-color": "^3.0.9",
|
||||
"@types/react-dom": "catalog:",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
|
||||
|
||||
/**
|
||||
* @description combine the file path with the base URL
|
||||
@@ -13,6 +14,30 @@ export const getFileURL = (path: string): string | undefined => {
|
||||
return `${API_BASE_URL}${path}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description from the provided signed URL response, generate a payload to be used to upload the file
|
||||
* @param {TFileSignedURLResponse} signedURLResponse
|
||||
* @param {File} file
|
||||
* @returns {FormData} file upload request payload
|
||||
*/
|
||||
export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value));
|
||||
formData.append("file", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description returns the necessary file meta data to upload a file
|
||||
* @param {File} file
|
||||
* @returns {TFileMetaDataLite} payload with file info
|
||||
*/
|
||||
export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
});
|
||||
|
||||
/**
|
||||
* @description this function returns the assetId from the asset source
|
||||
* @param {string} src
|
||||
|
||||
Generated
+283
-752
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -22,10 +22,9 @@ catalog:
|
||||
react-dom: 18.3.1
|
||||
"@types/react": 18.3.11
|
||||
"@types/react-dom": 18.3.1
|
||||
"@types/node": 22.12.0
|
||||
typescript: 5.8.3
|
||||
tsdown: 0.15.5
|
||||
vite: 7.1.7
|
||||
vite: 7.0.7
|
||||
uuid: 13.0.0
|
||||
"@tiptap/core": ^3.5.3
|
||||
"@tiptap/html": ^3.5.3
|
||||
|
||||
+2
-3
@@ -17,14 +17,13 @@
|
||||
"NEXT_PUBLIC_POSTHOG_KEY",
|
||||
"NEXT_PUBLIC_POSTHOG_HOST",
|
||||
"NEXT_PUBLIC_POSTHOG_DEBUG",
|
||||
"NEXT_PUBLIC_SUPPORT_EMAIL",
|
||||
"ENABLE_EXPERIMENTAL_COREPACK"
|
||||
"NEXT_PUBLIC_SUPPORT_EMAIL"
|
||||
],
|
||||
"globalDependencies": ["pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".next/**", "dist/**", "build/**"]
|
||||
"outputs": [".next/**", "dist/**"]
|
||||
},
|
||||
"build-storybook": {
|
||||
"dependsOn": ["^build"],
|
||||
|
||||
Reference in New Issue
Block a user