Compare commits

..
Author SHA1 Message Date
pablohashescobar 1053ff4684 feat: add tour_completed_features to Profile model and implement validation in ProfileSerializer
- Introduced a new JSONField `tour_completed_features` in the Profile model with a default value from `get_default_tour_completed_features`.
- Added validation logic in ProfileSerializer to clean and structure `tour_completed_features` input, ensuring only valid keys are retained.
- Updated ExporterHistory model to change `url` field from URLField to TextField for better flexibility.
- Created a new migration to apply these changes to the database.
2025-10-08 17:21:45 +05:30
50 changed files with 952 additions and 1384 deletions
-6
View File
@@ -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
View File
@@ -16,10 +16,9 @@ node_modules
/out/
# Production
/build
dist/
out/
build/
.react-router/
# Misc
.DS_Store
+1 -1
View File
@@ -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:"
+32 -1
View File
@@ -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),
),
]
+1 -1
View File
@@ -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,
+17
View File
@@ -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)
+5 -6
View File
@@ -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",
+25 -1
View File
@@ -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
+1 -1
View File
@@ -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);
+4 -5
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -34,7 +34,7 @@
"@types/express": "4.17.23",
"typescript": "catalog:",
"sharp": "catalog:",
"vite": "catalog:"
"vite": "7.0.7"
},
"onlyBuiltDependencies": [
"@swc/core",
+1 -1
View File
@@ -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:"
+1 -1
View File
@@ -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:",
+2 -2
View File
@@ -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;
},
};
},
+29 -35
View File
@@ -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,
+1
View File
@@ -42,6 +42,7 @@ export type TUserDetails = {
export type TRealtimeConfig = {
url: string;
queryParams: TWebhookConnectionQueryParams;
};
export type IMarking = {
+1 -1
View File
@@ -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:"
+1 -1
View File
@@ -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:",
+1 -1
View File
@@ -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:"
},
+1 -1
View File
@@ -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:*",
+216 -137
View File
@@ -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}
+3 -14
View File
@@ -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>
),
})
}
+1 -29
View File
@@ -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-&lsqb;cubic-bezier(0.22,1,0.36,1)&rsqb;",
// 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
)}
+1 -2
View File
@@ -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:*",
+6 -56
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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
}
}
+1 -1
View File
@@ -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:",
+1 -1
View File
@@ -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:"
+25
View File
@@ -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
+283 -752
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -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
View File
@@ -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"],