Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acf3faa11d |
@@ -3,11 +3,10 @@ name: Build and Lint on Pull Request
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: ["opened", "synchronize", "ready_for_review"]
|
||||
types: ["opened", "synchronize"]
|
||||
|
||||
jobs:
|
||||
get-changed-files:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
|
||||
|
||||
@@ -7,12 +7,12 @@ import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
import { Mails, KeyRound } from "lucide-react";
|
||||
import { TInstanceConfigurationKeys } from "@plane/types";
|
||||
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
|
||||
import { Loader, setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { PageHeader } from "@/components/core";
|
||||
// hooks
|
||||
// helpers
|
||||
import { cn, resolveGeneralTheme } from "@/helpers/common.helper";
|
||||
import { resolveGeneralTheme } from "@/helpers/common.helper";
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// images
|
||||
import githubLightModeImage from "@/public/logos/github-black.png";
|
||||
@@ -45,8 +45,6 @@ const InstanceAuthenticationPage = observer(() => {
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
// theme
|
||||
const { resolvedTheme } = useTheme();
|
||||
// derived values
|
||||
const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? "";
|
||||
|
||||
const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => {
|
||||
setIsSubmitting(true);
|
||||
@@ -131,34 +129,7 @@ const InstanceAuthenticationPage = observer(() => {
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
{formattedConfig ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-lg font-medium pb-1">Sign-up configuration</div>
|
||||
<div className={cn("w-full flex items-center gap-14 rounded")}>
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="grow">
|
||||
<div className={cn("font-medium leading-5 text-custom-text-100 text-sm")}>
|
||||
Allow anyone to sign up without invite
|
||||
</div>
|
||||
<div className={cn("font-normal leading-5 text-custom-text-300 text-xs")}>
|
||||
Toggling this off will disable self sign ups.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableSignUpConfig))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableSignUpConfig)) === true
|
||||
? updateConfig("ENABLE_SIGNUP", "0")
|
||||
: updateConfig("ENABLE_SIGNUP", "1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg font-medium pt-6">Authentication modes</div>
|
||||
<div className="text-lg font-medium">Authentication modes</div>
|
||||
{authenticationMethodsCard.map((method) => (
|
||||
<AuthenticationMethodCard
|
||||
key={method.key}
|
||||
|
||||
@@ -5,11 +5,9 @@ import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { WEB_BASE_URL, cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { WEB_BASE_URL } from "@/helpers/common.helper";
|
||||
import { useTheme } from "@/hooks/store";
|
||||
// assets
|
||||
import packageJson from "package.json";
|
||||
@@ -44,12 +42,9 @@ export const HelpSection: FC = observer(() => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 px-4 h-14 flex-shrink-0",
|
||||
{
|
||||
"flex-col h-auto py-1.5": isSidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
className={`flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-4 py-2 ${
|
||||
isSidebarCollapsed ? "flex-col" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}>
|
||||
<Tooltip tooltipContent="Redirect to plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "0.21.0",
|
||||
"version": "0.20.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.21.0"
|
||||
"version": "0.20.0"
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import uuid
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core.validators import validate_email
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Third party imports
|
||||
from zxcvbn import zxcvbn
|
||||
@@ -48,115 +46,53 @@ class Adapter:
|
||||
def authenticate(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def sanitize_email(self, email):
|
||||
# Check if email is present
|
||||
if not email:
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
|
||||
error_message="INVALID_EMAIL",
|
||||
payload={"email": email},
|
||||
)
|
||||
|
||||
# Sanitize email
|
||||
email = str(email).lower().strip()
|
||||
|
||||
# validate email
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError:
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
|
||||
error_message="INVALID_EMAIL",
|
||||
payload={"email": email},
|
||||
)
|
||||
# Return email
|
||||
return email
|
||||
|
||||
def validate_password(self, email):
|
||||
"""Validate password strength"""
|
||||
results = zxcvbn(self.code)
|
||||
if results["score"] < 3:
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
|
||||
error_message="INVALID_PASSWORD",
|
||||
payload={"email": email},
|
||||
)
|
||||
return
|
||||
|
||||
def __check_signup(self, email):
|
||||
"""Check if sign up is enabled or not and raise exception if not enabled"""
|
||||
|
||||
# Get configuration value
|
||||
(ENABLE_SIGNUP,) = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "ENABLE_SIGNUP",
|
||||
"default": os.environ.get("ENABLE_SIGNUP", "1"),
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
# Check if sign up is disabled and invite is present or not
|
||||
if (
|
||||
ENABLE_SIGNUP == "0"
|
||||
and not WorkspaceMemberInvite.objects.filter(
|
||||
email=email,
|
||||
).exists()
|
||||
):
|
||||
# Raise exception
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
|
||||
error_message="SIGNUP_DISABLED",
|
||||
payload={"email": email},
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def save_user_data(self, user):
|
||||
# Update user details
|
||||
user.last_login_medium = self.provider
|
||||
user.last_active = timezone.now()
|
||||
user.last_login_time = timezone.now()
|
||||
user.last_login_ip = self.request.META.get("REMOTE_ADDR")
|
||||
user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT")
|
||||
user.token_updated_at = timezone.now()
|
||||
user.save()
|
||||
return user
|
||||
|
||||
def complete_login_or_signup(self):
|
||||
# Get email
|
||||
email = self.user_data.get("email")
|
||||
|
||||
# Sanitize email
|
||||
email = self.sanitize_email(email)
|
||||
|
||||
# Check if the user is present
|
||||
user = User.objects.filter(email=email).first()
|
||||
# Check if sign up case or login
|
||||
is_signup = bool(user)
|
||||
# If user is not present, create a new user
|
||||
if not user:
|
||||
# New user
|
||||
self.__check_signup(email)
|
||||
|
||||
# Initialize user
|
||||
(ENABLE_SIGNUP,) = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "ENABLE_SIGNUP",
|
||||
"default": os.environ.get("ENABLE_SIGNUP", "1"),
|
||||
},
|
||||
]
|
||||
)
|
||||
if (
|
||||
ENABLE_SIGNUP == "0"
|
||||
and not WorkspaceMemberInvite.objects.filter(
|
||||
email=email,
|
||||
).exists()
|
||||
):
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
|
||||
error_message="SIGNUP_DISABLED",
|
||||
payload={"email": email},
|
||||
)
|
||||
user = User(email=email, username=uuid.uuid4().hex)
|
||||
|
||||
# Check if password is autoset
|
||||
if self.user_data.get("user").get("is_password_autoset"):
|
||||
user.set_password(uuid.uuid4().hex)
|
||||
user.is_password_autoset = True
|
||||
user.is_email_verified = True
|
||||
|
||||
# Validate password
|
||||
else:
|
||||
# Validate password
|
||||
self.validate_password(email)
|
||||
# Set password
|
||||
results = zxcvbn(self.code)
|
||||
if results["score"] < 3:
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES[
|
||||
"INVALID_PASSWORD"
|
||||
],
|
||||
error_message="INVALID_PASSWORD",
|
||||
payload={"email": email},
|
||||
)
|
||||
|
||||
user.set_password(self.code)
|
||||
user.is_password_autoset = False
|
||||
|
||||
# Set user details
|
||||
avatar = self.user_data.get("user", {}).get("avatar", "")
|
||||
first_name = self.user_data.get("user", {}).get("first_name", "")
|
||||
last_name = self.user_data.get("user", {}).get("last_name", "")
|
||||
@@ -164,8 +100,6 @@ class Adapter:
|
||||
user.first_name = first_name if first_name else ""
|
||||
user.last_name = last_name if last_name else ""
|
||||
user.save()
|
||||
|
||||
# Create profile
|
||||
Profile.objects.create(user=user)
|
||||
|
||||
if not user.is_active:
|
||||
@@ -174,10 +108,15 @@ class Adapter:
|
||||
error_message="USER_ACCOUNT_DEACTIVATED",
|
||||
)
|
||||
|
||||
# Save user data
|
||||
user = self.save_user_data(user=user)
|
||||
# Update user details
|
||||
user.last_login_medium = self.provider
|
||||
user.last_active = timezone.now()
|
||||
user.last_login_time = timezone.now()
|
||||
user.last_login_ip = self.request.META.get("REMOTE_ADDR")
|
||||
user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT")
|
||||
user.token_updated_at = timezone.now()
|
||||
user.save()
|
||||
|
||||
# Call callback if present
|
||||
if self.callback:
|
||||
self.callback(
|
||||
user,
|
||||
@@ -185,9 +124,7 @@ class Adapter:
|
||||
self.request,
|
||||
)
|
||||
|
||||
# Create or update account if token data is present
|
||||
if self.token_data:
|
||||
self.create_update_account(user=user)
|
||||
|
||||
# Return user
|
||||
return user
|
||||
|
||||
@@ -58,8 +58,6 @@ AUTHENTICATION_ERROR_CODES = {
|
||||
"ADMIN_USER_DEACTIVATED": 5190,
|
||||
# Rate limit
|
||||
"RATE_LIMIT_EXCEEDED": 5900,
|
||||
# Unknown
|
||||
"AUTHENTICATION_FAILED": 5999,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -81,11 +81,11 @@ class OauthAdapter(Adapter):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException:
|
||||
if self.provider == "google":
|
||||
code = "GOOGLE_OAUTH_PROVIDER_ERROR"
|
||||
if self.provider == "github":
|
||||
code = "GITHUB_OAUTH_PROVIDER_ERROR"
|
||||
|
||||
code = (
|
||||
"GOOGLE_OAUTH_PROVIDER_ERROR"
|
||||
if self.provider == "google"
|
||||
else "GITHUB_OAUTH_PROVIDER_ERROR"
|
||||
)
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES[code],
|
||||
error_message=str(code),
|
||||
|
||||
@@ -4,7 +4,6 @@ from plane.db.models import (
|
||||
WorkspaceMember,
|
||||
WorkspaceMemberInvite,
|
||||
)
|
||||
from plane.utils.cache import invalidate_cache_directly
|
||||
|
||||
|
||||
def process_workspace_project_invitations(user):
|
||||
@@ -27,16 +26,6 @@ def process_workspace_project_invitations(user):
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
[
|
||||
invalidate_cache_directly(
|
||||
path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/",
|
||||
url_params=False,
|
||||
user=False,
|
||||
multiple=True,
|
||||
)
|
||||
for workspace_member_invite in workspace_member_invites
|
||||
]
|
||||
|
||||
# Check if user has any project invites
|
||||
project_member_invites = ProjectMemberInvite.objects.filter(
|
||||
email=user.email, accepted=True
|
||||
|
||||
@@ -12,7 +12,6 @@ from .base import BaseModel
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
filename = filename[:50]
|
||||
if instance.workspace_id is not None:
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
return f"user-{uuid4().hex}-{filename}"
|
||||
|
||||
@@ -49,8 +49,8 @@ class Command(BaseCommand):
|
||||
instance_name="Plane Community Edition",
|
||||
instance_id=secrets.token_hex(12),
|
||||
license_key=None,
|
||||
current_version=payload.get("version"),
|
||||
latest_version=payload.get("version"),
|
||||
api_key=secrets.token_hex(8),
|
||||
version=payload.get("version"),
|
||||
last_checked_at=timezone.now(),
|
||||
user_count=payload.get("user_count", 0),
|
||||
)
|
||||
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
# Generated by Django 4.2.11 on 2024-06-05 13:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("license", "0002_rename_version_instance_current_version_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="changelog",
|
||||
name="title",
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="changelog",
|
||||
name="version",
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="instance",
|
||||
name="current_version",
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="instance",
|
||||
name="latest_version",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="instance",
|
||||
name="namespace",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="instance",
|
||||
name="product",
|
||||
field=models.CharField(default="plane-ce", max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -21,15 +21,15 @@ class Instance(BaseModel):
|
||||
whitelist_emails = models.TextField(blank=True, null=True)
|
||||
instance_id = models.CharField(max_length=255, unique=True)
|
||||
license_key = models.CharField(max_length=256, null=True, blank=True)
|
||||
current_version = models.CharField(max_length=255)
|
||||
latest_version = models.CharField(max_length=255, null=True, blank=True)
|
||||
current_version = models.CharField(max_length=10)
|
||||
latest_version = models.CharField(max_length=10, null=True, blank=True)
|
||||
product = models.CharField(
|
||||
max_length=255, default=ProductTypes.PLANE_CE.value
|
||||
max_length=50, default=ProductTypes.PLANE_CE.value
|
||||
)
|
||||
domain = models.TextField(blank=True)
|
||||
# Instance specifics
|
||||
last_checked_at = models.DateTimeField()
|
||||
namespace = models.CharField(max_length=255, blank=True, null=True)
|
||||
namespace = models.CharField(max_length=50, blank=True, null=True)
|
||||
# telemetry and support
|
||||
is_telemetry_enabled = models.BooleanField(default=True)
|
||||
is_support_required = models.BooleanField(default=True)
|
||||
@@ -86,9 +86,9 @@ class InstanceConfiguration(BaseModel):
|
||||
class ChangeLog(BaseModel):
|
||||
"""Change Log model to store the release changelogs made in the application."""
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
title = models.CharField(max_length=100)
|
||||
description = models.TextField(blank=True)
|
||||
version = models.CharField(max_length=255)
|
||||
version = models.CharField(max_length=100)
|
||||
tags = models.JSONField(default=list)
|
||||
release_date = models.DateTimeField(null=True)
|
||||
is_release_candidate = models.BooleanField(default=False)
|
||||
|
||||
@@ -66,7 +66,7 @@ def invalidate_cache_directly(
|
||||
custom_path = path if path is not None else request.get_full_path()
|
||||
auth_header = (
|
||||
None
|
||||
if request and request.user.is_anonymous
|
||||
if request.user.is_anonymous
|
||||
else str(request.user.id) if user else None
|
||||
)
|
||||
key = generate_cache_key(custom_path, auth_header)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.21.0",
|
||||
"version": "0.20.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.21.0",
|
||||
"version": "0.20.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor-core",
|
||||
"version": "0.21.0",
|
||||
"version": "0.20.0",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -28,7 +28,6 @@ export interface CustomEditorProps {
|
||||
// undefined when prop is not passed, null if intentionally passed to stop
|
||||
// swr syncing
|
||||
value?: string | null | undefined;
|
||||
provider?: any;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
@@ -54,7 +53,6 @@ export const useEditor = ({
|
||||
forwardedRef,
|
||||
tabIndex,
|
||||
handleEditorReady,
|
||||
provider,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
}: CustomEditorProps) => {
|
||||
@@ -114,7 +112,7 @@ export const useEditor = ({
|
||||
if (value === null || value === undefined) return;
|
||||
if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) {
|
||||
try {
|
||||
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
|
||||
editor.commands.setContent(value);
|
||||
const currentSavedSelection = savedSelectionRef.current;
|
||||
if (currentSavedSelection) {
|
||||
const docLength = editor.state.doc.content.size;
|
||||
@@ -134,7 +132,7 @@ export const useEditor = ({
|
||||
editorRef.current?.commands.clearContent();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
editorRef.current?.commands.setContent(content);
|
||||
},
|
||||
setEditorValueAtCursorPosition: (content: string) => {
|
||||
if (savedSelection) {
|
||||
@@ -176,16 +174,6 @@ export const useEditor = ({
|
||||
editorRef.current?.off("transaction");
|
||||
};
|
||||
},
|
||||
setSynced: () => {
|
||||
if (provider) {
|
||||
provider.setSynced();
|
||||
}
|
||||
},
|
||||
hasUnsyncedChanges: () => {
|
||||
if (provider) {
|
||||
return provider.hasUnsyncedChanges();
|
||||
}
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||
return markdownOutput;
|
||||
|
||||
@@ -52,7 +52,7 @@ export const useReadOnlyEditor = ({
|
||||
// for syncing swr data on tab refocus etc
|
||||
useEffect(() => {
|
||||
if (initialValue === null || initialValue === undefined) return;
|
||||
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
|
||||
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue);
|
||||
}, [editor, initialValue]);
|
||||
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
@@ -62,7 +62,7 @@ export const useReadOnlyEditor = ({
|
||||
editorRef.current?.commands.clearContent();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
editorRef.current?.commands.setContent(content);
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||
|
||||
@@ -3,7 +3,6 @@ import { startImageUpload } from "src/ui/plugins/image/image-upload-handler";
|
||||
import { findTableAncestor } from "src/lib/utils";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { UploadImage } from "src/types/upload-image";
|
||||
import { replaceCodeWithText } from "src/ui/extensions/code/utils/replace-code-block-with-text";
|
||||
|
||||
export const setText = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
|
||||
@@ -55,11 +54,69 @@ export const toggleUnderline = (editor: Editor, range?: Range) => {
|
||||
else editor.chain().focus().toggleUnderline().run();
|
||||
};
|
||||
|
||||
const replaceCodeBlockWithContent = (editor: Editor) => {
|
||||
try {
|
||||
const { schema } = editor.state;
|
||||
const { paragraph } = schema.nodes;
|
||||
let replaced = false;
|
||||
|
||||
const replaceCodeBlock = (from: number, to: number, textContent: string) => {
|
||||
const docSize = editor.state.doc.content.size;
|
||||
|
||||
if (from < 0 || to > docSize || from > to) {
|
||||
console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize);
|
||||
return;
|
||||
}
|
||||
|
||||
// split the textContent by new lines to handle each line as a separate paragraph
|
||||
const lines = textContent.split(/\r?\n/);
|
||||
|
||||
const tr = editor.state.tr;
|
||||
|
||||
// Calculate the position for inserting the first paragraph
|
||||
let insertPos = from;
|
||||
|
||||
// Remove the code block first
|
||||
tr.delete(from, to);
|
||||
|
||||
// For each line, create a paragraph node and insert it
|
||||
lines.forEach((line) => {
|
||||
const paragraphNode = paragraph.create({}, schema.text(line));
|
||||
tr.insert(insertPos, paragraphNode);
|
||||
// Update insertPos for the next insertion
|
||||
insertPos += paragraphNode.nodeSize;
|
||||
});
|
||||
|
||||
// Dispatch the transaction
|
||||
editor.view.dispatch(tr);
|
||||
replaced = true;
|
||||
};
|
||||
|
||||
editor.state.doc.nodesBetween(editor.state.selection.from, editor.state.selection.to, (node, pos) => {
|
||||
if (node.type === schema.nodes.codeBlock) {
|
||||
const startPos = pos;
|
||||
const endPos = pos + node.nodeSize;
|
||||
const textContent = node.textContent;
|
||||
if (textContent.length === 0) {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
}
|
||||
replaceCodeBlock(startPos, endPos, textContent);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!replaced) {
|
||||
console.log("No code block to replace.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("An error occurred while replacing code block content:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleCodeBlock = (editor: Editor, range?: Range) => {
|
||||
try {
|
||||
// if it's a code block, replace it with the code with paragraphs
|
||||
if (editor.isActive("codeBlock")) {
|
||||
replaceCodeWithText(editor);
|
||||
replaceCodeBlockWithContent(editor);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,16 +124,11 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => {
|
||||
const text = editor.state.doc.textBetween(from, to, "\n");
|
||||
const isMultiline = text.includes("\n");
|
||||
|
||||
// if the selection is not a range i.e. empty, then simply convert it into a code block
|
||||
if (editor.state.selection.empty) {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
} else if (isMultiline) {
|
||||
// if the selection is multiline, then also replace the text content with
|
||||
// a code block
|
||||
editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, `\`\`\`\n${text}\n\`\`\``).run();
|
||||
} else {
|
||||
// if the selection is single line, then simply convert it into inline
|
||||
// code
|
||||
editor.chain().focus().toggleCode().run();
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -110,11 +110,6 @@ ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover {
|
||||
}
|
||||
}
|
||||
|
||||
/* the p tag just after the ul tag */
|
||||
ul[data-type="taskList"] + p {
|
||||
margin-top: 0.4rem !important;
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
position: relative;
|
||||
-webkit-appearance: none;
|
||||
@@ -157,10 +152,6 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
}
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > div > p {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||
color: rgb(var(--color-text-400));
|
||||
text-decoration: line-through;
|
||||
|
||||
@@ -11,8 +11,6 @@ export type EditorReadOnlyRefApi = {
|
||||
|
||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
setEditorValueAtCursorPosition: (content: string) => void;
|
||||
setSynced: () => void;
|
||||
hasUnsyncedChanges: () => boolean;
|
||||
executeMenuItemCommand: (itemName: EditorMenuItemNames) => void;
|
||||
isMenuItemActive: (itemName: EditorMenuItemNames) => boolean;
|
||||
onStateChange: (callback: () => void) => () => void;
|
||||
|
||||
@@ -33,7 +33,7 @@ export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200",
|
||||
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200 text-sm",
|
||||
spellcheck: "false",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Editor, findParentNode } from "@tiptap/core";
|
||||
|
||||
type ReplaceCodeBlockParams = {
|
||||
editor: Editor;
|
||||
from: number;
|
||||
to: number;
|
||||
textContent: string;
|
||||
cursorPosInsideCodeblock: number;
|
||||
};
|
||||
|
||||
export function replaceCodeWithText(editor: Editor): void {
|
||||
try {
|
||||
const { from, to } = editor.state.selection;
|
||||
const cursorPosInsideCodeblock = from;
|
||||
let replaced = false;
|
||||
|
||||
editor.state.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (node.type === editor.state.schema.nodes.codeBlock) {
|
||||
const startPos = pos;
|
||||
const endPos = pos + node.nodeSize;
|
||||
const textContent = node.textContent;
|
||||
|
||||
if (textContent.length === 0) {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
} else {
|
||||
transformCodeBlockToParagraphs({
|
||||
editor,
|
||||
from: startPos,
|
||||
to: endPos,
|
||||
textContent,
|
||||
cursorPosInsideCodeblock,
|
||||
});
|
||||
}
|
||||
|
||||
replaced = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!replaced) {
|
||||
console.log("No code block to replace.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("An error occurred while replacing code block content:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function transformCodeBlockToParagraphs({
|
||||
editor,
|
||||
from,
|
||||
to,
|
||||
textContent,
|
||||
cursorPosInsideCodeblock,
|
||||
}: ReplaceCodeBlockParams): void {
|
||||
const { schema } = editor.state;
|
||||
const { paragraph } = schema.nodes;
|
||||
const docSize = editor.state.doc.content.size;
|
||||
|
||||
if (from < 0 || to > docSize || from > to) {
|
||||
console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split the textContent by new lines to handle each line as a separate paragraph for Windows (\r\n) and Unix (\n)
|
||||
const lines = textContent.split(/\r?\n/);
|
||||
const tr = editor.state.tr;
|
||||
let insertPos = from;
|
||||
|
||||
// Remove the code block first
|
||||
tr.delete(from, to);
|
||||
|
||||
// For each line, create a paragraph node and insert it
|
||||
lines.forEach((line) => {
|
||||
// if the line is empty, create a paragraph node with no content
|
||||
const paragraphNode = line.length === 0 ? paragraph.create({}) : paragraph.create({}, schema.text(line));
|
||||
tr.insert(insertPos, paragraphNode);
|
||||
insertPos += paragraphNode.nodeSize;
|
||||
});
|
||||
|
||||
// Now persist the focus to the converted paragraph
|
||||
const parentNodeOffset = findParentNode((node) => node.type === schema.nodes.codeBlock)(editor.state.selection)?.pos;
|
||||
|
||||
if (parentNodeOffset === undefined) throw new Error("Invalid code block offset");
|
||||
|
||||
const lineNumber = getLineNumber(textContent, cursorPosInsideCodeblock, parentNodeOffset);
|
||||
const cursorPosOutsideCodeblock = cursorPosInsideCodeblock + (lineNumber - 1);
|
||||
|
||||
editor.view.dispatch(tr);
|
||||
editor.chain().focus(cursorPosOutsideCodeblock).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the line number where the cursor is located inside the code block.
|
||||
* Assumes the indexing of the content inside the code block is like ProseMirror's indexing.
|
||||
*
|
||||
* @param {string} textContent - The content of the code block.
|
||||
* @param {number} cursorPosition - The absolute cursor position in the document.
|
||||
* @param {number} codeBlockNodePos - The starting position of the code block node in the document.
|
||||
* @returns {number} The 1-based line number where the cursor is located.
|
||||
*/
|
||||
function getLineNumber(textContent: string, cursorPosition: number, codeBlockNodePos: number): number {
|
||||
// Split the text content into lines, handling both Unix and Windows newlines
|
||||
const lines = textContent.split(/\r?\n/);
|
||||
const cursorPosInsideCodeblockRelative = cursorPosition - codeBlockNodePos;
|
||||
|
||||
let startPosition = 0;
|
||||
let lineNumber = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
// Calculate the end position of the current line
|
||||
const endPosition = startPosition + lines[i].length + 1; // +1 for the newline character
|
||||
|
||||
// Check if the cursor position is within the current line
|
||||
if (cursorPosInsideCodeblockRelative >= startPosition && cursorPosInsideCodeblockRelative <= endPosition) {
|
||||
lineNumber = i + 1; // Line numbers are 1-based
|
||||
break;
|
||||
}
|
||||
|
||||
// Update the start position for the next line
|
||||
startPosition = endPosition;
|
||||
}
|
||||
|
||||
return lineNumber;
|
||||
}
|
||||
@@ -72,7 +72,7 @@ const getPrevListDepth = (typeOrName: string, state: EditorState) => {
|
||||
// Traverse up the document structure from the adjusted position
|
||||
for (let d = resolvedPos.depth; d > 0; d--) {
|
||||
const node = resolvedPos.node(d);
|
||||
if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") {
|
||||
if (node.type.name === "bulletList" || node.type.name === "orderedList") {
|
||||
// Increment depth for each list ancestor found
|
||||
depth++;
|
||||
}
|
||||
@@ -146,8 +146,6 @@ export const handleBackspace = (editor: Editor, name: string, parentListTypes: s
|
||||
if (!isAtStartOfNode(editor.state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// is the paragraph node inside of the current list item (maybe with a hard break)
|
||||
const isParaSibling = isCurrentParagraphASibling(editor.state);
|
||||
const isCurrentListItemSublist = prevListIsHigher(name, editor.state);
|
||||
const listItemPos = findListItemPos(name, editor.state);
|
||||
@@ -308,10 +306,7 @@ const isCurrentParagraphASibling = (state: EditorState): boolean => {
|
||||
const currentParagraphNode = $from.parent; // Get the current node where the selection is.
|
||||
|
||||
// Ensure we're in a paragraph and the parent is a list item.
|
||||
if (
|
||||
currentParagraphNode.type.name === "paragraph" &&
|
||||
(listItemNode.type.name === "listItem" || listItemNode.type.name === "taskItem")
|
||||
) {
|
||||
if (currentParagraphNode.type.name === "paragraph" && listItemNode.type.name === "listItem") {
|
||||
let paragraphNodesCount = 0;
|
||||
listItemNode.forEach((child) => {
|
||||
if (child.type.name === "paragraph") {
|
||||
@@ -332,19 +327,16 @@ export function isCursorInSubList(editor: Editor) {
|
||||
|
||||
// Check if the current node is a list item
|
||||
const listItem = editor.schema.nodes.listItem;
|
||||
const taskItem = editor.schema.nodes.taskItem;
|
||||
|
||||
// Traverse up the document tree from the current position
|
||||
for (let depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type === listItem || node.type === taskItem) {
|
||||
if (node.type === listItem) {
|
||||
// If the parent of the list item is also a list, it's a sub-list
|
||||
const parent = $from.node(depth - 1);
|
||||
if (
|
||||
parent &&
|
||||
(parent.type === editor.schema.nodes.bulletList ||
|
||||
parent.type === editor.schema.nodes.orderedList ||
|
||||
parent.type === editor.schema.nodes.taskList)
|
||||
(parent.type === editor.schema.nodes.bulletList || parent.type === editor.schema.nodes.orderedList)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
|
||||
|
||||
return handled;
|
||||
} catch (e) {
|
||||
console.log("Error in handling Delete:", e);
|
||||
console.log("error in handling Backspac:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -104,7 +104,7 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
|
||||
|
||||
return handled;
|
||||
} catch (e) {
|
||||
console.log("Error in handling Backspace:", e);
|
||||
console.log("error in handling Backspac:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -71,7 +71,6 @@ export const CoreEditorExtensions = ({
|
||||
},
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
history: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
dropcursor: {
|
||||
|
||||
@@ -15,7 +15,9 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
}
|
||||
|
||||
function autoJoin(tr: Transaction, newTr: Transaction, nodeTypes: NodeType[]) {
|
||||
function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) {
|
||||
if (!tr.isGeneric) return false;
|
||||
|
||||
// Find all ranges where we might want to join.
|
||||
const ranges: Array<number> = [];
|
||||
for (let i = 0; i < tr.mapping.maps.length; i++) {
|
||||
@@ -26,7 +28,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeTypes: NodeType[]) {
|
||||
|
||||
// Figure out which joinable points exist inside those ranges,
|
||||
// by checking all node boundaries in their parent nodes.
|
||||
const joinable: number[] = [];
|
||||
const joinable = [];
|
||||
for (let i = 0; i < ranges.length; i += 2) {
|
||||
const from = ranges[i],
|
||||
to = ranges[i + 1];
|
||||
@@ -38,7 +40,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeTypes: NodeType[]) {
|
||||
if (!after) break;
|
||||
if (index && joinable.indexOf(pos) == -1) {
|
||||
const before = parent.child(index - 1);
|
||||
if (before.type == after.type && nodeTypes.includes(before.type)) joinable.push(pos);
|
||||
if (before.type == after.type && before.type === nodeType) joinable.push(pos);
|
||||
}
|
||||
pos += after.nodeSize;
|
||||
}
|
||||
@@ -86,15 +88,25 @@ export const CustomKeymap = Extension.create({
|
||||
// Create a new transaction.
|
||||
const newTr = newState.tr;
|
||||
|
||||
const joinableNodes = [
|
||||
newState.schema.nodes["orderedList"],
|
||||
newState.schema.nodes["taskList"],
|
||||
newState.schema.nodes["bulletList"],
|
||||
];
|
||||
let joined = false;
|
||||
for (const transaction of transactions) {
|
||||
const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["orderedList"]);
|
||||
joined = anotherJoin || joined;
|
||||
}
|
||||
if (joined) {
|
||||
return newTr;
|
||||
}
|
||||
},
|
||||
}),
|
||||
new Plugin({
|
||||
key: new PluginKey("unordered-list-merging"),
|
||||
appendTransaction(transactions, oldState, newState) {
|
||||
// Create a new transaction.
|
||||
const newTr = newState.tr;
|
||||
|
||||
let joined = false;
|
||||
for (const transaction of transactions) {
|
||||
const anotherJoin = autoJoin(transaction, newTr, joinableNodes);
|
||||
const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["bulletList"]);
|
||||
joined = anotherJoin || joined;
|
||||
}
|
||||
if (joined) {
|
||||
|
||||
@@ -50,25 +50,9 @@ export async function startImageUpload(
|
||||
};
|
||||
|
||||
try {
|
||||
const fileNameTrimmed = trimFileName(file.name);
|
||||
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(pos ?? 0);
|
||||
const nodeBefore = resolvedPos.nodeBefore;
|
||||
|
||||
// if the image is at the start of the line i.e. when nodeBefore is null
|
||||
if (nodeBefore === null) {
|
||||
if (pos) {
|
||||
// so that the image is not inserted at the next line, else incase the
|
||||
// image is inserted at any line where there's some content, the
|
||||
// position is kept as it is to be inserted at the next line
|
||||
pos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
view.focus();
|
||||
|
||||
const src = await uploadAndValidateImage(fileWithTrimmedName, uploadFile);
|
||||
const src = await uploadAndValidateImage(file, uploadFile);
|
||||
|
||||
if (src == null) {
|
||||
throw new Error("Resolved image URL is undefined.");
|
||||
@@ -128,14 +112,3 @@ async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Prom
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function trimFileName(fileName: string, maxLength = 100) {
|
||||
if (fileName.length > maxLength) {
|
||||
const extension = fileName.split(".").pop();
|
||||
const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1));
|
||||
const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot
|
||||
return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`;
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/document-editor",
|
||||
"version": "0.21.0",
|
||||
"version": "0.20.0",
|
||||
"description": "Package that powers Plane's Pages Editor",
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
// editor-core
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TFileHandler, useEditor } from "@plane/editor-core";
|
||||
// custom provider
|
||||
import { CollaborationProvider } from "src/providers/collaboration-provider";
|
||||
// extensions
|
||||
import { DocumentEditorExtensions } from "src/ui/extensions";
|
||||
// yjs
|
||||
import * as Y from "yjs";
|
||||
|
||||
type DocumentEditorProps = {
|
||||
id: string;
|
||||
fileHandler: TFileHandler;
|
||||
value: Uint8Array;
|
||||
editorClassName: string;
|
||||
onChange: (update: Uint8Array, source?: string) => void;
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
editorProps?: EditorProps;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
mentionHandler: {
|
||||
@@ -51,27 +51,18 @@ export const useDocumentEditor = ({
|
||||
[id]
|
||||
);
|
||||
|
||||
const [isIndexedDbSynced, setIndexedDbIsSynced] = useState(false);
|
||||
|
||||
// update document on value change from server
|
||||
// update document on value change
|
||||
useEffect(() => {
|
||||
if (value.length > 0) {
|
||||
Y.applyUpdate(provider.document, value);
|
||||
}
|
||||
if (value.byteLength > 0) Y.applyUpdate(provider.document, value);
|
||||
}, [value, provider.document]);
|
||||
|
||||
// watch for indexedDb to complete syncing, only after which the editor is
|
||||
// rendered
|
||||
useEffect(() => {
|
||||
async function checkIndexDbSynced() {
|
||||
const hasSynced = await provider.hasIndexedDBSynced();
|
||||
setIndexedDbIsSynced(hasSynced);
|
||||
}
|
||||
checkIndexDbSynced();
|
||||
// indexedDB provider
|
||||
useLayoutEffect(() => {
|
||||
const localProvider = new IndexeddbPersistence(id, provider.document);
|
||||
return () => {
|
||||
setIndexedDbIsSynced(false);
|
||||
localProvider?.destroy();
|
||||
};
|
||||
}, [provider]);
|
||||
}, [provider, id]);
|
||||
|
||||
const editor = useEditor({
|
||||
id,
|
||||
@@ -86,10 +77,9 @@ export const useDocumentEditor = ({
|
||||
setHideDragHandle: setHideDragHandleFunction,
|
||||
provider,
|
||||
}),
|
||||
provider,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
});
|
||||
|
||||
return { editor, isIndexedDbSynced };
|
||||
return editor;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
|
||||
export interface CompleteCollaboratorProviderConfiguration {
|
||||
@@ -13,11 +12,7 @@ export interface CompleteCollaboratorProviderConfiguration {
|
||||
/**
|
||||
* onChange callback
|
||||
*/
|
||||
onChange: (updates: Uint8Array, source?: string) => void;
|
||||
/**
|
||||
* Whether connection to the database has been established and all available content has been loaded or not.
|
||||
*/
|
||||
hasIndexedDBSynced: boolean;
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
}
|
||||
|
||||
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
|
||||
@@ -26,28 +21,19 @@ export type CollaborationProviderConfiguration = Required<Pick<CompleteCollabora
|
||||
export class CollaborationProvider {
|
||||
public configuration: CompleteCollaboratorProviderConfiguration = {
|
||||
name: "",
|
||||
document: new Y.Doc(),
|
||||
// @ts-expect-error cannot be undefined
|
||||
document: undefined,
|
||||
onChange: () => {},
|
||||
hasIndexedDBSynced: false,
|
||||
};
|
||||
|
||||
unsyncedChanges = 0;
|
||||
|
||||
private initialSync = false;
|
||||
|
||||
constructor(configuration: CollaborationProviderConfiguration) {
|
||||
this.setConfiguration(configuration);
|
||||
|
||||
this.indexeddbProvider = new IndexeddbPersistence(`page-${this.configuration.name}`, this.document);
|
||||
this.indexeddbProvider.on("synced", () => {
|
||||
this.configuration.hasIndexedDBSynced = true;
|
||||
});
|
||||
this.configuration.document = configuration.document ?? new Y.Doc();
|
||||
this.document.on("update", this.documentUpdateHandler.bind(this));
|
||||
this.document.on("destroy", this.documentDestroyHandler.bind(this));
|
||||
}
|
||||
|
||||
private indexeddbProvider: IndexeddbPersistence;
|
||||
|
||||
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
@@ -59,49 +45,12 @@ export class CollaborationProvider {
|
||||
return this.configuration.document;
|
||||
}
|
||||
|
||||
public hasUnsyncedChanges(): boolean {
|
||||
return this.unsyncedChanges > 0;
|
||||
}
|
||||
|
||||
private resetUnsyncedChanges() {
|
||||
this.unsyncedChanges = 0;
|
||||
}
|
||||
|
||||
private incrementUnsyncedChanges() {
|
||||
this.unsyncedChanges += 1;
|
||||
}
|
||||
|
||||
public setSynced() {
|
||||
this.resetUnsyncedChanges();
|
||||
}
|
||||
|
||||
public async hasIndexedDBSynced() {
|
||||
await this.indexeddbProvider.whenSynced;
|
||||
return this.configuration.hasIndexedDBSynced;
|
||||
}
|
||||
|
||||
async documentUpdateHandler(_update: Uint8Array, origin: any) {
|
||||
await this.indexeddbProvider.whenSynced;
|
||||
|
||||
documentUpdateHandler(update: Uint8Array, origin: any) {
|
||||
// return if the update is from the provider itself
|
||||
if (origin === this) return;
|
||||
|
||||
// call onChange with the update
|
||||
const stateVector = Y.encodeStateAsUpdate(this.document);
|
||||
|
||||
if (!this.initialSync) {
|
||||
this.configuration.onChange?.(stateVector, "initialSync");
|
||||
this.initialSync = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.configuration.onChange?.(stateVector);
|
||||
this.incrementUnsyncedChanges();
|
||||
}
|
||||
|
||||
getUpdateFromIndexedDB(): Uint8Array {
|
||||
const update = Y.encodeStateAsUpdate(this.document);
|
||||
return update;
|
||||
this.configuration.onChange?.(update);
|
||||
}
|
||||
|
||||
documentDestroyHandler() {
|
||||
|
||||
@@ -19,7 +19,7 @@ interface IDocumentEditor {
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
onChange: (update: Uint8Array, source?: string) => void;
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
@@ -52,7 +52,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
||||
};
|
||||
|
||||
// use document editor
|
||||
const { editor, isIndexedDbSynced } = useDocumentEditor({
|
||||
const editor = useDocumentEditor({
|
||||
id,
|
||||
editorClassName,
|
||||
fileHandler,
|
||||
@@ -72,7 +72,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor || !isIndexedDbSynced) return null;
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<PageRenderer
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor-extensions",
|
||||
"version": "0.21.0",
|
||||
"version": "0.20.0",
|
||||
"description": "Package that powers Plane's Editor with extensions",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -69,6 +69,7 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
|
||||
return true;
|
||||
},
|
||||
allowSpaces: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/lite-text-editor",
|
||||
"version": "0.21.0",
|
||||
"version": "0.20.0",
|
||||
"description": "Package that powers Plane's Comment Editor",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/rich-text-editor",
|
||||
"version": "0.21.0",
|
||||
"version": "0.20.0",
|
||||
"description": "Rich Text Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eslint-config-custom",
|
||||
"private": true,
|
||||
"version": "0.21.0",
|
||||
"version": "0.20.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"devDependencies": {},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tailwind-config-custom",
|
||||
"version": "0.21.0",
|
||||
"version": "0.20.0",
|
||||
"description": "common tailwind configuration across monorepo",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tsconfig",
|
||||
"version": "0.21.0",
|
||||
"version": "0.20.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
"base.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/types",
|
||||
"version": "0.21.0",
|
||||
"version": "0.20.0",
|
||||
"private": true,
|
||||
"main": "./src/index.d.ts"
|
||||
}
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@ export interface IInstance {
|
||||
whitelist_emails: string | undefined;
|
||||
instance_id: string | undefined;
|
||||
license_key: string | undefined;
|
||||
current_version: string | undefined;
|
||||
latest_version: string | undefined;
|
||||
api_key: string | undefined;
|
||||
version: string | undefined;
|
||||
last_checked_at: string | undefined;
|
||||
namespace: string | undefined;
|
||||
is_telemetry_enabled: boolean;
|
||||
|
||||
Vendored
-1
@@ -128,7 +128,6 @@ export interface IUserActivityResponse {
|
||||
prev_page_results: boolean;
|
||||
results: IIssueActivity[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
export type UserAuth = {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@plane/ui",
|
||||
"description": "UI components shared across multiple apps internally",
|
||||
"private": true,
|
||||
"version": "0.21.0",
|
||||
"version": "0.20.0",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -20,15 +20,12 @@
|
||||
"postcss": "postcss styles/globals.css -o styles/output.css --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.1.10",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
||||
"@blueprintjs/core": "^4.16.3",
|
||||
"@blueprintjs/popover2": "^1.13.3",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"clsx": "^2.0.0",
|
||||
"emoji-picker-react": "^4.5.16",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.379.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -7,11 +7,10 @@ export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
target?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
draggable?: boolean;
|
||||
};
|
||||
|
||||
export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((props, ref) => {
|
||||
const { href, onClick, children, target = "_self", disabled = false, className, draggable = false, ...rest } = props;
|
||||
const { href, onClick, children, target = "_self", disabled = false, className, ...rest } = props;
|
||||
const LEFT_CLICK_EVENT_CODE = 0;
|
||||
|
||||
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
@@ -34,15 +33,7 @@ export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((pr
|
||||
if (disabled) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={target}
|
||||
onClick={handleOnClick}
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={className}
|
||||
draggable={draggable}
|
||||
>
|
||||
<a href={href} target={target} onClick={handleOnClick} {...rest} ref={ref} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
Below is a detailed list of the props included:
|
||||
|
||||
### Root Props
|
||||
- value: string | string[]; - Current selected value.
|
||||
- onChange: (value: string | string []) => void; - Callback function for handling value changes.
|
||||
- options: TDropdownOption[] | undefined; - Array of options.
|
||||
- onOpen?: () => void; - Callback function triggered when the dropdown opens.
|
||||
- onClose?: () => void; - Callback function triggered when the dropdown closes.
|
||||
- containerClassName?: (isOpen: boolean) => string; - Function to return the class name for the container based on the open state.
|
||||
- tabIndex?: number; - Sets the tab index for the dropdown.
|
||||
- placement?: Placement; - Determines the placement of the dropdown (e.g., top, bottom, left, right).
|
||||
- disabled?: boolean; - Disables the dropdown if set to true.
|
||||
|
||||
---
|
||||
|
||||
### Button Props
|
||||
- buttonContent?: (isOpen: boolean) => React.ReactNode; - Function to render the content of the button based on the open state.
|
||||
- buttonContainerClassName?: string; - Class name for the button container.
|
||||
- buttonClassName?: string; - Class name for the button itself.
|
||||
|
||||
---
|
||||
|
||||
### Input Props
|
||||
- disableSearch?: boolean; - Disables the search input if set to true.
|
||||
- inputPlaceholder?: string; - Placeholder text for the search input.
|
||||
- inputClassName?: string; - Class name for the search input.
|
||||
- inputIcon?: React.ReactNode; - Icon to be displayed in the search input.
|
||||
- inputContainerClassName?: string; - Class name for the search input container.
|
||||
|
||||
---
|
||||
|
||||
### Options Props
|
||||
- keyExtractor: (option: TDropdownOption) => string; - Function to extract the key from each option.
|
||||
- optionsContainerClassName?: string; - Class name for the options container.
|
||||
- queryArray: string[]; - Array of strings to be used for querying the options.
|
||||
- sortByKey: string; - Key to sort the options by.
|
||||
- firstItem?: (optionValue: string) => boolean; - Function to determine if an option should be the first item.
|
||||
- renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode; - Function to render each option.
|
||||
- loader?: React.ReactNode; - Loader element to be displayed while options are being loaded.
|
||||
- disableSorting?: boolean; - Disables sorting of the options if set to true.
|
||||
|
||||
---
|
||||
|
||||
These properties offer extensive control over the dropdown's behavior and presentation, making it a highly versatile component suitable for various scenarios.
|
||||
@@ -1,38 +0,0 @@
|
||||
import React, { Fragment } from "react";
|
||||
// headless ui
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// helper
|
||||
import { cn } from "../../../helpers";
|
||||
import { IMultiSelectDropdownButton, ISingleSelectDropdownButton } from "../dropdown";
|
||||
|
||||
export const DropdownButton: React.FC<IMultiSelectDropdownButton | ISingleSelectDropdownButton> = (props) => {
|
||||
const {
|
||||
isOpen,
|
||||
buttonContent,
|
||||
buttonClassName,
|
||||
buttonContainerClassName,
|
||||
handleOnClick,
|
||||
value,
|
||||
setReferenceElement,
|
||||
disabled,
|
||||
} = props;
|
||||
return (
|
||||
<Combobox.Button as={Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
},
|
||||
buttonContainerClassName
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{buttonContent ? <>{buttonContent(isOpen)}</> : <span className={cn("", buttonClassName)}>{value}</span>}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./input-search";
|
||||
export * from "./button";
|
||||
export * from "./options";
|
||||
export * from "./loader";
|
||||
@@ -1,58 +0,0 @@
|
||||
import React, { FC, useEffect, useRef } from "react";
|
||||
// headless ui
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// icons
|
||||
import { Search } from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "../../../helpers";
|
||||
|
||||
interface IInputSearch {
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
updateQuery: (query: string) => void;
|
||||
inputIcon?: React.ReactNode;
|
||||
inputContainerClassName?: string;
|
||||
inputClassName?: string;
|
||||
inputPlaceholder?: string;
|
||||
}
|
||||
|
||||
export const InputSearch: FC<IInputSearch> = (props) => {
|
||||
const { isOpen, query, updateQuery, inputIcon, inputContainerClassName, inputClassName, inputPlaceholder } = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
updateQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
inputRef.current && inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2",
|
||||
inputContainerClassName
|
||||
)}
|
||||
>
|
||||
{inputIcon ? <>{inputIcon}</> : <Search className="h-4 w-4 text-custom-text-300" aria-hidden="true" />}
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
"w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none",
|
||||
inputClassName
|
||||
)}
|
||||
value={query}
|
||||
onChange={(e) => updateQuery(e.target.value)}
|
||||
placeholder={inputPlaceholder ?? "Search"}
|
||||
onKeyDown={searchInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export const DropdownOptionsLoader = () => (
|
||||
<div className="flex flex-col gap-1 animate-pulse">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="flex h-[1.925rem] w-full rounded px-1 py-1.5 bg-custom-background-90" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -1,88 +0,0 @@
|
||||
import React from "react";
|
||||
// headless ui
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// icons
|
||||
import { Check } from "lucide-react";
|
||||
// components
|
||||
import { DropdownOptionsLoader, InputSearch } from ".";
|
||||
// helpers
|
||||
import { cn } from "../../../helpers";
|
||||
// types
|
||||
import { IMultiSelectDropdownOptions, ISingleSelectDropdownOptions } from "../dropdown";
|
||||
|
||||
export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSelectDropdownOptions> = (props) => {
|
||||
const {
|
||||
isOpen,
|
||||
query,
|
||||
setQuery,
|
||||
inputIcon,
|
||||
inputPlaceholder,
|
||||
inputClassName,
|
||||
inputContainerClassName,
|
||||
disableSearch,
|
||||
keyExtractor,
|
||||
options,
|
||||
value,
|
||||
renderItem,
|
||||
loader,
|
||||
} = props;
|
||||
return (
|
||||
<>
|
||||
{!disableSearch && (
|
||||
<InputSearch
|
||||
isOpen={isOpen}
|
||||
query={query}
|
||||
updateQuery={(query) => setQuery(query)}
|
||||
inputIcon={inputIcon}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
inputClassName={inputClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||
<>
|
||||
{options ? (
|
||||
options.length > 0 ? (
|
||||
options?.map((option) => (
|
||||
<Combobox.Option
|
||||
key={keyExtractor(option)}
|
||||
value={option.data[option.value]}
|
||||
className={({ active, selected }) =>
|
||||
cn(
|
||||
"flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5",
|
||||
{
|
||||
"bg-custom-background-80": active,
|
||||
"text-custom-text-100": selected,
|
||||
"text-custom-text-200": !selected,
|
||||
},
|
||||
option.className && option.className({ active, selected })
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
{renderItem ? (
|
||||
<>{renderItem({ value: option.data[option.value], selected })}</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-grow truncate">{value}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="px-1.5 py-1 italic text-custom-text-400">No matching results</p>
|
||||
)
|
||||
) : loader ? (
|
||||
<> {loader} </>
|
||||
) : (
|
||||
<DropdownOptionsLoader />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
import { Placement } from "@popperjs/core";
|
||||
|
||||
export interface IDropdown {
|
||||
// root props
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
containerClassName?: (isOpen: boolean) => string;
|
||||
tabIndex?: number;
|
||||
placement?: Placement;
|
||||
disabled?: boolean;
|
||||
|
||||
// button props
|
||||
buttonContent?: (isOpen: boolean) => React.ReactNode;
|
||||
buttonContainerClassName?: string;
|
||||
buttonClassName?: string;
|
||||
|
||||
// input props
|
||||
disableSearch?: boolean;
|
||||
inputPlaceholder?: string;
|
||||
inputClassName?: string;
|
||||
inputIcon?: React.ReactNode;
|
||||
inputContainerClassName?: string;
|
||||
|
||||
// options props
|
||||
keyExtractor: (option: TDropdownOption) => string;
|
||||
optionsContainerClassName?: string;
|
||||
queryArray: string[];
|
||||
sortByKey: string;
|
||||
firstItem?: (optionValue: string) => boolean;
|
||||
renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode;
|
||||
loader?: React.ReactNode;
|
||||
disableSorting?: boolean;
|
||||
}
|
||||
|
||||
export interface TDropdownOption {
|
||||
data: any;
|
||||
value: string;
|
||||
className?: ({ active, selected }: { active: boolean; selected: boolean }) => string;
|
||||
}
|
||||
|
||||
export interface IMultiSelectDropdown extends IDropdown {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
options: TDropdownOption[] | undefined;
|
||||
}
|
||||
|
||||
export interface ISingleSelectDropdown extends IDropdown {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: TDropdownOption[] | undefined;
|
||||
}
|
||||
|
||||
export interface IDropdownButton {
|
||||
isOpen: boolean;
|
||||
buttonContent?: (isOpen: boolean) => React.ReactNode;
|
||||
buttonClassName?: string;
|
||||
buttonContainerClassName?: string;
|
||||
handleOnClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
setReferenceElement: (element: HTMLButtonElement | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IMultiSelectDropdownButton extends IDropdownButton {
|
||||
value: string[];
|
||||
}
|
||||
|
||||
export interface ISingleSelectDropdownButton extends IDropdownButton {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface IDropdownOptions {
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
setQuery: (query: string) => void;
|
||||
|
||||
inputPlaceholder?: string;
|
||||
inputClassName?: string;
|
||||
inputIcon?: React.ReactNode;
|
||||
inputContainerClassName?: string;
|
||||
disableSearch?: boolean;
|
||||
|
||||
keyExtractor: (option: TDropdownOption) => string;
|
||||
renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined;
|
||||
options: TDropdownOption[] | undefined;
|
||||
loader?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface IMultiSelectDropdownOptions extends IDropdownOptions {
|
||||
value: string[];
|
||||
}
|
||||
|
||||
export interface ISingleSelectDropdownOptions extends IDropdownOptions {
|
||||
value: string;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./common";
|
||||
export * from "./multi-select";
|
||||
export * from "./single-select";
|
||||
@@ -1,167 +0,0 @@
|
||||
import React, { FC, useMemo, useRef, useState } from "react";
|
||||
import sortBy from "lodash/sortBy";
|
||||
// headless ui
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// popper-js
|
||||
import { usePopper } from "react-popper";
|
||||
// components
|
||||
import { DropdownButton } from "./common";
|
||||
import { DropdownOptions } from "./common/options";
|
||||
// hooks
|
||||
import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed";
|
||||
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||
// helper
|
||||
import { cn } from "../../helpers";
|
||||
// types
|
||||
import { IMultiSelectDropdown } from "./dropdown";
|
||||
|
||||
export const MultiSelectDropdown: FC<IMultiSelectDropdown> = (props) => {
|
||||
const {
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
onOpen,
|
||||
onClose,
|
||||
containerClassName,
|
||||
tabIndex,
|
||||
placement,
|
||||
disabled,
|
||||
buttonContent,
|
||||
buttonContainerClassName,
|
||||
buttonClassName,
|
||||
disableSearch,
|
||||
inputPlaceholder,
|
||||
inputClassName,
|
||||
inputIcon,
|
||||
inputContainerClassName,
|
||||
keyExtractor,
|
||||
optionsContainerClassName,
|
||||
queryArray,
|
||||
sortByKey,
|
||||
firstItem,
|
||||
renderItem,
|
||||
loader = false,
|
||||
disableSorting,
|
||||
} = props;
|
||||
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// handlers
|
||||
const toggleDropdown = () => {
|
||||
if (!isOpen) onOpen?.();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
if (isOpen) onClose?.();
|
||||
};
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
setQuery?.("");
|
||||
};
|
||||
|
||||
// options
|
||||
const sortedOptions = useMemo(() => {
|
||||
if (!options) return undefined;
|
||||
|
||||
const filteredOptions = (options || []).filter((options) => {
|
||||
const queryString = queryArray.map((query) => options.data[query]).join(" ");
|
||||
return queryString.toLowerCase().includes(query.toLowerCase());
|
||||
});
|
||||
|
||||
if (disableSorting) return filteredOptions;
|
||||
|
||||
return sortBy(filteredOptions, [
|
||||
(option) => firstItem && firstItem(option.data[option.value]),
|
||||
(option) => !(value ?? []).includes(option.data[option.value]),
|
||||
() => sortByKey && sortByKey.toLowerCase(),
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, options]);
|
||||
|
||||
// hooks
|
||||
const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose);
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={cn("h-full", containerClassName)}
|
||||
tabIndex={tabIndex}
|
||||
multiple
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
>
|
||||
<DropdownButton
|
||||
value={value}
|
||||
isOpen={isOpen}
|
||||
setReferenceElement={setReferenceElement}
|
||||
handleOnClick={handleOnClick}
|
||||
buttonContent={buttonContent}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className={cn(
|
||||
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none",
|
||||
optionsContainerClassName
|
||||
)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<DropdownOptions
|
||||
isOpen={isOpen}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
inputIcon={inputIcon}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
inputClassName={inputClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
disableSearch={disableSearch}
|
||||
keyExtractor={keyExtractor}
|
||||
options={sortedOptions}
|
||||
value={value}
|
||||
renderItem={renderItem}
|
||||
loader={loader}
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
@@ -1,166 +0,0 @@
|
||||
import React, { FC, useMemo, useRef, useState } from "react";
|
||||
import sortBy from "lodash/sortBy";
|
||||
// headless ui
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// popper-js
|
||||
import { usePopper } from "react-popper";
|
||||
// components
|
||||
import { DropdownButton } from "./common";
|
||||
import { DropdownOptions } from "./common/options";
|
||||
// hooks
|
||||
import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed";
|
||||
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||
// helper
|
||||
import { cn } from "../../helpers";
|
||||
// types
|
||||
import { ISingleSelectDropdown } from "./dropdown";
|
||||
|
||||
export const Dropdown: FC<ISingleSelectDropdown> = (props) => {
|
||||
const {
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
onOpen,
|
||||
onClose,
|
||||
containerClassName,
|
||||
tabIndex,
|
||||
placement,
|
||||
disabled,
|
||||
buttonContent,
|
||||
buttonContainerClassName,
|
||||
buttonClassName,
|
||||
disableSearch,
|
||||
inputPlaceholder,
|
||||
inputClassName,
|
||||
inputIcon,
|
||||
inputContainerClassName,
|
||||
keyExtractor,
|
||||
optionsContainerClassName,
|
||||
queryArray,
|
||||
sortByKey,
|
||||
firstItem,
|
||||
renderItem,
|
||||
loader = false,
|
||||
disableSorting,
|
||||
} = props;
|
||||
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// handlers
|
||||
const toggleDropdown = () => {
|
||||
if (!isOpen) onOpen?.();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
if (isOpen) onClose?.();
|
||||
};
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
setQuery?.("");
|
||||
};
|
||||
|
||||
// options
|
||||
const sortedOptions = useMemo(() => {
|
||||
if (!options) return undefined;
|
||||
|
||||
const filteredOptions = (options || []).filter((options) => {
|
||||
const queryString = queryArray.map((query) => options.data[query]).join(" ");
|
||||
return queryString.toLowerCase().includes(query.toLowerCase());
|
||||
});
|
||||
|
||||
if (disableSorting) return filteredOptions;
|
||||
|
||||
return sortBy(filteredOptions, [
|
||||
(option) => firstItem && firstItem(option.data[option.value]),
|
||||
(option) => !(value ?? []).includes(option.data[option.value]),
|
||||
() => sortByKey && sortByKey.toLowerCase(),
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, options]);
|
||||
|
||||
// hooks
|
||||
const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose);
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={cn("h-full", containerClassName)}
|
||||
tabIndex={tabIndex}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
>
|
||||
<DropdownButton
|
||||
value={value}
|
||||
isOpen={isOpen}
|
||||
setReferenceElement={setReferenceElement}
|
||||
handleOnClick={handleOnClick}
|
||||
buttonContent={buttonContent}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className={cn(
|
||||
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none",
|
||||
optionsContainerClassName
|
||||
)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<DropdownOptions
|
||||
isOpen={isOpen}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
inputIcon={inputIcon}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
inputClassName={inputClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
disableSearch={disableSearch}
|
||||
keyExtractor={keyExtractor}
|
||||
options={sortedOptions}
|
||||
value={value}
|
||||
renderItem={renderItem}
|
||||
loader={loader}
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
type TUseDropdownKeyPressed = {
|
||||
(
|
||||
onEnterKeyDown: () => void,
|
||||
onEscKeyDown: () => void,
|
||||
stopPropagation?: boolean
|
||||
): (event: React.KeyboardEvent<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
export const useDropdownKeyPressed: TUseDropdownKeyPressed = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => {
|
||||
const stopEventPropagation = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (stopPropagation) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[stopPropagation]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
stopEventPropagation(event);
|
||||
onEnterKeyDown();
|
||||
} else if (event.key === "Escape") {
|
||||
stopEventPropagation(event);
|
||||
onEscKeyDown();
|
||||
} else if (event.key === "Tab") onEscKeyDown();
|
||||
},
|
||||
[onEnterKeyDown, onEscKeyDown, stopEventPropagation]
|
||||
);
|
||||
|
||||
return handleKeyDown;
|
||||
};
|
||||
@@ -4,7 +4,6 @@ export * from "./badge";
|
||||
export * from "./button";
|
||||
export * from "./emoji";
|
||||
export * from "./dropdowns";
|
||||
export * from "./dropdown";
|
||||
export * from "./form-fields";
|
||||
export * from "./icons";
|
||||
export * from "./progress";
|
||||
@@ -14,5 +13,4 @@ export * from "./loader";
|
||||
export * from "./control-link";
|
||||
export * from "./toast";
|
||||
export * from "./drag-handle";
|
||||
export * from "./drop-indicator";
|
||||
export * from "./sortable";
|
||||
export * from "./drop-indicator";
|
||||
@@ -1,62 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { isEqual } from "lodash";
|
||||
import { cn } from "../../helpers";
|
||||
import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
import { DropIndicator } from "../drop-indicator";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
data: any; //@todo make this generic
|
||||
className?: string;
|
||||
};
|
||||
const Draggable = ({ children, data, className }: Props) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<boolean>(false); // NEW
|
||||
const [isDraggedOver, setIsDraggedOver] = useState(false);
|
||||
|
||||
const [closestEdge, setClosestEdge] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
|
||||
if (el) {
|
||||
combine(
|
||||
draggable({
|
||||
element: el,
|
||||
onDragStart: () => setDragging(true), // NEW
|
||||
onDrop: () => setDragging(false), // NEW
|
||||
getInitialData: () => data,
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: el,
|
||||
onDragEnter: (args) => {
|
||||
setIsDraggedOver(true);
|
||||
setClosestEdge(extractClosestEdge(args.self.data));
|
||||
},
|
||||
onDragLeave: () => setIsDraggedOver(false),
|
||||
onDrop: () => {
|
||||
setIsDraggedOver(false);
|
||||
},
|
||||
canDrop: ({ source }) => !isEqual(source.data, data) && source.data.__uuid__ === data.__uuid__,
|
||||
getData: ({ input, element }) =>
|
||||
attachClosestEdge(data, {
|
||||
input,
|
||||
element,
|
||||
allowedEdges: ["top", "bottom"],
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn(dragging && "opacity-25", className)}>
|
||||
{<DropIndicator isVisible={isDraggedOver && closestEdge === "top"} />}
|
||||
{children}
|
||||
{<DropIndicator isVisible={isDraggedOver && closestEdge === "bottom"} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Draggable };
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./sortable";
|
||||
export * from "./draggable";
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { Draggable } from "./draggable";
|
||||
import { Sortable } from "./sortable";
|
||||
|
||||
const meta: Meta<typeof Sortable> = {
|
||||
title: "Sortable",
|
||||
component: Sortable,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Sortable>;
|
||||
|
||||
const data = [
|
||||
{ id: "1", name: "John Doe" },
|
||||
{ id: "2", name: "Jane Doe 2" },
|
||||
{ id: "3", name: "Alice" },
|
||||
{ id: "4", name: "Bob" },
|
||||
{ id: "5", name: "Charlie" },
|
||||
];
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
data,
|
||||
render: (item: any) => (
|
||||
// <Draggable data={item} className="rounded-lg">
|
||||
<div className="border ">{item.name}</div>
|
||||
// </Draggable>
|
||||
),
|
||||
onChange: (data) => console.log(data.map(({ id }) => id)),
|
||||
keyExtractor: (item: any) => item.id,
|
||||
},
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import React, { Fragment, useEffect, useMemo } from "react";
|
||||
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { Draggable } from "./draggable";
|
||||
|
||||
type Props<T> = {
|
||||
data: T[];
|
||||
render: (item: T, index: number) => React.ReactNode;
|
||||
onChange: (data: T[]) => void;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
containerClassName?: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const moveItem = <T,>(
|
||||
data: T[],
|
||||
source: T,
|
||||
destination: T & Record<symbol, string>,
|
||||
keyExtractor: (item: T, index: number) => string
|
||||
) => {
|
||||
const sourceIndex = data.indexOf(source);
|
||||
if (sourceIndex === -1) return data;
|
||||
|
||||
const destinationIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(destination, 0));
|
||||
|
||||
if (destinationIndex === -1) return data;
|
||||
|
||||
const symbolKey = Reflect.ownKeys(destination).find((key) => key.toString() === "Symbol(closestEdge)");
|
||||
const position = symbolKey ? destination[symbolKey as symbol] : "bottom"; // Add 'as symbol' to cast symbolKey to symbol
|
||||
const newData = [...data];
|
||||
const [movedItem] = newData.splice(sourceIndex, 1);
|
||||
|
||||
let adjustedDestinationIndex = destinationIndex;
|
||||
if (position === "bottom") {
|
||||
adjustedDestinationIndex++;
|
||||
}
|
||||
|
||||
// Prevent moving item out of bounds
|
||||
if (adjustedDestinationIndex > newData.length) {
|
||||
adjustedDestinationIndex = newData.length;
|
||||
}
|
||||
|
||||
newData.splice(adjustedDestinationIndex, 0, movedItem);
|
||||
|
||||
return newData;
|
||||
};
|
||||
|
||||
export const Sortable = <T,>({ data, render, onChange, keyExtractor, containerClassName, id }: Props<T>) => {
|
||||
useEffect(() => {
|
||||
const unsubscribe = monitorForElements({
|
||||
onDrop({ source, location }) {
|
||||
const destination = location?.current?.dropTargets[0];
|
||||
if (!destination) return;
|
||||
onChange(moveItem(data, source.data as T, destination.data as T & { closestEdge: string }, keyExtractor));
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up the subscription on unmount
|
||||
return () => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
};
|
||||
}, [data, keyExtractor, onChange]);
|
||||
|
||||
const enhancedData = useMemo(() => {
|
||||
const uuid = id ? id : Math.random().toString(36).substring(7);
|
||||
return data.map((item) => ({ ...item, __uuid__: uuid }));
|
||||
}, [data, id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{enhancedData.map((item, index) => (
|
||||
<Draggable key={keyExtractor(item, index)} data={item} className={containerClassName}>
|
||||
<Fragment>{render(item, index)} </Fragment>
|
||||
</Draggable>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sortable;
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./sub-heading";
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
noMargin?: boolean;
|
||||
};
|
||||
const SubHeading = ({ children, className, noMargin }: Props) => (
|
||||
<h3 className={cn("text-xl font-medium text-custom-text-200 block leading-7", !noMargin && "mb-2", className)}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export { SubHeading };
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "0.21.0",
|
||||
"version": "0.20.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
|
||||
@@ -210,7 +210,11 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
|
||||
{response !== "" && (
|
||||
<div className="page-block-section max-h-[8rem] text-sm">
|
||||
Response:
|
||||
<RichTextReadOnlyEditor initialValue={`<p>${response}</p>`} ref={responseRef} />
|
||||
<RichTextReadOnlyEditor
|
||||
initialValue={`<p>${response}</p>`}
|
||||
containerClassName={response ? "-mx-3 -my-3" : ""}
|
||||
ref={responseRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{invalidResponse && (
|
||||
|
||||
@@ -18,8 +18,6 @@ export const MultipleSelectEntityAction: React.FC<Props> = (props) => {
|
||||
// derived values
|
||||
const isSelected = selectionHelpers.getIsEntitySelected(id);
|
||||
|
||||
if (selectionHelpers.isSelectionDisabled) return null;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
className={cn("!outline-none size-3.5", className)}
|
||||
|
||||
@@ -17,8 +17,6 @@ export const MultipleSelectGroupAction: React.FC<Props> = (props) => {
|
||||
// derived values
|
||||
const groupSelectionStatus = selectionHelpers.isGroupSelected(groupID);
|
||||
|
||||
if (selectionHelpers.isSelectionDisabled) return null;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
className={cn("size-3.5 !outline-none", className)}
|
||||
|
||||
@@ -5,16 +5,14 @@ import { TSelectionHelper, useMultipleSelect } from "@/hooks/use-multiple-select
|
||||
type Props = {
|
||||
children: (helpers: TSelectionHelper) => React.ReactNode;
|
||||
containerRef: React.MutableRefObject<HTMLElement | null>;
|
||||
disabled?: boolean;
|
||||
entities: Record<string, string[]>; // { groupID: entityIds[] }
|
||||
};
|
||||
|
||||
export const MultipleSelectGroup: React.FC<Props> = observer((props) => {
|
||||
const { children, containerRef, disabled = false, entities } = props;
|
||||
const { children, containerRef, entities } = props;
|
||||
|
||||
const helpers = useMultipleSelect({
|
||||
containerRef,
|
||||
disabled,
|
||||
entities,
|
||||
});
|
||||
|
||||
|
||||
@@ -15,13 +15,14 @@ import { StateDropdown } from "@/components/dropdowns";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { ACYCLE_TAB_CHANGED } from "@/constants/event-tracker";
|
||||
import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
// helper
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useIssues, useProject } from "@/hooks/store";
|
||||
import { useIssues, useProject, useEventTracker } from "@/hooks/store";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
|
||||
export type ActiveCycleStatsProps = {
|
||||
@@ -34,6 +35,7 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
const { workspaceSlug, projectId, cycle } = props;
|
||||
|
||||
const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees");
|
||||
const { captureEvent } = useEventTracker();
|
||||
|
||||
const currentValue = (tab: string | null) => {
|
||||
switch (tab) {
|
||||
@@ -47,6 +49,18 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
const getCurrentTab = (index: number) => {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return "Priority-Issues";
|
||||
case 1:
|
||||
return "Assignees";
|
||||
case 2:
|
||||
return "Labels";
|
||||
default:
|
||||
return "Priority-Issues";
|
||||
}
|
||||
};
|
||||
const {
|
||||
issues: { fetchActiveCycleIssues },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
@@ -66,17 +80,9 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
as={Fragment}
|
||||
defaultIndex={currentValue(tab)}
|
||||
onChange={(i) => {
|
||||
switch (i) {
|
||||
case 0:
|
||||
return setTab("Priority-Issues");
|
||||
case 1:
|
||||
return setTab("Assignees");
|
||||
case 2:
|
||||
return setTab("Labels");
|
||||
|
||||
default:
|
||||
return setTab("Priority-Issues");
|
||||
}
|
||||
const currentTab = getCurrentTab(i);
|
||||
setTab(currentTab);
|
||||
captureEvent(ACYCLE_TAB_CHANGED, { tab: currentTab });
|
||||
}}
|
||||
>
|
||||
<Tab.List
|
||||
|
||||
@@ -3,8 +3,10 @@ import { useRouter } from "next/router";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// constants
|
||||
import { CYCLE_ARCHIVED } from "@/constants/event-tracker";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store";
|
||||
import { useCycle, useEventTracker } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
@@ -23,6 +25,7 @@ export const ArchiveCycleModal: React.FC<Props> = (props) => {
|
||||
const [isArchiving, setIsArchiving] = useState(false);
|
||||
// store hooks
|
||||
const { getCycleNameById, archiveCycle } = useCycle();
|
||||
const {captureEvent} =useEventTracker();
|
||||
|
||||
const cycleName = getCycleNameById(cycleId);
|
||||
|
||||
@@ -42,6 +45,9 @@ export const ArchiveCycleModal: React.FC<Props> = (props) => {
|
||||
});
|
||||
onClose();
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/cycles`);
|
||||
captureEvent(CYCLE_ARCHIVED, {
|
||||
cycle_id: cycleId,
|
||||
});
|
||||
})
|
||||
.catch(() =>
|
||||
setToast({
|
||||
|
||||
@@ -10,10 +10,11 @@ import { EmptyState } from "@/components/empty-state";
|
||||
import { CycleModuleListLayout } from "@/components/ui";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { CYCLES_FILTER_REMOVED } from "@/constants/event-tracker";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useCycle, useCycleFilter } from "@/hooks/store";
|
||||
import { useCycle, useCycleFilter, useEventTracker } from "@/hooks/store";
|
||||
|
||||
export const ArchivedCycleLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
@@ -21,6 +22,7 @@ export const ArchivedCycleLayoutRoot: React.FC = observer(() => {
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// hooks
|
||||
const { fetchArchivedCycles, currentProjectArchivedCycleIds, loader } = useCycle();
|
||||
const { captureEvent } = useEventTracker();
|
||||
// cycle filters hook
|
||||
const { clearAllFilters, currentProjectArchivedFilters, updateFilters } = useCycleFilter();
|
||||
// derived values
|
||||
@@ -43,6 +45,11 @@ export const ArchivedCycleLayoutRoot: React.FC = observer(() => {
|
||||
if (!value) newValues = [];
|
||||
else newValues = newValues.filter((val) => val !== value);
|
||||
|
||||
captureEvent(CYCLES_FILTER_REMOVED, {
|
||||
filter_type: key,
|
||||
filter_property: value,
|
||||
current_filters: currentProjectArchivedFilters,
|
||||
});
|
||||
updateFilters(projectId.toString(), { [key]: newValues }, "archived");
|
||||
};
|
||||
|
||||
|
||||
@@ -156,8 +156,6 @@ export const CycleMobileHeader = () => {
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
|
||||
@@ -7,11 +7,13 @@ import { TCycleFilters } from "@plane/types";
|
||||
// components
|
||||
import { CycleFiltersSelection } from "@/components/cycles";
|
||||
import { FiltersDropdown } from "@/components/issues";
|
||||
// constants
|
||||
import { CYCLES_FILTER_APPLIED, CYCLES_FILTER_REMOVED } from "@/constants/event-tracker";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useCycleFilter } from "@/hooks/store";
|
||||
import { useCycleFilter, useEventTracker } from "@/hooks/store";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
|
||||
type Props = {
|
||||
@@ -24,6 +26,7 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// hooks
|
||||
const { currentProjectFilters, searchQuery, updateFilters, updateSearchQuery } = useCycleFilter();
|
||||
const { captureEvent } = useEventTracker();
|
||||
// states
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
|
||||
// outside click detector hook
|
||||
@@ -34,7 +37,7 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
|
||||
const handleFilters = useCallback(
|
||||
(key: keyof TCycleFilters, value: string | string[]) => {
|
||||
if (!projectId) return;
|
||||
const newValues = currentProjectFilters?.[key] ?? [];
|
||||
const newValues = Array.from(currentProjectFilters?.[key] ?? []);
|
||||
|
||||
if (Array.isArray(value))
|
||||
value.forEach((val) => {
|
||||
@@ -46,6 +49,14 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
captureEvent(
|
||||
(currentProjectFilters?.[key] ?? []).length > newValues.length ? CYCLES_FILTER_REMOVED : CYCLES_FILTER_APPLIED,
|
||||
{
|
||||
filter_type: key,
|
||||
filter_property: value,
|
||||
current_filters: currentProjectFilters,
|
||||
}
|
||||
);
|
||||
updateFilters(projectId, { [key]: newValues });
|
||||
},
|
||||
[currentProjectFilters, projectId, updateFilters]
|
||||
|
||||
@@ -57,7 +57,6 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
() => {
|
||||
captureEvent(CYCLE_FAVORITED, {
|
||||
cycle_id: cycleId,
|
||||
element: "List layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
}
|
||||
@@ -87,7 +86,6 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
).then(() => {
|
||||
captureEvent(CYCLE_UNFAVORITED, {
|
||||
cycle_id: cycleId,
|
||||
element: "List layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, set
|
||||
// components
|
||||
import { ArchiveCycleModal, CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles";
|
||||
// constants
|
||||
import { CYCLE_ARCHIVED, CYCLE_RESTORED, E_CYCLES } from "@/constants/event-tracker";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
@@ -31,7 +32,7 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
const [archiveCycleModal, setArchiveCycleModal] = useState(false);
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
// store hooks
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { setTrackElement, captureEvent } = useEventTracker();
|
||||
const {
|
||||
membership: { currentWorkspaceAllProjectsRole },
|
||||
} = useUser();
|
||||
@@ -56,7 +57,7 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
const handleOpenInNewTab = () => window.open(`/${cycleLink}`, "_blank");
|
||||
|
||||
const handleEditCycle = () => {
|
||||
setTrackElement("Cycles page list layout");
|
||||
setTrackElement(E_CYCLES);
|
||||
setUpdateModal(true);
|
||||
};
|
||||
|
||||
@@ -65,6 +66,9 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
const handleRestoreCycle = async () =>
|
||||
await restoreCycle(workspaceSlug, projectId, cycleId)
|
||||
.then(() => {
|
||||
captureEvent(CYCLE_RESTORED, {
|
||||
cycle_id: cycleId,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Restore success",
|
||||
@@ -81,7 +85,7 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
);
|
||||
|
||||
const handleDeleteCycle = () => {
|
||||
setTrackElement("Cycles page list layout");
|
||||
setTrackElement(E_CYCLES);
|
||||
setDeleteModal(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
// components
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||
// types
|
||||
// constants
|
||||
import { BLOCK_HEIGHT } from "../constants";
|
||||
// components
|
||||
import { ChartAddBlock, ChartDraggable } from "../helpers";
|
||||
import { useGanttChart } from "../hooks";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||
|
||||
type Props = {
|
||||
@@ -22,7 +21,6 @@ type Props = {
|
||||
enableBlockMove: boolean;
|
||||
enableAddBlock: boolean;
|
||||
ganttContainerRef: React.RefObject<HTMLDivElement>;
|
||||
selectionHelpers: TSelectionHelper;
|
||||
};
|
||||
|
||||
export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
@@ -35,7 +33,6 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
enableBlockMove,
|
||||
enableAddBlock,
|
||||
ganttContainerRef,
|
||||
selectionHelpers,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { updateActiveBlockId, isBlockActive } = useGanttChart();
|
||||
@@ -73,10 +70,6 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const isBlockSelected = selectionHelpers.getIsEntitySelected(block.id);
|
||||
const isBlockFocused = selectionHelpers.getIsEntityActive(block.id);
|
||||
const isBlockHoveredOn = isBlockActive(block.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`block-${block.id}`}
|
||||
@@ -87,11 +80,10 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
<div
|
||||
className={cn("relative h-full", {
|
||||
"rounded-l border border-r-0 border-custom-primary-70": getIsIssuePeeked(block.data.id),
|
||||
"bg-custom-background-90": isBlockHoveredOn,
|
||||
"bg-custom-primary-100/5 hover:bg-custom-primary-100/10": isBlockSelected,
|
||||
"bg-custom-primary-100/10": isBlockSelected && isBlockHoveredOn,
|
||||
"border border-r-0 border-custom-border-400": isBlockFocused,
|
||||
"bg-custom-background-80": isBlockActive(block.id),
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(
|
||||
block.data.id
|
||||
),
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlockId(block.id)}
|
||||
onMouseLeave={() => updateActiveBlockId(null)}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { FC } from "react";
|
||||
// hooks
|
||||
import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||
// constants
|
||||
import { HEADER_HEIGHT } from "../constants";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||
// components
|
||||
import { HEADER_HEIGHT } from "../constants";
|
||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||
import { GanttChartBlock } from "./block";
|
||||
// types
|
||||
// constants
|
||||
|
||||
export type GanttChartBlocksProps = {
|
||||
itemsContainerWidth: number;
|
||||
@@ -19,7 +17,6 @@ export type GanttChartBlocksProps = {
|
||||
enableAddBlock: boolean;
|
||||
ganttContainerRef: React.RefObject<HTMLDivElement>;
|
||||
showAllBlocks: boolean;
|
||||
selectionHelpers: TSelectionHelper;
|
||||
};
|
||||
|
||||
export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
|
||||
@@ -34,7 +31,6 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
|
||||
enableAddBlock,
|
||||
ganttContainerRef,
|
||||
showAllBlocks,
|
||||
selectionHelpers,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -60,7 +56,6 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableAddBlock={enableAddBlock}
|
||||
ganttContainerRef={ganttContainerRef}
|
||||
selectionHelpers={selectionHelpers}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useRef } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
// components
|
||||
import { MultipleSelectGroup } from "@/components/core";
|
||||
import {
|
||||
BiWeekChartView,
|
||||
DayChartView,
|
||||
@@ -18,12 +18,8 @@ import {
|
||||
WeekChartView,
|
||||
YearChartView,
|
||||
} from "@/components/gantt-chart";
|
||||
import { IssueBulkOperationsRoot } from "@/components/issues";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// constants
|
||||
import { GANTT_SELECT_GROUP } from "../constants";
|
||||
// hooks
|
||||
import { useGanttChart } from "../hooks/use-gantt-chart";
|
||||
|
||||
type Props = {
|
||||
@@ -37,7 +33,6 @@ type Props = {
|
||||
enableBlockRightResize: boolean;
|
||||
enableReorder: boolean;
|
||||
enableAddBlock: boolean;
|
||||
enableSelection: boolean;
|
||||
itemsContainerWidth: number;
|
||||
showAllBlocks: boolean;
|
||||
sidebarToRender: (props: any) => React.ReactNode;
|
||||
@@ -58,7 +53,6 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
|
||||
enableBlockRightResize,
|
||||
enableReorder,
|
||||
enableAddBlock,
|
||||
enableSelection,
|
||||
itemsContainerWidth,
|
||||
showAllBlocks,
|
||||
sidebarToRender,
|
||||
@@ -113,59 +107,43 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
|
||||
const ActiveChartView = CHART_VIEW_COMPONENTS[currentView];
|
||||
|
||||
return (
|
||||
<MultipleSelectGroup
|
||||
containerRef={ganttContainerRef}
|
||||
entities={{
|
||||
[GANTT_SELECT_GROUP]: chartBlocks?.map((block) => block.id) ?? [],
|
||||
}}
|
||||
disabled
|
||||
>
|
||||
{(helpers) => (
|
||||
<>
|
||||
<div
|
||||
// DO NOT REMOVE THE ID
|
||||
id="gantt-container"
|
||||
className={cn(
|
||||
"h-full w-full overflow-auto vertical-scrollbar horizontal-scrollbar scrollbar-lg flex border-t-[0.5px] border-custom-border-200",
|
||||
{
|
||||
"mb-8": bottomSpacing,
|
||||
}
|
||||
)}
|
||||
ref={ganttContainerRef}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<GanttChartSidebar
|
||||
blocks={blocks}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableReorder={enableReorder}
|
||||
enableSelection={enableSelection}
|
||||
sidebarToRender={sidebarToRender}
|
||||
title={title}
|
||||
quickAdd={quickAdd}
|
||||
selectionHelpers={helpers}
|
||||
/>
|
||||
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
|
||||
<ActiveChartView />
|
||||
{currentViewData && (
|
||||
<GanttChartBlocksList
|
||||
itemsContainerWidth={itemsContainerWidth}
|
||||
blocks={chartBlocks}
|
||||
blockToRender={blockToRender}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableAddBlock={enableAddBlock}
|
||||
ganttContainerRef={ganttContainerRef}
|
||||
showAllBlocks={showAllBlocks}
|
||||
selectionHelpers={helpers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<IssueBulkOperationsRoot selectionHelpers={helpers} />
|
||||
</>
|
||||
<div
|
||||
// DO NOT REMOVE THE ID
|
||||
id="gantt-container"
|
||||
className={cn(
|
||||
"h-full w-full overflow-auto vertical-scrollbar horizontal-scrollbar scrollbar-lg flex border-t-[0.5px] border-custom-border-200",
|
||||
{
|
||||
"mb-8": bottomSpacing,
|
||||
}
|
||||
)}
|
||||
</MultipleSelectGroup>
|
||||
ref={ganttContainerRef}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<GanttChartSidebar
|
||||
blocks={blocks}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableReorder={enableReorder}
|
||||
sidebarToRender={sidebarToRender}
|
||||
title={title}
|
||||
quickAdd={quickAdd}
|
||||
/>
|
||||
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
|
||||
<ActiveChartView />
|
||||
{currentViewData && (
|
||||
<GanttChartBlocksList
|
||||
itemsContainerWidth={itemsContainerWidth}
|
||||
blocks={chartBlocks}
|
||||
blockToRender={blockToRender}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableAddBlock={enableAddBlock}
|
||||
ganttContainerRef={ganttContainerRef}
|
||||
showAllBlocks={showAllBlocks}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -32,7 +32,6 @@ type ChartViewRootProps = {
|
||||
enableBlockMove: boolean;
|
||||
enableReorder: boolean;
|
||||
enableAddBlock: boolean;
|
||||
enableSelection: boolean;
|
||||
bottomSpacing: boolean;
|
||||
showAllBlocks: boolean;
|
||||
quickAdd?: React.JSX.Element | undefined;
|
||||
@@ -52,7 +51,6 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
|
||||
enableBlockMove,
|
||||
enableReorder,
|
||||
enableAddBlock,
|
||||
enableSelection,
|
||||
bottomSpacing,
|
||||
showAllBlocks,
|
||||
quickAdd,
|
||||
@@ -186,7 +184,6 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableReorder={enableReorder}
|
||||
enableAddBlock={enableAddBlock}
|
||||
enableSelection={enableSelection}
|
||||
itemsContainerWidth={itemsContainerWidth}
|
||||
showAllBlocks={showAllBlocks}
|
||||
sidebarToRender={sidebarToRender}
|
||||
|
||||
@@ -3,5 +3,3 @@ export const BLOCK_HEIGHT = 44;
|
||||
export const HEADER_HEIGHT = 60;
|
||||
|
||||
export const SIDEBAR_WIDTH = 360;
|
||||
|
||||
export const GANTT_SELECT_GROUP = "gantt-issues";
|
||||
|
||||
@@ -18,7 +18,6 @@ type GanttChartRootProps = {
|
||||
enableBlockMove?: boolean;
|
||||
enableReorder?: boolean;
|
||||
enableAddBlock?: boolean;
|
||||
enableSelection?: boolean;
|
||||
bottomSpacing?: boolean;
|
||||
showAllBlocks?: boolean;
|
||||
};
|
||||
@@ -37,7 +36,6 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
|
||||
enableBlockMove = false,
|
||||
enableReorder = false,
|
||||
enableAddBlock = false,
|
||||
enableSelection = false,
|
||||
bottomSpacing = false,
|
||||
showAllBlocks = false,
|
||||
quickAdd,
|
||||
@@ -58,7 +56,6 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableReorder={enableReorder}
|
||||
enableAddBlock={enableAddBlock}
|
||||
enableSelection={enableSelection}
|
||||
bottomSpacing={bottomSpacing}
|
||||
showAllBlocks={showAllBlocks}
|
||||
quickAdd={quickAdd}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const CyclesSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
<div
|
||||
id={`sidebar-block-${block.id}`}
|
||||
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
|
||||
"bg-custom-background-90": isBlockActive(block.id),
|
||||
"bg-custom-background-80": isBlockActive(block.id),
|
||||
})}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
|
||||
@@ -1,87 +1,63 @@
|
||||
import React, { MutableRefObject } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
// components
|
||||
import { MultipleSelectEntityAction } from "@/components/core";
|
||||
// hooks
|
||||
import { useGanttChart } from "@/components/gantt-chart/hooks";
|
||||
// components
|
||||
import { IssueGanttSidebarBlock } from "@/components/issues";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { findTotalDaysInRange } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||
// constants
|
||||
import { BLOCK_HEIGHT, GANTT_SELECT_GROUP } from "../../constants";
|
||||
// types
|
||||
// constants
|
||||
import { BLOCK_HEIGHT } from "../../constants";
|
||||
import { IGanttBlock } from "../../types";
|
||||
|
||||
type Props = {
|
||||
block: IGanttBlock;
|
||||
enableReorder: boolean;
|
||||
enableSelection: boolean;
|
||||
isDragging: boolean;
|
||||
dragHandleRef: MutableRefObject<HTMLButtonElement | null>;
|
||||
selectionHelpers?: TSelectionHelper;
|
||||
};
|
||||
|
||||
export const IssuesSidebarBlock = observer((props: Props) => {
|
||||
const { block, enableReorder, enableSelection, isDragging, dragHandleRef, selectionHelpers } = props;
|
||||
const { block, enableReorder, isDragging, dragHandleRef } = props;
|
||||
// store hooks
|
||||
const { updateActiveBlockId, isBlockActive } = useGanttChart();
|
||||
const { getIsIssuePeeked } = useIssueDetail();
|
||||
|
||||
const duration = findTotalDaysInRange(block.start_date, block.target_date);
|
||||
|
||||
const isIssueSelected = selectionHelpers?.getIsEntitySelected(block.id);
|
||||
const isIssueFocused = selectionHelpers?.getIsEntityActive(block.id);
|
||||
const isBlockHoveredOn = isBlockActive(block.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("group/list-block", {
|
||||
className={cn({
|
||||
"rounded bg-custom-background-80": isDragging,
|
||||
"rounded-l border border-r-0 border-custom-primary-70": getIsIssuePeeked(block.data.id),
|
||||
"border border-r-0 border-custom-border-400": isIssueFocused,
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(
|
||||
block.data.id
|
||||
),
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlockId(block.id)}
|
||||
onMouseLeave={() => updateActiveBlockId(null)}
|
||||
>
|
||||
<div
|
||||
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
|
||||
"bg-custom-background-90": isBlockHoveredOn,
|
||||
"bg-custom-primary-100/5 hover:bg-custom-primary-100/10": isIssueSelected,
|
||||
"bg-custom-primary-100/10": isIssueSelected && isBlockHoveredOn,
|
||||
"bg-custom-background-80": isBlockActive(block.id),
|
||||
})}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{enableReorder && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
|
||||
ref={dragHandleRef}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{enableSelection && selectionHelpers && (
|
||||
<MultipleSelectEntityAction
|
||||
className={cn(
|
||||
"opacity-0 pointer-events-none group-hover/list-block:opacity-100 group-hover/list-block:pointer-events-auto transition-opacity",
|
||||
{
|
||||
"opacity-100 pointer-events-auto": isIssueSelected,
|
||||
}
|
||||
)}
|
||||
groupId={GANTT_SELECT_GROUP}
|
||||
id={block.id}
|
||||
selectionHelpers={selectionHelpers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{enableReorder && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
|
||||
ref={dragHandleRef}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
|
||||
<div className="flex-grow truncate">
|
||||
<IssueGanttSidebarBlock issueId={block.data.id} />
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import { MutableRefObject } from "react";
|
||||
// components
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
// types
|
||||
import { IGanttBlock, IBlockUpdateData } from "@/components/gantt-chart/types";
|
||||
// hooks
|
||||
import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||
import { GanttDnDHOC } from "../gantt-dnd-HOC";
|
||||
import { handleOrderChange } from "../utils";
|
||||
// types
|
||||
import { IssuesSidebarBlock } from "./block";
|
||||
|
||||
type Props = {
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blocks: IGanttBlock[] | null;
|
||||
enableReorder: boolean;
|
||||
enableSelection: boolean;
|
||||
showAllBlocks?: boolean;
|
||||
selectionHelpers?: TSelectionHelper;
|
||||
};
|
||||
|
||||
export const IssueGanttSidebar: React.FC<Props> = (props) => {
|
||||
const { blockUpdateHandler, blocks, enableReorder, enableSelection, showAllBlocks = false, selectionHelpers } = props;
|
||||
const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props;
|
||||
|
||||
const handleOnDrop = (
|
||||
draggingBlockId: string | undefined,
|
||||
@@ -51,10 +47,8 @@ export const IssueGanttSidebar: React.FC<Props> = (props) => {
|
||||
<IssuesSidebarBlock
|
||||
block={block}
|
||||
enableReorder={enableReorder}
|
||||
enableSelection={enableSelection}
|
||||
isDragging={isDragging}
|
||||
dragHandleRef={dragHandleRef}
|
||||
selectionHelpers={selectionHelpers}
|
||||
/>
|
||||
)}
|
||||
</GanttDnDHOC>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ModulesSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
<div
|
||||
id={`sidebar-block-${block.id}`}
|
||||
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
|
||||
"bg-custom-background-90": isBlockActive(block.id),
|
||||
"bg-custom-background-80": isBlockActive(block.id),
|
||||
})}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
|
||||
@@ -1,38 +1,19 @@
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { MultipleSelectGroupAction } from "@/components/core";
|
||||
import { IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||
// constants
|
||||
import { GANTT_SELECT_GROUP, HEADER_HEIGHT, SIDEBAR_WIDTH } from "../constants";
|
||||
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "../constants";
|
||||
|
||||
type Props = {
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
enableReorder: boolean;
|
||||
enableSelection: boolean;
|
||||
sidebarToRender: (props: any) => React.ReactNode;
|
||||
title: string;
|
||||
quickAdd?: React.JSX.Element | undefined;
|
||||
selectionHelpers: TSelectionHelper;
|
||||
};
|
||||
|
||||
export const GanttChartSidebar: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
blocks,
|
||||
blockUpdateHandler,
|
||||
enableReorder,
|
||||
enableSelection,
|
||||
sidebarToRender,
|
||||
title,
|
||||
quickAdd,
|
||||
selectionHelpers,
|
||||
} = props;
|
||||
|
||||
const isGroupSelectionEmpty = selectionHelpers.isGroupSelected(GANTT_SELECT_GROUP) === "empty";
|
||||
export const GanttChartSidebar: React.FC<Props> = (props) => {
|
||||
const { blocks, blockUpdateHandler, enableReorder, sidebarToRender, title, quickAdd } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -44,39 +25,19 @@ export const GanttChartSidebar: React.FC<Props> = observer((props) => {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="group/list-header box-border flex-shrink-0 flex items-end justify-between gap-2 border-b-[0.5px] border-custom-border-200 pb-2 pl-2 pr-4 text-sm font-medium text-custom-text-300 sticky top-0 z-10 bg-custom-background-100"
|
||||
className="box-border flex-shrink-0 flex items-end justify-between gap-2 border-b-[0.5px] border-custom-border-200 pb-2 pl-8 pr-4 text-sm font-medium text-custom-text-300 sticky top-0 z-10 bg-custom-background-100"
|
||||
style={{
|
||||
height: `${HEADER_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("flex items-center gap-2", {
|
||||
"pl-2": !enableSelection,
|
||||
})}
|
||||
>
|
||||
{enableSelection && (
|
||||
<div className="flex-shrink-0 flex items-center w-3.5">
|
||||
<MultipleSelectGroupAction
|
||||
className={cn(
|
||||
"size-3.5 opacity-0 pointer-events-none group-hover/list-header:opacity-100 group-hover/list-header:pointer-events-auto !outline-none",
|
||||
{
|
||||
"opacity-100 pointer-events-auto": !isGroupSelectionEmpty,
|
||||
}
|
||||
)}
|
||||
groupID={GANTT_SELECT_GROUP}
|
||||
selectionHelpers={selectionHelpers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h6>{title}</h6>
|
||||
</div>
|
||||
<h6>{title}</h6>
|
||||
<h6>Duration</h6>
|
||||
</div>
|
||||
|
||||
<div className="min-h-full h-max bg-custom-background-100 overflow-hidden">
|
||||
{sidebarToRender?.({ title, blockUpdateHandler, blocks, enableReorder, enableSelection, selectionHelpers })}
|
||||
{sidebarToRender && sidebarToRender({ title, blockUpdateHandler, blocks, enableReorder })}
|
||||
</div>
|
||||
{quickAdd ? quickAdd : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -241,8 +241,6 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
|
||||
@@ -117,8 +117,6 @@ export const GlobalIssuesHeader: React.FC = observer(() => {
|
||||
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_LAYOUT.my_issues.spreadsheet}
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
labels={workspaceLabels ?? undefined}
|
||||
memberIds={workspaceMemberIds ?? undefined}
|
||||
/>
|
||||
|
||||
@@ -239,8 +239,6 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
|
||||
@@ -138,8 +138,6 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
|
||||
@@ -182,8 +182,6 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
|
||||
@@ -216,8 +216,6 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
||||
const [declineIssueModal, setDeclineIssueModal] = useState(false);
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
// store
|
||||
const { currentTab, deleteInboxIssue, filteredInboxIssueIds } = useProjectInbox();
|
||||
const { currentTab, deleteInboxIssue, inboxIssueIds } = useProjectInbox();
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
@@ -76,11 +76,11 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
||||
|
||||
const redirectIssue = (): string | undefined => {
|
||||
let nextOrPreviousIssueId: string | undefined = undefined;
|
||||
const currentIssueIndex = filteredInboxIssueIds.findIndex((id) => id === currentInboxIssueId);
|
||||
if (filteredInboxIssueIds[currentIssueIndex + 1])
|
||||
nextOrPreviousIssueId = filteredInboxIssueIds[currentIssueIndex + 1];
|
||||
else if (filteredInboxIssueIds[currentIssueIndex - 1])
|
||||
nextOrPreviousIssueId = filteredInboxIssueIds[currentIssueIndex - 1];
|
||||
const currentIssueIndex = inboxIssueIds.findIndex((id) => id === currentInboxIssueId);
|
||||
if (inboxIssueIds[currentIssueIndex + 1])
|
||||
nextOrPreviousIssueId = inboxIssueIds[currentIssueIndex + 1];
|
||||
else if (inboxIssueIds[currentIssueIndex - 1])
|
||||
nextOrPreviousIssueId = inboxIssueIds[currentIssueIndex - 1];
|
||||
else nextOrPreviousIssueId = undefined;
|
||||
return nextOrPreviousIssueId;
|
||||
};
|
||||
@@ -134,22 +134,22 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
||||
})
|
||||
);
|
||||
|
||||
const currentIssueIndex = filteredInboxIssueIds.findIndex((issueId) => issueId === currentInboxIssueId) ?? 0;
|
||||
const currentIssueIndex = inboxIssueIds.findIndex((issueId) => issueId === currentInboxIssueId) ?? 0;
|
||||
|
||||
const handleInboxIssueNavigation = useCallback(
|
||||
(direction: "next" | "prev") => {
|
||||
if (!filteredInboxIssueIds || !currentInboxIssueId) return;
|
||||
if (!inboxIssueIds || !currentInboxIssueId) return;
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement && (activeElement.classList.contains("tiptap") || activeElement.id === "title-input")) return;
|
||||
const nextIssueIndex =
|
||||
direction === "next"
|
||||
? (currentIssueIndex + 1) % filteredInboxIssueIds.length
|
||||
: (currentIssueIndex - 1 + filteredInboxIssueIds.length) % filteredInboxIssueIds.length;
|
||||
const nextIssueId = filteredInboxIssueIds[nextIssueIndex];
|
||||
? (currentIssueIndex + 1) % inboxIssueIds.length
|
||||
: (currentIssueIndex - 1 + inboxIssueIds.length) % inboxIssueIds.length;
|
||||
const nextIssueId = inboxIssueIds[nextIssueIndex];
|
||||
if (!nextIssueId) return;
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/inbox?inboxIssueId=${nextIssueId}`);
|
||||
},
|
||||
[currentInboxIssueId, currentIssueIndex, filteredInboxIssueIds, projectId, router, workspaceSlug]
|
||||
[currentInboxIssueId, currentIssueIndex, inboxIssueIds, projectId, router, workspaceSlug]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { Inbox, PanelLeft } from "lucide-react";
|
||||
// components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
@@ -9,7 +10,6 @@ import { InboxLayoutLoader } from "@/components/ui";
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { EInboxIssueCurrentTab } from "@/helpers/inbox.helper";
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store";
|
||||
|
||||
@@ -18,25 +18,25 @@ type TInboxIssueRoot = {
|
||||
projectId: string;
|
||||
inboxIssueId: string | undefined;
|
||||
inboxAccessible: boolean;
|
||||
navigationTab?: EInboxIssueCurrentTab | undefined;
|
||||
};
|
||||
|
||||
export const InboxIssueRoot: FC<TInboxIssueRoot> = observer((props) => {
|
||||
const { workspaceSlug, projectId, inboxIssueId, inboxAccessible, navigationTab } = props;
|
||||
const { workspaceSlug, projectId, inboxIssueId, inboxAccessible } = props;
|
||||
// states
|
||||
const [isMobileSidebar, setIsMobileSidebar] = useState(true);
|
||||
// hooks
|
||||
const { loader, error, currentTab, handleCurrentTab, fetchInboxIssues } = useProjectInbox();
|
||||
const { loader, error, fetchInboxIssues } = useProjectInbox();
|
||||
|
||||
useEffect(() => {
|
||||
if (!inboxAccessible || !workspaceSlug || !projectId) return;
|
||||
if (navigationTab && navigationTab !== currentTab) {
|
||||
handleCurrentTab(navigationTab);
|
||||
} else {
|
||||
fetchInboxIssues(workspaceSlug.toString(), projectId.toString());
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inboxAccessible, workspaceSlug, projectId]);
|
||||
useSWR(
|
||||
inboxAccessible && workspaceSlug && projectId ? `PROJECT_INBOX_ISSUES_${workspaceSlug}_${projectId}` : null,
|
||||
async () => {
|
||||
inboxAccessible &&
|
||||
workspaceSlug &&
|
||||
projectId &&
|
||||
(await fetchInboxIssues(workspaceSlug.toString(), projectId.toString()));
|
||||
},
|
||||
{ revalidateOnFocus: false, revalidateIfStale: false }
|
||||
);
|
||||
|
||||
// loader
|
||||
if (loader === "init-loading")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, useCallback, useRef, useState } from "react";
|
||||
import { FC, useCallback, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { TInboxIssueCurrentTab } from "@plane/types";
|
||||
@@ -37,14 +37,14 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
|
||||
const { workspaceSlug, projectId, setIsMobileSidebar } = props;
|
||||
// ref
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [elementRef, setElementRef] = useState<HTMLDivElement | null>(null);
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
// store
|
||||
const { currentProjectDetails } = useProject();
|
||||
const {
|
||||
currentTab,
|
||||
handleCurrentTab,
|
||||
loader,
|
||||
filteredInboxIssueIds,
|
||||
inboxIssueIds,
|
||||
inboxIssuePaginationInfo,
|
||||
fetchInboxPaginationIssues,
|
||||
getAppliedFiltersCount,
|
||||
@@ -72,10 +72,8 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
|
||||
currentTab === option?.key ? `text-custom-primary-100` : `hover:text-custom-text-200`
|
||||
)}
|
||||
onClick={() => {
|
||||
if (currentTab != option?.key) {
|
||||
handleCurrentTab(option?.key);
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/inbox?currentTab=${option?.key}`);
|
||||
}
|
||||
if (currentTab != option?.key) handleCurrentTab(option?.key);
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/inbox?currentTab=${option?.key}`);
|
||||
}}
|
||||
>
|
||||
<div>{option?.label}</div>
|
||||
@@ -106,13 +104,13 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
|
||||
className="w-full h-full overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-md"
|
||||
ref={containerRef}
|
||||
>
|
||||
{filteredInboxIssueIds.length > 0 ? (
|
||||
{inboxIssueIds.length > 0 ? (
|
||||
<InboxIssueList
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
projectIdentifier={currentProjectDetails?.identifier}
|
||||
inboxIssueIds={filteredInboxIssueIds}
|
||||
inboxIssueIds={inboxIssueIds}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
@@ -128,14 +126,14 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div ref={setElementRef}>
|
||||
{inboxIssuePaginationInfo?.next_page_results && (
|
||||
{inboxIssuePaginationInfo?.next_page_results && (
|
||||
<div ref={elementRef}>
|
||||
<Loader className="mx-auto w-full space-y-4 py-4 px-2">
|
||||
<Loader.Item height="64px" width="w-100" />
|
||||
<Loader.Item height="64px" width="w-100" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -77,8 +77,6 @@ export const ArchivedIssuesHeader: FC = observer(() => {
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters || {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.archived_issues[activeLayout] : undefined
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./root";
|
||||
export * from "./upgrade-banner";
|
||||
@@ -1,21 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { BulkOperationsUpgradeBanner } from "@/components/issues";
|
||||
// hooks
|
||||
import { useMultipleSelectStore } from "@/hooks/store";
|
||||
import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
selectionHelpers: TSelectionHelper;
|
||||
};
|
||||
|
||||
export const IssueBulkOperationsRoot: React.FC<Props> = observer((props) => {
|
||||
const { className, selectionHelpers } = props;
|
||||
// store hooks
|
||||
const { isSelectionActive } = useMultipleSelectStore();
|
||||
|
||||
if (!isSelectionActive || selectionHelpers.isSelectionDisabled) return null;
|
||||
|
||||
return <BulkOperationsUpgradeBanner className={className} />;
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
// ui
|
||||
import { getButtonStyling } from "@plane/ui";
|
||||
// constants
|
||||
import { MARKETING_PLANE_ONE_PAGE_LINK } from "@/constants/common";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const BulkOperationsUpgradeBanner: React.FC<Props> = (props) => {
|
||||
const { className } = props;
|
||||
|
||||
return (
|
||||
<div className={cn("sticky bottom-0 left-0 h-20 z-[2] px-3.5 grid place-items-center", className)}>
|
||||
<div className="h-14 w-full bg-custom-primary-100/10 border-[0.5px] border-custom-primary-100/50 py-4 px-3.5 flex items-center justify-between gap-2 rounded-md">
|
||||
<p className="text-custom-primary-100 font-medium">
|
||||
Change state, priority, and more for several issues at once. Save three minutes on an average per operation.
|
||||
</p>
|
||||
<a
|
||||
href={MARKETING_PLANE_ONE_PAGE_LINK}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(getButtonStyling("primary", "sm"), "flex-shrink-0")}
|
||||
>
|
||||
Upgrade to One
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from "./attachment";
|
||||
export * from "./bulk-operations";
|
||||
export * from "./issue-modal";
|
||||
export * from "./delete-issue-modal";
|
||||
export * from "./issue-layouts";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user