Compare commits

..
Author SHA1 Message Date
Satish Gandham 733b725b5e Extract utility functions out of Storage class 2024-12-24 16:17:00 +05:30
Satish Gandham 3bee051ee9 Fix typos and add comments 2024-12-24 14:32:53 +05:30
Satish Gandham 6b902d458e Add comments to stroage class 2024-12-18 16:00:17 +05:30
480 changed files with 7179 additions and 13345 deletions
+8 -6
View File
@@ -4,11 +4,10 @@ import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
import Link from "next/link";
import { useForm } from "react-hook-form";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// types
import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
// ui
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import {
CodeBlock,
@@ -18,6 +17,8 @@ import {
TControllerInputFormField,
TCopyField,
} from "@/components/common";
// helpers
import { API_BASE_URL, cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
@@ -102,7 +103,8 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
url: originURL,
description: (
<>
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Authorized origin URL</CodeBlock> field{" "}
We will auto-generate this. Paste this into the{" "}
<CodeBlock darkerShade>Authorized origin URL</CodeBlock> field{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
@@ -121,8 +123,8 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
url: `${originURL}/auth/github/callback/`,
description: (
<>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
field{" "}
We will auto-generate this. Paste this into your{" "}
<CodeBlock darkerShade>Authorized Callback URI</CodeBlock> field{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
+2 -2
View File
@@ -5,12 +5,12 @@ import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane internal packages
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
import { resolveGeneralTheme } from "@plane/utils";
// components
import { AuthenticationMethodCard } from "@/components/authentication";
import { PageHeader } from "@/components/common";
// helpers
import { resolveGeneralTheme } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
// icons
+6 -4
View File
@@ -2,11 +2,10 @@ import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
import Link from "next/link";
import { useForm } from "react-hook-form";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// types
import { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
// ui
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import {
CodeBlock,
@@ -16,6 +15,8 @@ import {
TControllerInputFormField,
TCopyField,
} from "@/components/common";
// helpers
import { API_BASE_URL, cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
@@ -116,7 +117,8 @@ export const InstanceGitlabConfigForm: FC<Props> = (props) => {
url: `${originURL}/auth/gitlab/callback/`,
description: (
<>
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Redirect URI</CodeBlock> field of your{" "}
We will auto-generate this. Paste this into the{" "}
<CodeBlock darkerShade>Redirect URI</CodeBlock> field of your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
+4 -3
View File
@@ -3,11 +3,10 @@ import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
import Link from "next/link";
import { useForm } from "react-hook-form";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// types
import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
// ui
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import {
CodeBlock,
@@ -17,6 +16,8 @@ import {
TControllerInputFormField,
TCopyField,
} from "@/components/common";
// helpers
import { API_BASE_URL, cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
+2 -2
View File
@@ -3,10 +3,10 @@
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane internal packages
import { TInstanceConfigurationKeys } from "@plane/types";
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
// plane admin components
+3 -4
View File
@@ -4,11 +4,11 @@ import { ReactNode } from "react";
import { ThemeProvider, useTheme } from "next-themes";
import { SWRConfig } from "swr";
// ui
import { ADMIN_BASE_PATH, DEFAULT_SWR_CONFIG } from "@plane/constants";
import { Toast } from "@plane/ui";
import { resolveGeneralTheme } from "@plane/utils";
// constants
import { SWR_CONFIG } from "@/constants/swr-config";
// helpers
import { ASSET_PREFIX, resolveGeneralTheme } from "@/helpers/common.helper";
// lib
import { InstanceProvider } from "@/lib/instance-provider";
import { StoreProvider } from "@/lib/store-provider";
@@ -22,7 +22,6 @@ const ToastWithTheme = () => {
};
export default function RootLayout({ children }: { children: ReactNode }) {
const ASSET_PREFIX = ADMIN_BASE_PATH;
return (
<html lang="en">
<head>
@@ -35,7 +34,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<body className={`antialiased`}>
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
<ToastWithTheme />
<SWRConfig value={DEFAULT_SWR_CONFIG}>
<SWRConfig value={SWR_CONFIG}>
<StoreProvider>
<InstanceProvider>
<UserProvider>{children}</UserProvider>
+3 -1
View File
@@ -3,11 +3,13 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
// constants
import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
// types
import { IWorkspace } from "@plane/types";
// components
import { Button, CustomSelect, getButtonStyling, Input, setToast, TOAST_TYPE } from "@plane/ui";
// helpers
import { WEB_BASE_URL } from "@/helpers/common.helper";
// hooks
import { useWorkspace } from "@/hooks/store";
// services
+3 -1
View File
@@ -7,10 +7,12 @@ import useSWR from "swr";
import { Loader as LoaderIcon } from "lucide-react";
// types
import { TInstanceConfigurationKeys } from "@plane/types";
// ui
import { Button, getButtonStyling, Loader, setPromiseToast, ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { WorkspaceListItem } from "@/components/workspace";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance, useWorkspace } from "@/hooks/store";
@@ -10,7 +10,7 @@ import {
// components
import { AuthenticationMethodCard } from "@/components/authentication";
// helpers
import { getBaseAuthenticationModes } from "@/lib/auth-helpers";
import { getBaseAuthenticationModes } from "@/helpers/authentication.helper";
// plane admin components
import { UpgradeButton } from "@/plane-admin/components/common";
// images
@@ -3,9 +3,10 @@
import React from "react";
// icons
import { SquareArrowOutUpRight } from "lucide-react";
// plane internal packages
// ui
import { getButtonStyling } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { cn } from "@/helpers/common.helper";
export const UpgradeButton: React.FC = () => (
<a href="https://plane.so/pricing?mode=self-hosted" target="_blank" className={cn(getButtonStyling("primary", "sm"))}>
@@ -5,14 +5,13 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
import { Transition } from "@headlessui/react";
// plane internal packages
import { WEB_BASE_URL } from "@plane/constants";
// ui
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { WEB_BASE_URL, cn } from "@/helpers/common.helper";
// hooks
import { useTheme } from "@/hooks/store";
// assets
// eslint-disable-next-line import/order
import packageJson from "package.json";
const helpOptions = [
@@ -5,10 +5,11 @@ import { observer } from "mobx-react";
import { useTheme as useNextTheme } from "next-themes";
import { LogOut, UserCog2, Palette } from "lucide-react";
import { Menu, Transition } from "@headlessui/react";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// plane ui
import { Avatar } from "@plane/ui";
import { getFileURL, cn } from "@plane/utils";
// helpers
import { API_BASE_URL, cn } from "@/helpers/common.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useTheme, useUser } from "@/hooks/store";
// services
@@ -4,11 +4,11 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
// plane internal packages
import { Tooltip, WorkspaceIcon } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { cn } from "@/helpers/common.helper";
import { useTheme } from "@/hooks/store";
// helpers
const INSTANCE_ADMIN_LINKS = [
{
@@ -1,7 +1,7 @@
import { FC } from "react";
import { Info, X } from "lucide-react";
// plane constants
import { TAuthErrorInfo } from "@plane/constants";
// helpers
import { TAuthErrorInfo } from "@/helpers/authentication.helper";
type TAuthBanner = {
bannerData: TAuthErrorInfo | undefined;
@@ -2,7 +2,7 @@
import { FC } from "react";
// helpers
import { cn } from "@plane/utils";
import { cn } from "helpers/common.helper";
type Props = {
name: string;
@@ -5,10 +5,12 @@ import { observer } from "mobx-react";
import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// plane internal packages
// types
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
// ui
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
@@ -5,10 +5,12 @@ import { observer } from "mobx-react";
import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// plane internal packages
// types
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
// ui
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
@@ -5,10 +5,12 @@ import { observer } from "mobx-react";
import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// plane internal packages
// types
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
// ui
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
+1 -1
View File
@@ -1,4 +1,4 @@
import { cn } from "@plane/utils";
import { cn } from "@/helpers/common.helper";
type TProps = {
children: React.ReactNode;
@@ -4,9 +4,10 @@ import React, { useState } from "react";
import { Controller, Control } from "react-hook-form";
// icons
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
// ui
import { Input } from "@plane/ui";
import { cn } from "@plane/utils";
// helpers
import { cn } from "@/helpers/common.helper";
type Props = {
control: Control<any>;
@@ -36,7 +37,9 @@ export const ControllerInput: React.FC<Props> = (props) => {
return (
<div className="flex flex-col gap-1">
<h4 className="text-sm text-custom-text-300">{label}</h4>
<h4 className="text-sm text-custom-text-300">
{label}
</h4>
<div className="relative">
<Controller
control={control}
@@ -1,9 +1,14 @@
"use client";
import { FC, useMemo } from "react";
// plane internal packages
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { cn, getPasswordStrength } from "@plane/utils";
// import { CircleCheck } from "lucide-react";
// helpers
import { cn } from "@/helpers/common.helper";
import {
E_PASSWORD_STRENGTH,
// PASSWORD_CRITERIA,
getPasswordStrength,
} from "@/helpers/password.helper";
type TPasswordStrengthMeter = {
password: string;
@@ -4,12 +4,13 @@ import { FC, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
// icons
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
// ui
import { Button, Checkbox, Input, Spinner } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
import { Banner, PasswordStrengthMeter } from "@/components/common";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper";
// services
import { AuthService } from "@/services/auth.service";
+12 -6
View File
@@ -2,18 +2,24 @@
import { FC, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
// services
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { Button, Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common";
// helpers
import { authErrorHandler } from "@/lib/auth-helpers";
// services
import {
authErrorHandler,
EAuthenticationErrorCodes,
EErrorAlertType,
TAuthErrorInfo,
} from "@/helpers/authentication.helper";
import { API_BASE_URL } from "@/helpers/common.helper";
import { AuthService } from "@/services/auth.service";
// local components
import { AuthBanner } from "../authentication";
// ui
// icons
// service initialization
const authService = new AuthService();
@@ -96,7 +102,7 @@ export const InstanceSignInForm: FC = (props) => {
useEffect(() => {
if (errorCode) {
const errorDetail = authErrorHandler(errorCode?.toString() as EAdminAuthErrorCodes);
const errorDetail = authErrorHandler(errorCode?.toString() as EAuthenticationErrorCodes);
if (errorDetail) {
setErrorInfo(errorDetail);
}
+1 -1
View File
@@ -1,13 +1,13 @@
"use client";
import React from "react";
import { resolveGeneralTheme } from "helpers/common.helper";
import { observer } from "mobx-react";
import Image from "next/image";
import Link from "next/link";
import { useTheme as nextUseTheme } from "next-themes";
// ui
import { Button, getButtonStyling } from "@plane/ui";
import { resolveGeneralTheme } from "@plane/utils";
// hooks
import { useTheme } from "@/hooks/store";
// icons
@@ -1,9 +1,9 @@
import { observer } from "mobx-react";
import { ExternalLink } from "lucide-react";
// plane internal packages
import { WEB_BASE_URL } from "@plane/constants";
// helpers
import { Tooltip } from "@plane/ui";
import { getFileURL } from "@plane/utils";
import { WEB_BASE_URL } from "@/helpers/common.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useWorkspace } from "@/hooks/store";
+8
View File
@@ -0,0 +1,8 @@
export const SITE_NAME = "Plane | Simple, extensible, open-source project management tool.";
export const SITE_TITLE = "Plane | Simple, extensible, open-source project management tool.";
export const SITE_DESCRIPTION =
"Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.";
export const SITE_KEYWORDS =
"software development, plan, ship, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration";
export const SITE_URL = "https://app.plane.so/";
export const TWITTER_USER_NAME = "Plane | Simple, extensible, open-source project management tool.";
@@ -1,4 +1,4 @@
export const DEFAULT_SWR_CONFIG = {
export const SWR_CONFIG = {
refreshWhenHidden: false,
revalidateIfStale: false,
revalidateOnFocus: false,
-164
View File
@@ -1,164 +0,0 @@
import { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { KeyRound, Mails } from "lucide-react";
// plane packages
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
import { resolveGeneralTheme } from "@plane/utils";
// components
import {
EmailCodesConfiguration,
GithubConfiguration,
GitlabConfiguration,
GoogleConfiguration,
PasswordLoginConfiguration,
} from "@/components/authentication";
// images
import githubLightModeImage from "@/public/logos/github-black.png";
import githubDarkModeImage from "@/public/logos/github-white.png";
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
import GoogleLogo from "@/public/logos/google-logo.svg";
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
INLINE_EMAIL = "INLINE_EMAIL",
INLINE_PASSWORD = "INLINE_PASSWORD",
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
}
const errorCodeMessages: {
[key in EAdminAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
} = {
// admin
[EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: {
title: `Admin already exists`,
message: () => `Admin already exists. Please try again.`,
},
[EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
title: `Email, password and first name required`,
message: () => `Email, password and first name required. Please try again.`,
},
[EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL]: {
title: `Invalid admin email`,
message: () => `Invalid admin email. Please try again.`,
},
[EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD]: {
title: `Invalid admin password`,
message: () => `Invalid admin password. Please try again.`,
},
[EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
title: `Email and password required`,
message: () => `Email and password required. Please try again.`,
},
[EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
title: `Authentication failed`,
message: () => `Authentication failed. Please try again.`,
},
[EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
title: `Admin user already exists`,
message: () => (
<div>
Admin user already exists.&nbsp;
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
Sign In
</Link>
&nbsp;now.
</div>
),
},
[EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
title: `Admin user does not exist`,
message: () => (
<div>
Admin user does not exist.&nbsp;
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
Sign In
</Link>
&nbsp;now.
</div>
),
},
[EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: {
title: `User account deactivated`,
message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
},
};
export const authErrorHandler = (
errorCode: EAdminAuthErrorCodes,
email?: string | undefined
): TAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL,
EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD,
EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED,
EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST,
EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST,
EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED,
];
if (bannerAlertErrorCodes.includes(errorCode))
return {
type: EErrorAlertType.BANNER_ALERT,
code: errorCode,
title: errorCodeMessages[errorCode]?.title || "Error",
message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
};
return undefined;
};
export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
disabled,
updateConfig,
resolvedTheme,
}) => [
{
key: "unique-codes",
name: "Unique codes",
description:
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
icon: <Mails className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "passwords-login",
name: "Passwords",
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
icon: <KeyRound className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "google",
name: "Google",
description: "Allow members to log in or sign up for Plane with their Google accounts.",
icon: <Image src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "github",
name: "GitHub",
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
icon: (
<Image
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
height={20}
width={20}
alt="GitHub Logo"
/>
),
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "gitlab",
name: "GitLab",
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
];
+2 -1
View File
@@ -1,4 +1,5 @@
import { API_BASE_URL } from "@plane/constants";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
+2 -2
View File
@@ -1,5 +1,4 @@
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// types
import type {
IFormattedInstanceConfiguration,
IInstance,
@@ -8,6 +7,7 @@ import type {
IInstanceInfo,
} from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service";
export class InstanceService extends APIService {
+3 -2
View File
@@ -1,6 +1,7 @@
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// types
import type { IUser } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
+3 -2
View File
@@ -1,6 +1,7 @@
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
// types
import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
+2 -2
View File
@@ -1,7 +1,5 @@
import set from "lodash/set";
import { observable, action, computed, makeObservable, runInAction } from "mobx";
// plane internal packages
import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
import {
IInstance,
IInstanceAdmin,
@@ -10,6 +8,8 @@ import {
IInstanceInfo,
IInstanceConfig,
} from "@plane/types";
// helpers
import { EInstanceStatus, TInstanceStatus } from "@/helpers/instance.helper";
// services
import { InstanceService } from "@/services/instance.service";
// root store
+2 -2
View File
@@ -1,7 +1,7 @@
import { action, observable, runInAction, makeObservable } from "mobx";
// plane internal packages
import { EUserStatus, TUserStatus } from "@plane/constants";
import { IUser } from "@plane/types";
// helpers
import { EUserStatus, TUserStatus } from "@/helpers/user.helper";
// services
import { AuthService } from "@/services/auth.service";
import { UserService } from "@/services/user.service";
+203
View File
@@ -0,0 +1,203 @@
import { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { KeyRound, Mails } from "lucide-react";
// types
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
// components
import {
EmailCodesConfiguration,
GithubConfiguration,
GitlabConfiguration,
GoogleConfiguration,
PasswordLoginConfiguration,
} from "@/components/authentication";
// helpers
import { SUPPORT_EMAIL, resolveGeneralTheme } from "@/helpers/common.helper";
// images
import githubLightModeImage from "@/public/logos/github-black.png";
import githubDarkModeImage from "@/public/logos/github-white.png";
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
import GoogleLogo from "@/public/logos/google-logo.svg";
export enum EPageTypes {
PUBLIC = "PUBLIC",
NON_AUTHENTICATED = "NON_AUTHENTICATED",
SET_PASSWORD = "SET_PASSWORD",
ONBOARDING = "ONBOARDING",
AUTHENTICATED = "AUTHENTICATED",
}
export enum EAuthModes {
SIGN_IN = "SIGN_IN",
SIGN_UP = "SIGN_UP",
}
export enum EAuthSteps {
EMAIL = "EMAIL",
PASSWORD = "PASSWORD",
UNIQUE_CODE = "UNIQUE_CODE",
}
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
INLINE_EMAIL = "INLINE_EMAIL",
INLINE_PASSWORD = "INLINE_PASSWORD",
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
}
export enum EAuthenticationErrorCodes {
// Admin
ADMIN_ALREADY_EXIST = "5150",
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
INVALID_ADMIN_EMAIL = "5160",
INVALID_ADMIN_PASSWORD = "5165",
REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
ADMIN_AUTHENTICATION_FAILED = "5175",
ADMIN_USER_ALREADY_EXIST = "5180",
ADMIN_USER_DOES_NOT_EXIST = "5185",
ADMIN_USER_DEACTIVATED = "5190",
}
export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAuthenticationErrorCodes;
title: string;
message: ReactNode;
};
const errorCodeMessages: {
[key in EAuthenticationErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
} = {
// admin
[EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: {
title: `Admin already exists`,
message: () => `Admin already exists. Please try again.`,
},
[EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
title: `Email, password and first name required`,
message: () => `Email, password and first name required. Please try again.`,
},
[EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: {
title: `Invalid admin email`,
message: () => `Invalid admin email. Please try again.`,
},
[EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: {
title: `Invalid admin password`,
message: () => `Invalid admin password. Please try again.`,
},
[EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
title: `Email and password required`,
message: () => `Email and password required. Please try again.`,
},
[EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
title: `Authentication failed`,
message: () => `Authentication failed. Please try again.`,
},
[EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
title: `Admin user already exists`,
message: () => (
<div>
Admin user already exists.&nbsp;
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
Sign In
</Link>
&nbsp;now.
</div>
),
},
[EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
title: `Admin user does not exist`,
message: () => (
<div>
Admin user does not exist.&nbsp;
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
Sign In
</Link>
&nbsp;now.
</div>
),
},
[EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED]: {
title: `User account deactivated`,
message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
},
};
export const authErrorHandler = (
errorCode: EAuthenticationErrorCodes,
email?: string | undefined
): TAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST,
EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL,
EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD,
EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD,
EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED,
EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST,
EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST,
EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED,
];
if (bannerAlertErrorCodes.includes(errorCode))
return {
type: EErrorAlertType.BANNER_ALERT,
code: errorCode,
title: errorCodeMessages[errorCode]?.title || "Error",
message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
};
return undefined;
};
export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
disabled,
updateConfig,
resolvedTheme,
}) => [
{
key: "unique-codes",
name: "Unique codes",
description:
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
icon: <Mails className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "passwords-login",
name: "Passwords",
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
icon: <KeyRound className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "google",
name: "Google",
description: "Allow members to log in or sign up for Plane with their Google accounts.",
icon: <Image src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "github",
name: "GitHub",
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
icon: (
<Image
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
height={20}
width={20}
alt="GitHub Logo"
/>
),
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "gitlab",
name: "GitLab",
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
];
+20
View File
@@ -0,0 +1,20 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || "";
export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "";
export const ASSET_PREFIX = ADMIN_BASE_PATH;
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
export const resolveGeneralTheme = (resolvedTheme: string | undefined) =>
resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system";
+14
View File
@@ -0,0 +1,14 @@
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
/**
* @description combine the file path with the base URL
* @param {string} path
* @returns {string} final URL with the base URL
*/
export const getFileURL = (path: string): string | undefined => {
if (!path) return undefined;
const isValidURL = path.startsWith("http");
if (isValidURL) return path;
return `${API_BASE_URL}${path}`;
};
+2
View File
@@ -0,0 +1,2 @@
export * from "./instance.helper";
export * from "./user.helper";
+67
View File
@@ -0,0 +1,67 @@
import zxcvbn from "zxcvbn";
export enum E_PASSWORD_STRENGTH {
EMPTY = "empty",
LENGTH_NOT_VALID = "length_not_valid",
STRENGTH_NOT_VALID = "strength_not_valid",
STRENGTH_VALID = "strength_valid",
}
const PASSWORD_MIN_LENGTH = 8;
// const PASSWORD_NUMBER_REGEX = /\d/;
// const PASSWORD_CHAR_CAPS_REGEX = /[A-Z]/;
// const PASSWORD_SPECIAL_CHAR_REGEX = /[`!@#$%^&*()_\-+=\[\]{};':"\\|,.<>\/?~ ]/;
export const PASSWORD_CRITERIA = [
{
key: "min_8_char",
label: "Min 8 characters",
isCriteriaValid: (password: string) => password.length >= PASSWORD_MIN_LENGTH,
},
// {
// key: "min_1_upper_case",
// label: "Min 1 upper-case letter",
// isCriteriaValid: (password: string) => PASSWORD_NUMBER_REGEX.test(password),
// },
// {
// key: "min_1_number",
// label: "Min 1 number",
// isCriteriaValid: (password: string) => PASSWORD_CHAR_CAPS_REGEX.test(password),
// },
// {
// key: "min_1_special_char",
// label: "Min 1 special character",
// isCriteriaValid: (password: string) => PASSWORD_SPECIAL_CHAR_REGEX.test(password),
// },
];
export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => {
let passwordStrength: E_PASSWORD_STRENGTH = E_PASSWORD_STRENGTH.EMPTY;
if (!password || password === "" || password.length <= 0) {
return passwordStrength;
}
if (password.length >= PASSWORD_MIN_LENGTH) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
} else {
passwordStrength = E_PASSWORD_STRENGTH.LENGTH_NOT_VALID;
return passwordStrength;
}
const passwordCriteriaValidation = PASSWORD_CRITERIA.map((criteria) => criteria.isCriteriaValid(password)).every(
(criterion) => criterion
);
const passwordStrengthScore = zxcvbn(password).score;
if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
return passwordStrength;
}
if (passwordCriteriaValidation === true && passwordStrengthScore >= 3) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_VALID;
}
return passwordStrength;
};
+21
View File
@@ -0,0 +1,21 @@
/**
* @description
* This function test whether a URL is valid or not.
*
* It accepts URLs with or without the protocol.
* @param {string} url
* @returns {boolean}
* @example
* checkURLValidity("https://example.com") => true
* checkURLValidity("example.com") => true
* checkURLValidity("example") => false
*/
export const checkURLValidity = (url: string): boolean => {
if (!url) return false;
// regex to support complex query parameters and fragments
const urlPattern =
/^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i;
return urlPattern.test(url);
};
+1
View File
@@ -5,6 +5,7 @@
"baseUrl": ".",
"paths": {
"@/*": ["core/*"],
"@/helpers/*": ["helpers/*"],
"@/public/*": ["public/*"],
"@/plane-admin/*": ["ce/*"]
}
+4 -24
View File
@@ -237,37 +237,17 @@ class IssueSerializer(BaseSerializer):
from .user import UserLiteSerializer
data["assignees"] = UserLiteSerializer(
User.objects.filter(
pk__in=IssueAssignee.objects.filter(issue=instance).values_list(
"assignee_id", flat=True
)
),
many=True,
instance.assignees.all(), many=True
).data
else:
data["assignees"] = [
str(assignee)
for assignee in IssueAssignee.objects.filter(
issue=instance
).values_list("assignee_id", flat=True)
str(assignee.id) for assignee in instance.assignees.all()
]
if "labels" in self.fields:
if "labels" in self.expand:
data["labels"] = LabelSerializer(
Label.objects.filter(
pk__in=IssueLabel.objects.filter(issue=instance).values_list(
"label_id", flat=True
)
),
many=True,
).data
data["labels"] = LabelSerializer(instance.labels.all(), many=True).data
else:
data["labels"] = [
str(label)
for label in IssueLabel.objects.filter(issue=instance).values_list(
"label_id", flat=True
)
]
data["labels"] = [str(label.id) for label in instance.labels.all()]
return data
+11
View File
@@ -109,6 +109,16 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
)
# Create or get state
state, _ = State.objects.get_or_create(
name="Triage",
group="triage",
description="Default state for managing all Intake Issues",
project_id=project_id,
color="#ff7700",
is_triage=True,
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
@@ -118,6 +128,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
),
priority=request.data.get("issue", {}).get("priority", "none"),
project_id=project_id,
state=state,
)
# create an intake issue
+1 -3
View File
@@ -258,9 +258,7 @@ class ProjectAPIEndpoint(BaseAPIView):
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
intake_view = request.data.get(
"inbox_view", request.data.get("intake_view", project.intake_view)
)
intake_view = request.data.get("inbox_view", project.intake_view)
if project.archived_at:
return Response(
-4
View File
@@ -54,8 +54,6 @@ class PageSerializer(BaseSerializer):
labels = validated_data.pop("labels", None)
project_id = self.context["project_id"]
owned_by_id = self.context["owned_by_id"]
description = self.context["description"]
description_binary = self.context["description_binary"]
description_html = self.context["description_html"]
# Get the workspace id from the project
@@ -64,8 +62,6 @@ class PageSerializer(BaseSerializer):
# Create the page
page = Page.objects.create(
**validated_data,
description=description,
description_binary=description_binary,
description_html=description_html,
owned_by_id=owned_by_id,
workspace_id=project.workspace_id,
+1 -1
View File
@@ -116,7 +116,7 @@ class WebhookSerializer(DynamicBaseSerializer):
class Meta:
model = Webhook
fields = "__all__"
read_only_fields = ["workspace", "secret_key", "deleted_at"]
read_only_fields = ["workspace", "secret_key"]
class WebhookLogSerializer(DynamicBaseSerializer):
-2
View File
@@ -17,7 +17,6 @@ from .user import urlpatterns as user_urls
from .views import urlpatterns as view_urls
from .webhook import urlpatterns as webhook_urls
from .workspace import urlpatterns as workspace_urls
from .timezone import urlpatterns as timezone_urls
urlpatterns = [
*analytic_urls,
@@ -39,5 +38,4 @@ urlpatterns = [
*workspace_urls,
*api_urls,
*webhook_urls,
*timezone_urls,
]
-6
View File
@@ -8,7 +8,6 @@ from plane.app.views import (
SubPagesEndpoint,
PagesDescriptionViewSet,
PageVersionEndpoint,
PageDuplicateEndpoint,
)
@@ -79,9 +78,4 @@ urlpatterns = [
PageVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/duplicate/",
PageDuplicateEndpoint.as_view(),
name="page-duplicate",
),
]
+1 -6
View File
@@ -1,7 +1,7 @@
from django.urls import path
from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint, SearchEndpoint
from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint
urlpatterns = [
@@ -15,9 +15,4 @@ urlpatterns = [
IssueSearchEndpoint.as_view(),
name="project-issue-search",
),
path(
"workspaces/<str:slug>/entity-search/",
SearchEndpoint.as_view(),
name="entity-search",
),
]
-8
View File
@@ -1,8 +0,0 @@
from django.urls import path
from plane.app.views import TimezoneEndpoint
urlpatterns = [
# timezone endpoint
path("timezones/", TimezoneEndpoint.as_view(), name="timezone-list")
]
+1 -4
View File
@@ -155,11 +155,10 @@ from .page.base import (
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageDuplicateEndpoint,
)
from .page.version import PageVersionEndpoint
from .search.base import GlobalSearchEndpoint, SearchEndpoint
from .search.base import GlobalSearchEndpoint
from .search.issue import IssueSearchEndpoint
@@ -205,5 +204,3 @@ from .error_404 import custom_404_view
from .notification.base import MarkAllReadNotificationViewSet
from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint
from .timezone.base import TimezoneEndpoint
+68 -152
View File
@@ -1,169 +1,71 @@
# Python import
import os
from typing import List, Dict, Tuple
# Third party import
import litellm
# Python imports
import requests
import os
from rest_framework import status
# Third party imports
from openai import OpenAI
from rest_framework.response import Response
from rest_framework import status
# Module import
from plane.app.permissions import ROLE, allow_permission
from plane.app.serializers import (ProjectLiteSerializer,
WorkspaceLiteSerializer)
from plane.db.models import Project, Workspace
from plane.license.utils.instance_value import get_configuration_value
from plane.utils.exception_logger import log_exception
# Django imports
# Module imports
from ..base import BaseAPIView
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import Workspace, Project
from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
from plane.license.utils.instance_value import get_configuration_value
class LLMProvider:
"""Base class for LLM provider configurations"""
name: str = ""
models: List[str] = []
default_model: str = ""
@classmethod
def get_config(cls) -> Dict[str, str | List[str]]:
return {
"name": cls.name,
"models": cls.models,
"default_model": cls.default_model,
}
class OpenAIProvider(LLMProvider):
name = "OpenAI"
models = ["gpt-3.5-turbo", "gpt-4o-mini", "gpt-4o", "o1-mini", "o1-preview"]
default_model = "gpt-4o-mini"
class AnthropicProvider(LLMProvider):
name = "Anthropic"
models = [
"claude-3-5-sonnet-20240620",
"claude-3-haiku-20240307",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-2.1",
"claude-2",
"claude-instant-1.2",
"claude-instant-1"
]
default_model = "claude-3-sonnet-20240229"
class GeminiProvider(LLMProvider):
name = "Gemini"
models = ["gemini-pro", "gemini-1.5-pro-latest", "gemini-pro-vision"]
default_model = "gemini-pro"
SUPPORTED_PROVIDERS = {
"openai": OpenAIProvider,
"anthropic": AnthropicProvider,
"gemini": GeminiProvider,
}
def get_llm_config() -> Tuple[str | None, str | None, str | None]:
"""
Helper to get LLM configuration values, returns:
- api_key, model, provider
"""
api_key, provider_key, model = get_configuration_value([
{
"key": "LLM_API_KEY",
"default": os.environ.get("LLM_API_KEY", None),
},
{
"key": "LLM_PROVIDER",
"default": os.environ.get("LLM_PROVIDER", "openai"),
},
{
"key": "LLM_MODEL",
"default": os.environ.get("LLM_MODEL", None),
},
])
provider = SUPPORTED_PROVIDERS.get(provider_key.lower())
if not provider:
log_exception(ValueError(f"Unsupported provider: {provider_key}"))
return None, None, None
if not api_key:
log_exception(ValueError(f"Missing API key for provider: {provider.name}"))
return None, None, None
# If no model specified, use provider's default
if not model:
model = provider.default_model
# Validate model is supported by provider
if model not in provider.models:
log_exception(ValueError(
f"Model {model} not supported by {provider.name}. "
f"Supported models: {', '.join(provider.models)}"
))
return None, None, None
return api_key, model, provider_key
def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> Tuple[str | None, str | None]:
"""Helper to get LLM completion response"""
final_text = task + "\n" + prompt
try:
# For Gemini, prepend provider name to model
if provider.lower() == "gemini":
model = f"gemini/{model}"
response = litellm.completion(
model=model,
messages=[{"role": "user", "content": final_text}],
api_key=api_key,
)
text = response.choices[0].message.content.strip()
return text, None
except Exception as e:
log_exception(e)
error_type = e.__class__.__name__
if error_type == "AuthenticationError":
return None, f"Invalid API key for {provider}"
elif error_type == "RateLimitError":
return None, f"Rate limit exceeded for {provider}"
else:
return None, f"Error occurred while generating response from {provider}"
class GPTIntegrationEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id):
api_key, model, provider = get_llm_config()
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
[
{
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", None),
},
{
"key": "GPT_ENGINE",
"default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
},
]
)
if not api_key or not model or not provider:
# Get the configuration value
# Check the keys
if not OPENAI_API_KEY or not GPT_ENGINE:
return Response(
{"error": "LLM provider API key and model are required"},
{"error": "OpenAI API key and engine is required"},
status=status.HTTP_400_BAD_REQUEST,
)
prompt = request.data.get("prompt", False)
task = request.data.get("task", False)
if not task:
return Response(
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
)
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
if not text and error:
return Response(
{"error": "An internal error has occurred."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
final_text = task + "\n" + prompt
client = OpenAI(api_key=OPENAI_API_KEY)
response = client.chat.completions.create(
model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}]
)
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=project_id)
text = response.choices[0].message.content.strip()
text_html = text.replace("\n", "<br/>")
return Response(
{
"response": text,
"response_html": text.replace("\n", "<br/>"),
"response_html": text_html,
"project_detail": ProjectLiteSerializer(project).data,
"workspace_detail": WorkspaceLiteSerializer(workspace).data,
},
@@ -174,33 +76,47 @@ class GPTIntegrationEndpoint(BaseAPIView):
class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug):
api_key, model, provider = get_llm_config()
if not api_key or not model or not provider:
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
[
{
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", None),
},
{
"key": "GPT_ENGINE",
"default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
},
]
)
# Get the configuration value
# Check the keys
if not OPENAI_API_KEY or not GPT_ENGINE:
return Response(
{"error": "LLM provider API key and model are required"},
{"error": "OpenAI API key and engine is required"},
status=status.HTTP_400_BAD_REQUEST,
)
prompt = request.data.get("prompt", False)
task = request.data.get("task", False)
if not task:
return Response(
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
)
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
if not text and error:
return Response(
{"error": "An internal error has occurred."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
final_text = task + "\n" + prompt
client = OpenAI(api_key=OPENAI_API_KEY)
response = client.chat.completions.create(
model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}]
)
text = response.choices[0].message.content.strip()
text_html = text.replace("\n", "<br/>")
return Response(
{
"response": text,
"response_html": text.replace("\n", "<br/>"),
},
status=status.HTTP_200_OK,
{"response": text, "response_html": text_html}, status=status.HTTP_200_OK
)
-36
View File
@@ -121,8 +121,6 @@ class PageViewSet(BaseViewSet):
context={
"project_id": project_id,
"owned_by_id": request.user.id,
"description": request.data.get("description", {}),
"description_binary": request.data.get("description_binary", None),
"description_html": request.data.get("description_html", "<p></p>"),
},
)
@@ -555,37 +553,3 @@ class PagesDescriptionViewSet(BaseViewSet):
return Response({"message": "Updated successfully"})
else:
return Response({"error": "No binary data provided"})
class PageDuplicateEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id, page_id):
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
).first()
# get all the project ids where page is present
project_ids = ProjectPage.objects.filter(page_id=page_id).values_list(
"project_id", flat=True
)
page.pk = None
page.name = f"{page.name} (Copy)"
page.description_binary = None
page.save()
for project_id in project_ids:
ProjectPage.objects.create(
workspace_id=page.workspace_id,
project_id=project_id,
page_id=page.id,
created_by_id=page.created_by_id,
updated_by_id=page.updated_by_id,
)
page_transaction.delay(
{"description_html": page.description_html}, None, page.id
)
page = Page.objects.get(pk=page.id)
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
+2 -471
View File
@@ -2,21 +2,10 @@
import re
# Django imports
from django.db import models
from django.db.models import (
Q,
OuterRef,
Subquery,
Value,
UUIDField,
CharField,
When,
Case,
)
from django.db.models import Q, OuterRef, Subquery, Value, UUIDField, CharField
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce, Concat
from django.utils import timezone
from django.db.models.functions import Coalesce
# Third party imports
from rest_framework import status
@@ -32,9 +21,7 @@ from plane.db.models import (
Module,
Page,
IssueView,
ProjectMember,
ProjectPage,
WorkspaceMember,
)
@@ -250,459 +237,3 @@ class GlobalSearchEndpoint(BaseAPIView):
func = MODELS_MAPPER.get(model, None)
results[model] = func(query, slug, project_id, workspace_search)
return Response({"results": results}, status=status.HTTP_200_OK)
class SearchEndpoint(BaseAPIView):
def get(self, request, slug):
query = request.query_params.get("query", False)
query_types = request.query_params.get("query_type", "user_mention").split(",")
query_types = [qt.strip() for qt in query_types]
count = int(request.query_params.get("count", 5))
project_id = request.query_params.get("project_id", None)
issue_id = request.query_params.get("issue_id", None)
response_data = {}
if project_id:
for query_type in query_types:
if query_type == "user_mention":
fields = [
"member__first_name",
"member__last_name",
"member__display_name",
]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
base_filters = Q(
q,
is_active=True,
workspace__slug=slug,
member__is_bot=False,
project_id=project_id,
role__gt=10,
)
if issue_id:
issue_created_by = (
Issue.objects.filter(id=issue_id)
.values_list("created_by_id", flat=True)
.first()
)
# Add condition to include `issue_created_by` in the query
filters = Q(member_id=issue_created_by) | base_filters
else:
filters = base_filters
# Query to fetch users
users = (
ProjectMember.objects.filter(filters)
.annotate(
member__avatar_url=Case(
When(
member__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"member__avatar_asset",
Value("/"),
),
),
When(
member__avatar_asset__isnull=True,
then="member__avatar",
),
default=Value(None),
output_field=CharField(),
)
)
.order_by("-created_at")
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)[:count]
)
response_data["user_mention"] = list(users)
elif query_type == "project":
fields = ["name", "identifier"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user)
| Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name", "id", "identifier", "logo_props", "workspace__slug"
)[:count]
)
response_data["project"] = list(projects)
elif query_type == "issue":
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
if query:
for field in fields:
if field == "sequence_id":
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
issues = (
Issue.issue_objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
project_id=project_id,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"priority",
"state_id",
"type_id",
)[:count]
)
response_data["issue"] = list(issues)
elif query_type == "cycle":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
cycles = (
Cycle.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING"),
),
When(
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
output_field=CharField(),
)
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"status",
"workspace__slug",
)[:count]
)
response_data["cycle"] = list(cycles)
elif query_type == "module":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
modules = (
Module.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
project_id=project_id,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"status",
"workspace__slug",
)[:count]
)
response_data["module"] = list(modules)
elif query_type == "page":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = (
Page.objects.filter(
q,
projects__project_projectmember__member=self.request.user,
projects__project_projectmember__is_active=True,
projects__id=project_id,
workspace__slug=slug,
access=0,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"logo_props",
"projects__id",
"workspace__slug",
)[:count]
)
response_data["page"] = list(pages)
return Response(response_data, status=status.HTTP_200_OK)
else:
for query_type in query_types:
if query_type == "user_mention":
fields = [
"member__first_name",
"member__last_name",
"member__display_name",
]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
users = (
WorkspaceMember.objects.filter(
q,
is_active=True,
workspace__slug=slug,
member__is_bot=False,
)
.annotate(
member__avatar_url=Case(
When(
member__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"member__avatar_asset",
Value("/"),
),
),
When(
member__avatar_asset__isnull=True,
then="member__avatar",
),
default=Value(None),
output_field=models.CharField(),
)
)
.order_by("-created_at")
.values(
"member__avatar_url", "member__display_name", "member__id"
)[:count]
)
response_data["user_mention"] = list(users)
elif query_type == "project":
fields = ["name", "identifier"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user)
| Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name", "id", "identifier", "logo_props", "workspace__slug"
)[:count]
)
response_data["project"] = list(projects)
elif query_type == "issue":
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
if query:
for field in fields:
if field == "sequence_id":
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
issues = (
Issue.issue_objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"priority",
"state_id",
"type_id",
)[:count]
)
response_data["issue"] = list(issues)
elif query_type == "cycle":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
cycles = (
Cycle.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
)
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING"),
),
When(
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
output_field=CharField(),
)
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"status",
"workspace__slug",
)[:count]
)
response_data["cycle"] = list(cycles)
elif query_type == "module":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
modules = (
Module.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"status",
"workspace__slug",
)[:count]
)
response_data["module"] = list(modules)
elif query_type == "page":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = (
Page.objects.filter(
q,
projects__project_projectmember__member=self.request.user,
projects__project_projectmember__is_active=True,
workspace__slug=slug,
access=0,
is_global=True,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"logo_props",
"projects__id",
"workspace__slug",
)[:count]
)
response_data["page"] = list(pages)
return Response(response_data, status=status.HTTP_200_OK)
-247
View File
@@ -1,247 +0,0 @@
# Python imports
import pytz
from datetime import datetime
# Django imports
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
# Module imports
from plane.authentication.rate_limit import AuthenticationThrottle
class TimezoneEndpoint(APIView):
permission_classes = [AllowAny]
throttle_classes = [AuthenticationThrottle]
@method_decorator(cache_page(60 * 60 * 24))
def get(self, request):
timezone_mapping = {
"-1100": [
("Midway Island", "Pacific/Midway"),
("American Samoa", "Pacific/Pago_Pago"),
],
"-1000": [
("Hawaii", "Pacific/Honolulu"),
("Aleutian Islands", "America/Adak"),
],
"-0930": [("Marquesas Islands", "Pacific/Marquesas")],
"-0900": [
("Alaska", "America/Anchorage"),
("Gambier Islands", "Pacific/Gambier"),
],
"-0800": [
("Pacific Time (US and Canada)", "America/Los_Angeles"),
("Baja California", "America/Tijuana"),
],
"-0700": [
("Mountain Time (US and Canada)", "America/Denver"),
("Arizona", "America/Phoenix"),
("Chihuahua, Mazatlan", "America/Chihuahua"),
],
"-0600": [
("Central Time (US and Canada)", "America/Chicago"),
("Saskatchewan", "America/Regina"),
("Guadalajara, Mexico City, Monterrey", "America/Mexico_City"),
("Tegucigalpa, Honduras", "America/Tegucigalpa"),
("Costa Rica", "America/Costa_Rica"),
],
"-0500": [
("Eastern Time (US and Canada)", "America/New_York"),
("Lima", "America/Lima"),
("Bogota", "America/Bogota"),
("Quito", "America/Guayaquil"),
("Chetumal", "America/Cancun"),
],
"-0430": [("Caracas (Old Venezuela Time)", "America/Caracas")],
"-0400": [
("Atlantic Time (Canada)", "America/Halifax"),
("Caracas", "America/Caracas"),
("Santiago", "America/Santiago"),
("La Paz", "America/La_Paz"),
("Manaus", "America/Manaus"),
("Georgetown", "America/Guyana"),
("Bermuda", "Atlantic/Bermuda"),
],
"-0330": [("Newfoundland Time (Canada)", "America/St_Johns")],
"-0300": [
("Buenos Aires", "America/Argentina/Buenos_Aires"),
("Brasilia", "America/Sao_Paulo"),
("Greenland", "America/Godthab"),
("Montevideo", "America/Montevideo"),
("Falkland Islands", "Atlantic/Stanley"),
],
"-0200": [
(
"South Georgia and the South Sandwich Islands",
"Atlantic/South_Georgia",
)
],
"-0100": [
("Azores", "Atlantic/Azores"),
("Cape Verde Islands", "Atlantic/Cape_Verde"),
],
"+0000": [
("Dublin", "Europe/Dublin"),
("Reykjavik", "Atlantic/Reykjavik"),
("Lisbon", "Europe/Lisbon"),
("Monrovia", "Africa/Monrovia"),
("Casablanca", "Africa/Casablanca"),
],
"+0100": [
("Central European Time (Berlin, Rome, Paris)", "Europe/Paris"),
("West Central Africa", "Africa/Lagos"),
("Algiers", "Africa/Algiers"),
("Lagos", "Africa/Lagos"),
("Tunis", "Africa/Tunis"),
],
"+0200": [
("Eastern European Time (Cairo, Helsinki, Kyiv)", "Europe/Kiev"),
("Athens", "Europe/Athens"),
("Jerusalem", "Asia/Jerusalem"),
("Johannesburg", "Africa/Johannesburg"),
("Harare, Pretoria", "Africa/Harare"),
],
"+0300": [
("Moscow Time", "Europe/Moscow"),
("Baghdad", "Asia/Baghdad"),
("Nairobi", "Africa/Nairobi"),
("Kuwait, Riyadh", "Asia/Riyadh"),
],
"+0330": [("Tehran", "Asia/Tehran")],
"+0400": [
("Abu Dhabi", "Asia/Dubai"),
("Baku", "Asia/Baku"),
("Yerevan", "Asia/Yerevan"),
("Astrakhan", "Europe/Astrakhan"),
("Tbilisi", "Asia/Tbilisi"),
("Mauritius", "Indian/Mauritius"),
],
"+0500": [
("Islamabad", "Asia/Karachi"),
("Karachi", "Asia/Karachi"),
("Tashkent", "Asia/Tashkent"),
("Yekaterinburg", "Asia/Yekaterinburg"),
("Maldives", "Indian/Maldives"),
("Chagos", "Indian/Chagos"),
],
"+0530": [
("Chennai", "Asia/Kolkata"),
("Kolkata", "Asia/Kolkata"),
("Mumbai", "Asia/Kolkata"),
("New Delhi", "Asia/Kolkata"),
("Sri Jayawardenepura", "Asia/Colombo"),
],
"+0545": [("Kathmandu", "Asia/Kathmandu")],
"+0600": [
("Dhaka", "Asia/Dhaka"),
("Almaty", "Asia/Almaty"),
("Bishkek", "Asia/Bishkek"),
("Thimphu", "Asia/Thimphu"),
],
"+0630": [
("Yangon (Rangoon)", "Asia/Yangon"),
("Cocos Islands", "Indian/Cocos"),
],
"+0700": [
("Bangkok", "Asia/Bangkok"),
("Hanoi", "Asia/Ho_Chi_Minh"),
("Jakarta", "Asia/Jakarta"),
("Novosibirsk", "Asia/Novosibirsk"),
("Krasnoyarsk", "Asia/Krasnoyarsk"),
],
"+0800": [
("Beijing", "Asia/Shanghai"),
("Singapore", "Asia/Singapore"),
("Perth", "Australia/Perth"),
("Hong Kong", "Asia/Hong_Kong"),
("Ulaanbaatar", "Asia/Ulaanbaatar"),
("Palau", "Pacific/Palau"),
],
"+0845": [("Eucla", "Australia/Eucla")],
"+0900": [
("Tokyo", "Asia/Tokyo"),
("Seoul", "Asia/Seoul"),
("Yakutsk", "Asia/Yakutsk"),
],
"+0930": [
("Adelaide", "Australia/Adelaide"),
("Darwin", "Australia/Darwin"),
],
"+1000": [
("Sydney", "Australia/Sydney"),
("Brisbane", "Australia/Brisbane"),
("Guam", "Pacific/Guam"),
("Vladivostok", "Asia/Vladivostok"),
("Tahiti", "Pacific/Tahiti"),
],
"+1030": [("Lord Howe Island", "Australia/Lord_Howe")],
"+1100": [
("Solomon Islands", "Pacific/Guadalcanal"),
("Magadan", "Asia/Magadan"),
("Norfolk Island", "Pacific/Norfolk"),
("Bougainville Island", "Pacific/Bougainville"),
("Chokurdakh", "Asia/Srednekolymsk"),
],
"+1200": [
("Auckland", "Pacific/Auckland"),
("Wellington", "Pacific/Auckland"),
("Fiji Islands", "Pacific/Fiji"),
("Anadyr", "Asia/Anadyr"),
],
"+1245": [("Chatham Islands", "Pacific/Chatham")],
"+1300": [("Nuku'alofa", "Pacific/Tongatapu"), ("Samoa", "Pacific/Apia")],
"+1400": [("Kiritimati Island", "Pacific/Kiritimati")],
}
timezone_list = []
now = datetime.now()
# Process timezone mapping
for offset, locations in timezone_mapping.items():
sign = "-" if offset.startswith("-") else "+"
hours = offset[1:3]
minutes = offset[3:] if len(offset) > 3 else "00"
for friendly_name, tz_identifier in locations:
try:
tz = pytz.timezone(tz_identifier)
current_offset = now.astimezone(tz).strftime("%z")
# converting and formatting UTC offset to GMT offset
current_utc_offset = now.astimezone(tz).utcoffset()
total_seconds = int(current_utc_offset.total_seconds())
hours_offset = total_seconds // 3600
minutes_offset = abs(total_seconds % 3600) // 60
gmt_offset = (
f"GMT{'+' if hours_offset >= 0 else '-'}"
f"{abs(hours_offset):02}:{minutes_offset:02}"
)
timezone_value = {
"offset": int(current_offset),
"utc_offset": f"UTC{sign}{hours}:{minutes}",
"gmt_offset": gmt_offset,
"value": tz_identifier,
"label": f"{friendly_name}",
}
timezone_list.append(timezone_value)
except pytz.exceptions.UnknownTimeZoneError:
continue
# Sort by offset and then by label
timezone_list.sort(key=lambda x: (x["offset"], x["label"]))
# Remove offset from final output
for tz in timezone_list:
del tz["offset"]
return Response({"timezones": timezone_list}, status=status.HTTP_200_OK)
+9 -9
View File
@@ -32,6 +32,7 @@ from bs4 import BeautifulSoup
def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
aggregated_issue_mentions = []
for mention_id in new_mentions:
aggregated_issue_mentions.append(
IssueMention(
@@ -124,9 +125,7 @@ def extract_mentions(issue_instance):
data = json.loads(issue_instance)
html = data.get("description_html")
soup = BeautifulSoup(html, "html.parser")
mention_tags = soup.find_all(
"mention-component", attrs={"entity_name": "user_mention"}
)
mention_tags = soup.find_all("mention-component", attrs={"target": "users"})
mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags]
@@ -140,9 +139,7 @@ def extract_comment_mentions(comment_value):
try:
mentions = []
soup = BeautifulSoup(comment_value, "html.parser")
mentions_tags = soup.find_all(
"mention-component", attrs={"entity_name": "user_mention"}
)
mentions_tags = soup.find_all("mention-component", attrs={"target": "users"})
for mention_tag in mentions_tags:
mentions.append(mention_tag["entity_identifier"])
return list(set(mentions))
@@ -258,9 +255,12 @@ def notifications(
new_mentions = get_new_mentions(
requested_instance=requested_data, current_instance=current_instance
)
new_mentions = list(
set(new_mentions) & {str(member) for member in project_members}
)
new_mentions = [
str(mention)
for mention in new_mentions
if mention in set(project_members)
]
removed_mention = get_removed_mentions(
requested_instance=requested_data, current_instance=current_instance
)
@@ -1,124 +0,0 @@
# Generated by Django 4.2.15 on 2024-12-24 14:57
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0087_remove_issueversion_description_and_more'),
]
operations = [
migrations.AddField(
model_name="sticky",
name="sort_order",
field=models.FloatField(default=65535),
),
migrations.CreateModel(
name="WorkspaceUserLink",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("title", models.CharField(blank=True, max_length=255, null=True)),
("url", models.TextField()),
("metadata", models.JSONField(default=dict)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="owner_workspace_user_link",
to=settings.AUTH_USER_MODEL,
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Workspace User Link",
"verbose_name_plural": "Workspace User Links",
"db_table": "workspace_user_links",
"ordering": ("-created_at",),
},
),
migrations.AlterField(
model_name="pagelog",
name="entity_name",
field=models.CharField(max_length=30, verbose_name="Transaction Type"),
),
migrations.AlterUniqueTogether(
name="webhook",
unique_together={("workspace", "url", "deleted_at")},
),
migrations.AddConstraint(
model_name="webhook",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("workspace", "url"),
name="webhook_url_unique_url_when_deleted_at_null",
),
),
]
-1
View File
@@ -68,7 +68,6 @@ from .workspace import (
WorkspaceMemberInvite,
WorkspaceTheme,
WorkspaceUserProperties,
WorkspaceUserLink,
)
from .favorite import UserFavorite
+1 -1
View File
@@ -90,7 +90,7 @@ class PageLog(BaseModel):
page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE)
entity_identifier = models.UUIDField(null=True)
entity_name = models.CharField(
max_length=30, verbose_name="Transaction Type"
max_length=30, choices=TYPE_CHOICES, verbose_name="Transaction Type"
)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log"
-16
View File
@@ -24,25 +24,9 @@ class Sticky(BaseModel):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="stickies"
)
sort_order = models.FloatField(default=65535)
class Meta:
verbose_name = "Sticky"
verbose_name_plural = "Stickies"
db_table = "stickies"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
# Get the maximum sequence value from the database
last_id = Sticky.objects.filter(workspace=self.workspace).aggregate(
largest=models.Max("sort_order")
)["largest"]
# if last_id is not None
if last_id is not None:
self.sort_order = last_id + 10000
super(Sticky, self).save(*args, **kwargs)
def __str__(self):
return str(self.name)
+1 -8
View File
@@ -47,18 +47,11 @@ class Webhook(BaseModel):
return f"{self.workspace.slug} {self.url}"
class Meta:
unique_together = ["workspace", "url", "deleted_at"]
unique_together = ["workspace", "url"]
verbose_name = "Webhook"
verbose_name_plural = "Webhooks"
db_table = "webhooks"
ordering = ("-created_at",)
constraints = [
models.UniqueConstraint(
fields=["workspace", "url"],
condition=models.Q(deleted_at__isnull=True),
name="webhook_url_unique_url_when_deleted_at_null",
)
]
class WebhookLog(BaseModel):
-20
View File
@@ -322,23 +322,3 @@ class WorkspaceUserProperties(BaseModel):
def __str__(self):
return f"{self.workspace.name} {self.user.email}"
class WorkspaceUserLink(WorkspaceBaseModel):
title = models.CharField(max_length=255, null=True, blank=True)
url = models.TextField()
metadata = models.JSONField(default=dict)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="owner_workspace_user_link",
)
class Meta:
verbose_name = "Workspace User Link"
verbose_name_plural = "Workspace User Links"
db_table = "workspace_user_links"
ordering = ("-created_at",)
def __str__(self):
return f"{self.workspace.id} {self.url}"
@@ -132,33 +132,20 @@ class Command(BaseCommand):
"is_encrypted": False,
},
{
"key": "LLM_API_KEY",
"value": os.environ.get("LLM_API_KEY"),
"category": "AI",
"key": "OPENAI_API_KEY",
"value": os.environ.get("OPENAI_API_KEY"),
"category": "OPENAI",
"is_encrypted": True,
},
{
"key": "LLM_PROVIDER",
"value": os.environ.get("LLM_PROVIDER", "openai"),
"category": "AI",
"is_encrypted": False,
},
{
"key": "LLM_MODEL",
"value": os.environ.get("LLM_MODEL", "gpt-4o-mini"),
"category": "AI",
"is_encrypted": False,
},
# Deprecated, use LLM_MODEL
{
"key": "GPT_ENGINE",
"key": "GPT_ENGINE",
"value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "UNSPLASH_ACCESS_KEY",
"value": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
"value": os.environ.get("UNSPLASH_ACESS_KEY", ""),
"category": "UNSPLASH",
"is_encrypted": True,
},
+1 -1
View File
@@ -37,7 +37,7 @@ uvicorn==0.29.0
# sockets
channels==4.1.0
# ai
litellm==1.51.0
openai==1.25.0
# slack
slack-sdk==3.27.1
# apm
-6
View File
@@ -1,6 +0,0 @@
.next
.turbo
out/
dist/
build/
node_modules/
-5
View File
@@ -1,5 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
-1
View File
@@ -20,7 +20,6 @@
"@hocuspocus/extension-logger": "^2.11.3",
"@hocuspocus/extension-redis": "^2.13.5",
"@hocuspocus/server": "^2.11.3",
"@plane/constants": "*",
"@plane/editor": "*",
"@plane/types": "*",
"@sentry/node": "^8.28.0",
-3
View File
@@ -1,3 +0,0 @@
export enum AI_EDITOR_TASKS {
ASK_ANYTHING = "ASK_ANYTHING",
}
+2 -63
View File
@@ -1,36 +1,3 @@
export enum E_PASSWORD_STRENGTH {
EMPTY = "empty",
LENGTH_NOT_VALID = "length_not_valid",
STRENGTH_NOT_VALID = "strength_not_valid",
STRENGTH_VALID = "strength_valid",
}
export const PASSWORD_MIN_LENGTH = 8;
export const SPACE_PASSWORD_CRITERIA = [
{
key: "min_8_char",
label: "Min 8 characters",
isCriteriaValid: (password: string) =>
password.length >= PASSWORD_MIN_LENGTH,
},
// {
// key: "min_1_upper_case",
// label: "Min 1 upper-case letter",
// isCriteriaValid: (password: string) => PASSWORD_NUMBER_REGEX.test(password),
// },
// {
// key: "min_1_number",
// label: "Min 1 number",
// isCriteriaValid: (password: string) => PASSWORD_CHAR_CAPS_REGEX.test(password),
// },
// {
// key: "min_1_special_char",
// label: "Min 1 special character",
// isCriteriaValid: (password: string) => PASSWORD_SPECIAL_CHAR_REGEX.test(password),
// },
];
export enum EAuthPageTypes {
PUBLIC = "PUBLIC",
NON_AUTHENTICATED = "NON_AUTHENTICATED",
@@ -39,14 +6,6 @@ export enum EAuthPageTypes {
AUTHENTICATED = "AUTHENTICATED",
}
export enum EPageTypes {
INIT = "INIT",
PUBLIC = "PUBLIC",
NON_AUTHENTICATED = "NON_AUTHENTICATED",
ONBOARDING = "ONBOARDING",
AUTHENTICATED = "AUTHENTICATED",
}
export enum EAuthModes {
SIGN_IN = "SIGN_IN",
SIGN_UP = "SIGN_UP",
@@ -58,35 +17,15 @@ export enum EAuthSteps {
UNIQUE_CODE = "UNIQUE_CODE",
}
// TODO: remove this
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
TOAST_ALERT = "TOAST_ALERT",
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
INLINE_EMAIL = "INLINE_EMAIL",
INLINE_PASSWORD = "INLINE_PASSWORD",
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
}
export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAdminAuthErrorCodes;
title: string;
message: any;
};
export enum EAdminAuthErrorCodes {
// Admin
ADMIN_ALREADY_EXIST = "5150",
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
INVALID_ADMIN_EMAIL = "5160",
INVALID_ADMIN_PASSWORD = "5165",
REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
ADMIN_AUTHENTICATION_FAILED = "5175",
ADMIN_USER_ALREADY_EXIST = "5180",
ADMIN_USER_DOES_NOT_EXIST = "5185",
ADMIN_USER_DEACTIVATED = "5190",
}
export enum EAuthErrorCodes {
// Global
INSTANCE_NOT_CONFIGURED = "5000",
@@ -135,7 +74,7 @@ export enum EAuthErrorCodes {
INCORRECT_OLD_PASSWORD = "5135",
MISSING_PASSWORD = "5138",
INVALID_NEW_PASSWORD = "5140",
// set password
// set passowrd
PASSWORD_ALREADY_SET = "5145",
// Admin
ADMIN_ALREADY_EXIST = "5150",
+8 -15
View File
@@ -1,25 +1,18 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "/";
export const API_URL = encodeURI(`${API_BASE_URL}${API_BASE_PATH}`);
// PI Base Url
export const PI_BASE_URL = process.env.NEXT_PUBLIC_PI_BASE_URL || "";
// God Mode Admin App Base Url
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "/";
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}`);
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}/`);
// Publish App Base Url
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "/";
export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}`);
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}/`);
// Live App Base Url
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "/";
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}`);
// Web App Base Url
export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || "";
export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "/";
export const WEB_URL = encodeURI(`${WEB_BASE_URL}${WEB_BASE_PATH}`);
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}/`);
// plane website url
export const WEBSITE_URL =
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
// support email
export const SUPPORT_EMAIL =
process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
-7
View File
@@ -1,11 +1,4 @@
export * from "./ai";
export * from "./auth";
export * from "./endpoints";
export * from "./file";
export * from "./instance";
export * from "./issue";
export * from "./metadata";
export * from "./state";
export * from "./swr";
export * from "./user";
export * from "./workspace";
-139
View File
@@ -1,25 +1,5 @@
import { List, Kanban } from "lucide-react";
export const ALL_ISSUES = "All Issues";
export type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none";
export type TIssueFilterKeys = "priority" | "state" | "labels";
export type TIssueLayout =
| "list"
| "kanban"
| "calendar"
| "spreadsheet"
| "gantt";
export type TIssueFilterPriorityObject = {
key: TIssuePriorities;
title: string;
className: string;
icon: string;
};
export enum EIssueGroupByToServerOptions {
"state" = "state_id",
"priority" = "priority",
@@ -64,122 +44,3 @@ export enum EIssueServiceType {
ISSUES = "issues",
EPICS = "epics",
}
export enum EIssueLayoutTypes {
LIST = "list",
KANBAN = "kanban",
CALENDAR = "calendar",
GANTT = "gantt_chart",
SPREADSHEET = "spreadsheet",
}
export enum EIssuesStoreType {
GLOBAL = "GLOBAL",
PROFILE = "PROFILE",
TEAM = "TEAM",
PROJECT = "PROJECT",
CYCLE = "CYCLE",
MODULE = "MODULE",
TEAM_VIEW = "TEAM_VIEW",
PROJECT_VIEW = "PROJECT_VIEW",
ARCHIVED = "ARCHIVED",
DRAFT = "DRAFT",
DEFAULT = "DEFAULT",
WORKSPACE_DRAFT = "WORKSPACE_DRAFT",
EPIC = "EPIC",
}
export enum EIssueFilterType {
FILTERS = "filters",
DISPLAY_FILTERS = "display_filters",
DISPLAY_PROPERTIES = "display_properties",
KANBAN_FILTERS = "kanban_filters",
}
export enum EIssueCommentAccessSpecifier {
EXTERNAL = "EXTERNAL",
INTERNAL = "INTERNAL",
}
export enum EIssueListRow {
HEADER = "HEADER",
ISSUE = "ISSUE",
NO_ISSUES = "NO_ISSUES",
QUICK_ADD = "QUICK_ADD",
}
export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
[key in TIssueLayout]: Record<"filters", TIssueFilterKeys[]>;
} = {
list: {
filters: ["priority", "state", "labels"],
},
kanban: {
filters: ["priority", "state", "labels"],
},
calendar: {
filters: ["priority", "state", "labels"],
},
spreadsheet: {
filters: ["priority", "state", "labels"],
},
gantt: {
filters: ["priority", "state", "labels"],
},
};
export const ISSUE_PRIORITIES: {
key: TIssuePriorities;
title: string;
}[] = [
{ key: "urgent", title: "Urgent" },
{ key: "high", title: "High" },
{ key: "medium", title: "Medium" },
{ key: "low", title: "Low" },
{ key: "none", title: "None" },
];
export const ISSUE_PRIORITY_FILTERS: TIssueFilterPriorityObject[] = [
{
key: "urgent",
title: "Urgent",
className: "bg-red-500 border-red-500 text-white",
icon: "error",
},
{
key: "high",
title: "High",
className: "text-orange-500 border-custom-border-300",
icon: "signal_cellular_alt",
},
{
key: "medium",
title: "Medium",
className: "text-yellow-500 border-custom-border-300",
icon: "signal_cellular_alt_2_bar",
},
{
key: "low",
title: "Low",
className: "text-green-500 border-custom-border-300",
icon: "signal_cellular_alt_1_bar",
},
{
key: "none",
title: "None",
className: "text-gray-500 border-custom-border-300",
icon: "block",
},
];
export const SITES_ISSUE_LAYOUTS: {
key: TIssueLayout;
title: string;
icon: any;
}[] = [
{ key: "list", title: "List", icon: List },
{ key: "kanban", title: "Kanban", icon: Kanban },
// { key: "calendar", title: "Calendar", icon: Calendar },
// { key: "spreadsheet", title: "Spreadsheet", icon: Sheet },
// { key: "gantt", title: "Gantt chart", icon: GanttChartSquare },
];
-23
View File
@@ -1,23 +0,0 @@
export const SITE_NAME =
"Plane | Simple, extensible, open-source project management tool.";
export const SITE_TITLE =
"Plane | Simple, extensible, open-source project management tool.";
export const SITE_DESCRIPTION =
"Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.";
export const SITE_KEYWORDS =
"software development, plan, ship, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration";
export const SITE_URL = "https://app.plane.so/";
export const TWITTER_USER_NAME =
"Plane | Simple, extensible, open-source project management tool.";
// Plane Sites Metadata
export const SPACE_SITE_NAME =
"Plane Publish | Make your Plane boards and roadmaps pubic with just one-click. ";
export const SPACE_SITE_TITLE =
"Plane Publish | Make your Plane boards public with one-click";
export const SPACE_SITE_DESCRIPTION =
"Plane Publish is a customer feedback management tool built on top of plane.so";
export const SPACE_SITE_KEYWORDS =
"software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration";
export const SPACE_SITE_URL = "https://app.plane.so/";
export const SPACE_TWITTER_USER_NAME = "planepowers";
-1
View File
@@ -37,7 +37,6 @@
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@hocuspocus/provider": "^2.13.5",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@tiptap/core": "^2.1.13",
@@ -131,7 +131,7 @@ export const PageRenderer = (props: IPageRenderer) => {
return (
<>
<div className="frame-renderer flex-grow w-full" onMouseOver={handleLinkHover}>
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
<EditorContainer
displayConfig={displayConfig}
editor={editor}
@@ -10,7 +10,7 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import { EditorReadOnlyRefApi, TDisplayConfig, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TExtensions, TFileHandler } from "@/types";
interface IDocumentReadOnlyEditor {
disabledExtensions: TExtensions[];
@@ -23,7 +23,9 @@ interface IDocumentReadOnlyEditor {
fileHandler: Pick<TFileHandler, "getAssetSrc">;
tabIndex?: number;
handleEditorReady?: (value: boolean) => void;
mentionHandler: TReadOnlyMentionHandler;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
};
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
}
@@ -74,7 +74,6 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
"editor-container cursor-text relative",
{
"active-editor": editor?.isFocused && editor?.isEditable,
"wide-layout": displayConfig.wideLayout,
},
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
@@ -1,4 +1,4 @@
import { Editor, Extensions } from "@tiptap/core";
import { Editor, Extension } from "@tiptap/core";
// components
import { EditorContainer } from "@/components/editors";
// constants
@@ -12,7 +12,7 @@ import { EditorContentWrapper } from "./editor-content";
type Props = IEditorProps & {
children?: (editor: Editor) => React.ReactNode;
extensions: Extensions;
extensions: Extension<any, any>[];
};
export const EditorWrapper: React.FC<Props> = (props) => {
@@ -1,185 +1,3 @@
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
CaseSensitive,
Code2,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
Image,
Italic,
List,
ListOrdered,
ListTodo,
LucideIcon,
Strikethrough,
Table,
TextQuote,
Underline,
} from "lucide-react";
import { TCommandExtraProps, TEditorCommands } from "@/types/editor";
export type TEditorTypes = "lite" | "document";
// Utility type to enforce the necessary extra props or make extraProps optional
export type ExtraPropsForCommand<T extends TEditorCommands> = T extends keyof TCommandExtraProps
? TCommandExtraProps[T]
: object; // Default to empty object for commands without extra props
export type ToolbarMenuItem<T extends TEditorCommands = TEditorCommands> = {
itemKey: T;
renderKey: string;
name: string;
icon: LucideIcon;
shortcut?: string[];
editors: TEditorTypes[];
extraProps?: ExtraPropsForCommand<T>;
};
export const TYPOGRAPHY_ITEMS: ToolbarMenuItem<"text" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6">[] = [
{ itemKey: "text", renderKey: "text", name: "Text", icon: CaseSensitive, editors: ["document"] },
{ itemKey: "h1", renderKey: "h1", name: "Heading 1", icon: Heading1, editors: ["document"] },
{ itemKey: "h2", renderKey: "h2", name: "Heading 2", icon: Heading2, editors: ["document"] },
{ itemKey: "h3", renderKey: "h3", name: "Heading 3", icon: Heading3, editors: ["document"] },
{ itemKey: "h4", renderKey: "h4", name: "Heading 4", icon: Heading4, editors: ["document"] },
{ itemKey: "h5", renderKey: "h5", name: "Heading 5", icon: Heading5, editors: ["document"] },
{ itemKey: "h6", renderKey: "h6", name: "Heading 6", icon: Heading6, editors: ["document"] },
];
export const TEXT_ALIGNMENT_ITEMS: ToolbarMenuItem<"text-align">[] = [
{
itemKey: "text-align",
renderKey: "text-align-left",
name: "Left align",
icon: AlignLeft,
shortcut: ["Cmd", "Shift", "L"],
editors: ["lite", "document"],
extraProps: {
alignment: "left",
},
},
{
itemKey: "text-align",
renderKey: "text-align-center",
name: "Center align",
icon: AlignCenter,
shortcut: ["Cmd", "Shift", "E"],
editors: ["lite", "document"],
extraProps: {
alignment: "center",
},
},
{
itemKey: "text-align",
renderKey: "text-align-right",
name: "Right align",
icon: AlignRight,
shortcut: ["Cmd", "Shift", "R"],
editors: ["lite", "document"],
extraProps: {
alignment: "right",
},
},
];
const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strikethrough">[] = [
{
itemKey: "bold",
renderKey: "bold",
name: "Bold",
icon: Bold,
shortcut: ["Cmd", "B"],
editors: ["lite", "document"],
},
{
itemKey: "italic",
renderKey: "italic",
name: "Italic",
icon: Italic,
shortcut: ["Cmd", "I"],
editors: ["lite", "document"],
},
{
itemKey: "underline",
renderKey: "underline",
name: "Underline",
icon: Underline,
shortcut: ["Cmd", "U"],
editors: ["lite", "document"],
},
{
itemKey: "strikethrough",
renderKey: "strikethrough",
name: "Strikethrough",
icon: Strikethrough,
shortcut: ["Cmd", "Shift", "S"],
editors: ["lite", "document"],
},
];
const LIST_ITEMS: ToolbarMenuItem<"bulleted-list" | "numbered-list" | "to-do-list">[] = [
{
itemKey: "bulleted-list",
renderKey: "bulleted-list",
name: "Bulleted list",
icon: List,
shortcut: ["Cmd", "Shift", "7"],
editors: ["lite", "document"],
},
{
itemKey: "numbered-list",
renderKey: "numbered-list",
name: "Numbered list",
icon: ListOrdered,
shortcut: ["Cmd", "Shift", "8"],
editors: ["lite", "document"],
},
{
itemKey: "to-do-list",
renderKey: "to-do-list",
name: "To-do list",
icon: ListTodo,
shortcut: ["Cmd", "Shift", "9"],
editors: ["lite", "document"],
},
];
export const USER_ACTION_ITEMS: ToolbarMenuItem<"quote" | "code">[] = [
{ itemKey: "quote", renderKey: "quote", name: "Quote", icon: TextQuote, editors: ["lite", "document"] },
{ itemKey: "code", renderKey: "code", name: "Code", icon: Code2, editors: ["lite", "document"] },
];
export const COMPLEX_ITEMS: ToolbarMenuItem<"table" | "image">[] = [
{ itemKey: "table", renderKey: "table", name: "Table", icon: Table, editors: ["document"] },
{ itemKey: "image", renderKey: "image", name: "Image", icon: Image, editors: ["lite", "document"] },
];
export const TOOLBAR_ITEMS: {
[editorType in TEditorTypes]: {
[key: string]: ToolbarMenuItem[];
};
} = {
lite: {
basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("lite")),
alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("lite")),
list: LIST_ITEMS.filter((item) => item.editors.includes("lite")),
userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("lite")),
complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("lite")),
},
document: {
basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("document")),
alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("document")),
list: LIST_ITEMS.filter((item) => item.editors.includes("document")),
userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("document")),
complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("document")),
},
};
export const COLORS_LIST: {
key: string;
label: string;
@@ -4,7 +4,6 @@ import { TDisplayConfig } from "@/types";
export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
fontSize: "large-font",
fontStyle: "sans-serif",
wideLayout: false,
};
export const ACCEPTED_FILE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"];
@@ -3,6 +3,4 @@ export const DocumentCollaborativeEvents = {
unlock: { client: "unlocked", server: "unlock" },
archive: { client: "archived", server: "archive" },
unarchive: { client: "unarchived", server: "unarchive" },
"make-public": { client: "made-public", server: "make-public" },
"make-private": { client: "made-private", server: "make-private" },
} as const;
@@ -13,7 +13,7 @@ import { CustomHorizontalRule } from "./horizontal-rule";
import { ImageExtensionWithoutProps } from "./image";
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props";
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
import { CustomTextAlignExtension } from "./text-align";
@@ -97,7 +97,7 @@ export const CoreEditorExtensionsWithoutProps = [
TableHeader,
TableCell,
TableRow,
CustomMentionExtensionConfig,
CustomMentionWithoutProps(),
CustomTextAlignExtension,
CustomCalloutExtensionConfig,
CustomColorExtension,
@@ -1,5 +1,5 @@
import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
// extensions
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
@@ -14,77 +14,46 @@ type Props = {
toggleFullScreenMode: (val: boolean) => void;
};
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 2;
const ZOOM_SPEED = 0.05;
const ZOOM_STEPS = [0.5, 1, 1.5, 2];
const MAGNIFICATION_VALUES = [0.5, 0.75, 1, 1.5, 1.75, 2];
export const ImageFullScreenAction: React.FC<Props> = (props) => {
const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props;
const { src, width, aspectRatio } = image;
const [magnification, setMagnification] = useState<number>(1);
const [initialMagnification, setInitialMagnification] = useState(1);
const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0 });
const dragOffset = useRef({ x: 0, y: 0 });
// states
const [magnification, setMagnification] = useState(1);
// refs
const modalRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
// derived values
const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]);
const setImageRef = useCallback(
(node: HTMLImageElement | null) => {
if (!node || !isFullScreenEnabled) return;
imgRef.current = node;
const viewportWidth = window.innerWidth * 0.9;
const viewportHeight = window.innerHeight * 0.75;
const imageWidth = widthInNumber;
const imageHeight = imageWidth / aspectRatio;
const widthRatio = viewportWidth / imageWidth;
const heightRatio = viewportHeight / imageHeight;
setInitialMagnification(Math.min(widthRatio, heightRatio));
setMagnification(1);
// Reset image position
node.style.left = "0px";
node.style.top = "0px";
},
[isFullScreenEnabled, widthInNumber, aspectRatio]
);
// close handler
const handleClose = useCallback(() => {
if (isDragging) return;
toggleFullScreenMode(false);
setMagnification(1);
setInitialMagnification(1);
}, [isDragging, toggleFullScreenMode]);
const handleMagnification = useCallback((direction: "increase" | "decrease") => {
setMagnification((prev) => {
// Find the appropriate target zoom level based on current magnification
let targetZoom: number;
if (direction === "increase") {
targetZoom = ZOOM_STEPS.find((step) => step > prev) ?? MAX_ZOOM;
} else {
// Reverse the array to find the next lower step
targetZoom = [...ZOOM_STEPS].reverse().find((step) => step < prev) ?? MIN_ZOOM;
}
// Reset position when zoom matches initial magnification
if (targetZoom === 1 && imgRef.current) {
imgRef.current.style.left = "0px";
imgRef.current.style.top = "0px";
}
return targetZoom;
});
}, []);
setTimeout(() => {
setMagnification(1);
}, 200);
}, [toggleFullScreenMode]);
// download handler
const handleOpenInNewTab = () => {
const link = document.createElement("a");
link.href = src;
link.target = "_blank";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// magnification decrease handler
const handleDecreaseMagnification = useCallback(() => {
const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification);
if (currentIndex === 0) return;
setMagnification(MAGNIFICATION_VALUES[currentIndex - 1]);
}, [magnification]);
// magnification increase handler
const handleIncreaseMagnification = useCallback(() => {
const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification);
if (currentIndex === MAGNIFICATION_VALUES.length - 1) return;
setMagnification(MAGNIFICATION_VALUES[currentIndex + 1]);
}, [magnification]);
// keydown handler
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "+" || e.key === "=" || e.key === "-") {
@@ -92,113 +61,43 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
e.stopPropagation();
if (e.key === "Escape") handleClose();
if (e.key === "+" || e.key === "=") handleMagnification("increase");
if (e.key === "-") handleMagnification("decrease");
if (e.key === "+" || e.key === "=") handleIncreaseMagnification();
if (e.key === "-") handleDecreaseMagnification();
}
},
[handleClose, handleMagnification]
[handleClose, handleDecreaseMagnification, handleIncreaseMagnification]
);
// click outside handler
const handleClickOutside = useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (modalRef.current && e.target === modalRef.current) {
handleClose();
}
},
[handleClose]
);
// register keydown listener
useEffect(() => {
if (isFullScreenEnabled) {
document.addEventListener("keydown", handleKeyDown);
const handleMouseDown = (e: React.MouseEvent) => {
if (!imgRef.current) return;
const imgWidth = imgRef.current.offsetWidth * magnification;
const imgHeight = imgRef.current.offsetHeight * magnification;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (imgWidth > viewportWidth || imgHeight > viewportHeight) {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY };
dragOffset.current = {
x: parseInt(imgRef.current.style.left || "0"),
y: parseInt(imgRef.current.style.top || "0"),
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}
};
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging || !imgRef.current) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
// Apply the scale factor to the drag movement
const scaledDx = dx / magnification;
const scaledDy = dy / magnification;
imgRef.current.style.left = `${dragOffset.current.x + scaledDx}px`;
imgRef.current.style.top = `${dragOffset.current.y + scaledDy}px`;
},
[isDragging, magnification]
);
const handleMouseUp = useCallback(() => {
if (!isDragging || !imgRef.current) return;
setIsDragging(false);
}, [isDragging]);
const handleWheel = useCallback(
(e: WheelEvent) => {
if (!imgRef.current || !isFullScreenEnabled) return;
e.preventDefault();
// Handle pinch-to-zoom
if (e.ctrlKey) {
const delta = e.deltaY;
setMagnification((prev) => {
const newZoom = prev * (1 - delta * ZOOM_SPEED);
const clampedZoom = Math.min(Math.max(newZoom, MIN_ZOOM), MAX_ZOOM);
// Reset position when zoom matches initial magnification
if (clampedZoom === 1 && imgRef.current) {
imgRef.current.style.left = "0px";
imgRef.current.style.top = "0px";
}
return clampedZoom;
});
return;
}
},
[isFullScreenEnabled, magnification]
);
// Event listeners
useEffect(() => {
if (!isFullScreenEnabled) return;
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("wheel", handleWheel, { passive: false });
return () => {
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("wheel", handleWheel);
};
}, [isFullScreenEnabled, handleKeyDown, handleMouseMove, handleMouseUp, handleWheel]);
}, [handleKeyDown, isFullScreenEnabled]);
return (
<>
<div
className={cn("fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none transition-opacity", {
"opacity-100 pointer-events-auto": isFullScreenEnabled,
"cursor-default": !isDragging,
"cursor-grabbing": isDragging,
})}
className={cn(
"fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none cursor-default transition-opacity",
{
"opacity-100 pointer-events-auto": isFullScreenEnabled,
}
)}
>
<div
ref={modalRef}
onMouseDown={(e) => e.target === modalRef.current && handleClose()}
className="relative size-full grid place-items-center overflow-hidden"
>
<div ref={modalRef} onClick={handleClickOutside} className="relative size-full grid place-items-center">
<button
type="button"
onClick={handleClose}
@@ -207,49 +106,41 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
<X className="size-8 text-white/60 hover:text-white transition-colors" />
</button>
<img
ref={setImageRef}
src={src}
className="read-only-image rounded-lg"
className="read-only-image rounded-lg transition-all duration-200"
style={{
width: `${widthInNumber * initialMagnification}px`,
maxWidth: "none",
maxHeight: "none",
width: `${widthInNumber * magnification}px`,
aspectRatio,
position: "relative",
transform: `scale(${magnification})`,
transformOrigin: "center",
transition: "width 0.2s ease, transform 0.2s ease",
}}
onMouseDown={handleMouseDown}
/>
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20 bg-black">
<div className="flex items-center">
<button
type="button"
onClick={() => handleMagnification("decrease")}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification <= MIN_ZOOM}
>
<Minus className="size-4" />
</button>
<span className="text-sm w-12 text-center text-white">{Math.round(100 * magnification)}%</span>
<button
type="button"
onClick={() => handleMagnification("increase")}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification >= MAX_ZOOM}
>
<Plus className="size-4" />
</button>
</div>
</div>
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20 bg-black">
<div className="flex items-center">
<button
type="button"
onClick={() => window.open(src, "_blank")}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
onClick={handleDecreaseMagnification}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification === MAGNIFICATION_VALUES[0]}
>
<ExternalLink className="size-4" />
<Minus className="size-4" />
</button>
<span className="text-sm w-12 text-center text-white">{(100 * magnification).toFixed(0)}%</span>
<button
type="button"
onClick={handleIncreaseMagnification}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification === MAGNIFICATION_VALUES[MAGNIFICATION_VALUES.length - 1]}
>
<Plus className="size-4" />
</button>
</div>
<button
type="button"
onClick={handleOpenInNewTab}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
>
<ExternalLink className="size-4" />
</button>
</div>
</div>
<button
@@ -18,7 +18,7 @@ import {
CustomImageExtension,
CustomKeymap,
CustomLinkExtension,
CustomMentionExtension,
CustomMention,
CustomQuoteExtension,
CustomTextAlignExtension,
CustomTypographyExtension,
@@ -33,7 +33,7 @@ import {
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
import { IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
// plane editor extensions
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
@@ -41,14 +41,17 @@ type TArguments = {
disabledExtensions: TExtensions[];
enableHistory: boolean;
fileHandler: TFileHandler;
mentionHandler: TMentionHandler;
mentionConfig: {
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
};
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
editable: boolean;
};
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex } = args;
const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex, editable } = args;
return [
StarterKit.configure({
@@ -82,8 +85,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
},
},
dropcursor: {
class:
"text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]",
class: "text-custom-text-300",
},
...(enableHistory ? {} : { history: false }),
}),
@@ -144,7 +146,11 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
TableHeader,
TableCell,
TableRow,
CustomMentionExtension(mentionHandler),
CustomMention({
mentionSuggestions: editable ? mentionConfig.mentionSuggestions : undefined,
mentionHighlights: mentionConfig.mentionHighlights,
readonly: !editable,
}),
Placeholder.configure({
placeholder: ({ editor, node }) => {
if (!editor.isEditable) return;
@@ -1,48 +0,0 @@
import { mergeAttributes } from "@tiptap/core";
import Mention, { MentionOptions } from "@tiptap/extension-mention";
// types
import { TMentionHandler } from "@/types";
// local types
import { EMentionComponentAttributeNames } from "./types";
export type TMentionExtensionOptions = MentionOptions & {
renderComponent: TMentionHandler["renderComponent"];
};
export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOptions>({
addAttributes() {
return {
[EMentionComponentAttributeNames.ID]: {
default: null,
},
[EMentionComponentAttributeNames.ENTITY_IDENTIFIER]: {
default: null,
},
[EMentionComponentAttributeNames.ENTITY_NAME]: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "mention-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["mention-component", mergeAttributes(HTMLAttributes)];
},
HTMLAttributes: {
class: "mention",
},
addStorage(this) {
return {
mentionsOpen: false,
};
},
});
@@ -1,31 +1,154 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { Editor, mergeAttributes } from "@tiptap/core";
import Mention, { MentionOptions } from "@tiptap/extension-mention";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
import { Node } from "@tiptap/pm/model";
import { ReactNodeViewRenderer, ReactRenderer } from "@tiptap/react";
import tippy from "tippy.js";
// extensions
import { MentionList, MentionNodeView } from "@/extensions";
// types
import { TMentionHandler } from "@/types";
// extension config
import { CustomMentionExtensionConfig } from "./extension-config";
// node view
import { MentionNodeView } from "./mention-node-view";
// utils
import { renderMentionsDropdown } from "./utils";
import { IMentionHighlight, IMentionSuggestion } from "@/types";
export const CustomMentionExtension = (props: TMentionHandler) => {
const { searchCallback, renderComponent } = props;
return CustomMentionExtensionConfig.extend({
addOptions(this) {
interface CustomMentionOptions extends MentionOptions {
mentionHighlights: () => Promise<IMentionHighlight[]>;
readonly?: boolean;
}
export const CustomMention = ({
mentionHighlights,
mentionSuggestions,
readonly,
}: {
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
readonly: boolean;
}) =>
Mention.extend<CustomMentionOptions>({
addStorage(this) {
return {
...this.parent?.(),
renderComponent,
mentionsOpen: false,
markdown: {
serialize(state: MarkdownSerializerState, node: Node) {
const { attrs } = node;
const label = `@${state.esc(attrs.label)}`;
const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
const safeRedirectionPath = state.esc(attrs.redirect_uri);
const url = `${originUrl}${safeRedirectionPath}`;
state.write(`[${label}](${url})`);
},
},
};
},
addAttributes() {
return {
id: {
default: null,
},
label: {
default: null,
},
target: {
default: null,
},
self: {
default: false,
},
redirect_uri: {
default: "/",
},
entity_identifier: {
default: null,
},
entity_name: {
default: null,
},
};
},
addNodeView() {
return ReactNodeViewRenderer(MentionNodeView);
},
parseHTML() {
return [
{
tag: "mention-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["mention-component", mergeAttributes(HTMLAttributes)];
},
}).configure({
HTMLAttributes: {
class: "mention",
},
readonly: readonly,
mentionHighlights,
suggestion: {
render: renderMentionsDropdown({
searchCallback,
}),
// @ts-expect-error - Tiptap types are incorrect
render: () => {
if (!mentionSuggestions) return;
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
if (!props.clientRect) {
return;
}
component = new ReactRenderer(MentionList, {
props: { ...props, mentionSuggestions },
editor: props.editor,
});
props.editor.storage.mentionsOpen = true;
// @ts-expect-error - Tippy types are incorrect
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () =>
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'),
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
if (!props.clientRect) {
return;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
if (navigationKeys.includes(props.event.key)) {
// @ts-expect-error - Tippy types are incorrect
component?.ref?.onKeyDown(props);
event?.stopPropagation();
return true;
}
return false;
},
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
props.editor.storage.mentionsOpen = false;
popup?.[0].destroy();
component?.destroy();
},
};
},
},
});
};
@@ -1,2 +1,4 @@
export * from "./extension";
export * from "./extension-config";
export * from "./mention-node-view";
export * from "./mentions-list";
export * from "./mentions-without-props";
@@ -1,26 +1,49 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
// extension config
import { TMentionExtensionOptions } from "./extension-config";
// extension types
import { EMentionComponentAttributeNames, TMentionComponentAttributes } from "./types";
// TODO: fix all warnings
type Props = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TMentionComponentAttributes;
/* eslint-disable react/display-name */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { useEffect, useState } from "react";
import { NodeViewWrapper } from "@tiptap/react";
// plane utils
import { cn } from "@plane/utils";
// types
import { IMentionHighlight } from "@/types";
// eslint-disable-next-line import/no-anonymous-default-export
export const MentionNodeView = (props) => {
// TODO: move it to web app
const [highlightsState, setHighlightsState] = useState<IMentionHighlight[]>();
useEffect(() => {
if (!props.extension.options.mentionHighlights) return;
const hightlights = async () => {
const userId = await props.extension.options.mentionHighlights?.();
setHighlightsState(userId);
};
hightlights();
}, [props.extension.options]);
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (!props.node.attrs.redirect_uri) {
event.preventDefault();
}
};
};
export const MentionNodeView = (props: Props) => {
const {
extension,
node: { attrs },
} = props;
return (
<NodeViewWrapper className="mention-component inline w-fit">
{(extension.options as TMentionExtensionOptions).renderComponent({
entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER],
entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention",
})}
<a
href={props.node.attrs.redirect_uri || "#"}
target="_blank"
className={cn("mention rounded bg-custom-primary-100/20 px-1 py-0.5 font-medium text-custom-primary-100", {
"bg-yellow-500/20 text-yellow-500": highlightsState
? highlightsState.includes(props.node.attrs.entity_identifier)
: false,
"cursor-pointer": !props.extension.options.readonly,
})}
>
@{props.node.attrs.label}
</a>
</NodeViewWrapper>
);
};
@@ -1,163 +0,0 @@
"use client";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
import { Editor } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// plane utils
import { cn } from "@plane/utils";
// helpers
import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy";
// types
import { TMentionHandler, TMentionSection, TMentionSuggestion } from "@/types";
export type MentionsListDropdownProps = {
command: (item: TMentionSuggestion) => void;
query: string;
editor: Editor;
} & Pick<TMentionHandler, "searchCallback">;
export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps, ref) => {
const { command, query, searchCallback } = props;
// states
const [sections, setSections] = useState<TMentionSection[]>([]);
const [selectedIndex, setSelectedIndex] = useState({
section: 0,
item: 0,
});
const [isLoading, setIsLoading] = useState(false);
// refs
const commandListContainer = useRef<HTMLDivElement>(null);
const selectItem = useCallback(
(sectionIndex: number, itemIndex: number) => {
try {
const item = sections?.[sectionIndex]?.items?.[itemIndex];
const transactionId = uuidv4();
if (item) {
command({
...item,
id: transactionId,
});
}
} catch (error) {
console.error("Error selecting mention item:", error);
}
},
[command, sections]
);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (!DROPDOWN_NAVIGATION_KEYS.includes(event.key)) return;
event.preventDefault();
if (event.key === "Enter") {
selectItem(selectedIndex.section, selectedIndex.item);
return;
}
const newIndex = getNextValidIndex({
event,
sections,
selectedIndex,
});
setSelectedIndex(newIndex);
},
}));
// initialize the select index to 0 by default
useEffect(() => {
setSelectedIndex({
section: 0,
item: 0,
});
}, [sections]);
// fetch mention sections based on query
useEffect(() => {
const fetchSuggestions = async () => {
setIsLoading(true);
try {
const sectionsResponse = await searchCallback?.(query);
setSections(sectionsResponse);
} catch (error) {
console.error("Failed to fetch suggestions:", error);
} finally {
setIsLoading(false);
}
};
fetchSuggestions();
}, [query, searchCallback]);
// scroll to the dropdown item when navigating via keyboard
useLayoutEffect(() => {
const container = commandListContainer?.current;
if (!container) return;
const item = container.querySelector(`#mention-item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement;
if (item) {
const containerRect = container.getBoundingClientRect();
const itemRect = item.getBoundingClientRect();
const isItemInView = itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom;
if (!isItemInView) {
item.scrollIntoView({ block: "nearest" });
}
}
}, [selectedIndex]);
return (
<div
ref={commandListContainer}
className="z-10 max-h-[90vh] w-[14rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2"
>
{isLoading ? (
<div className="text-center text-sm text-custom-text-400">Loading...</div>
) : sections.length ? (
sections.map((section, sectionIndex) => (
<div key={section.key} className="space-y-2">
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
{section.items.map((item, itemIndex) => {
const isSelected = sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item;
return (
<button
key={item.id}
id={`mention-item-${sectionIndex}-${itemIndex}`}
type="button"
className={cn(
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200",
{
"bg-custom-background-80": isSelected,
}
)}
onClick={(e) => {
e.preventDefault();
selectItem(sectionIndex, itemIndex);
}}
onMouseEnter={() =>
setSelectedIndex({
section: sectionIndex,
item: itemIndex,
})
}
>
<span className="size-5 grid place-items-center flex-shrink-0">{item.icon}</span>
{item.subTitle && (
<h5 className="whitespace-nowrap text-xs text-custom-text-300 flex-shrink-0">{item.subTitle}</h5>
)}
<p className="flex-grow truncate">{item.title}</p>
</button>
);
})}
</div>
))
) : (
<div className="text-center text-sm text-custom-text-400">No results</div>
)}
</div>
);
});
MentionsListDropdown.displayName = "MentionsListDropdown";
@@ -0,0 +1,171 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
import { Editor } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// ui
import { Avatar } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// types
import { IMentionSuggestion } from "@/types";
interface MentionListProps {
command: (item: {
id: string;
label: string;
entity_name: string;
entity_identifier: string;
target: string;
redirect_uri: string;
}) => void;
query: string;
editor: Editor;
mentionSuggestions: () => Promise<IMentionSuggestion[]>;
}
export const MentionList = forwardRef((props: MentionListProps, ref) => {
const { query, mentionSuggestions } = props;
const [items, setItems] = useState<IMentionSuggestion[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchSuggestions = async () => {
setIsLoading(true);
try {
const suggestions = await mentionSuggestions();
const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => {
const transactionId = uuidv4();
return {
...suggestion,
id: transactionId,
};
});
const filteredSuggestions = mappedSuggestions.filter((suggestion) =>
suggestion.title.toLowerCase().startsWith(query.toLowerCase())
);
setItems(filteredSuggestions);
} catch (error) {
console.error("Failed to fetch suggestions:", error);
} finally {
setIsLoading(false);
}
};
fetchSuggestions();
}, [query, mentionSuggestions]);
const selectItem = (index: number) => {
try {
const item = items[index];
if (item) {
props.command({
id: item.id,
label: item.title,
entity_identifier: item.entity_identifier,
entity_name: item.entity_name,
target: "users",
redirect_uri: item.redirect_uri,
});
}
} catch (error) {
console.error("Error selecting item:", error);
}
};
const commandListContainer = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const container = commandListContainer?.current;
const item = container?.children[selectedIndex] as HTMLElement;
if (item && container) updateScrollView(container, item);
}, [selectedIndex]);
const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
const itemHeight = item ? item.offsetHeight : 0;
const top = item.offsetTop;
const bottom = top + itemHeight;
if (top < container.scrollTop) {
container.scrollTop -= container.scrollTop - top + 5;
} else if (bottom > containerHeight + container.scrollTop) {
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
}
};
const upHandler = () => {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => {
setSelectedIndex(0);
}, [items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
return true;
}
return false;
},
}));
return (
<div
ref={commandListContainer}
className="mentions max-h-48 min-w-[12rem] rounded-md bg-custom-background-100 border-[0.5px] border-custom-border-300 px-2 py-2.5 text-xs shadow-custom-shadow-rg overflow-y-scroll"
>
{isLoading ? (
<div className="text-center text-custom-text-400">Loading...</div>
) : items.length ? (
items.map((item, index) => (
<button
key={item.id}
type="button"
className={cn(
"w-full text-left flex cursor-pointer items-center gap-2 rounded px-1 py-1.5 hover:bg-custom-background-80 text-custom-text-200",
{
"bg-custom-background-80": index === selectedIndex,
}
)}
onClick={() => selectItem(index)}
>
<Avatar name={item?.title} src={item?.avatar} />
<span className="flex-grow truncate">{item.title}</span>
</button>
))
) : (
<div className="text-center text-custom-text-400">No results</div>
)}
</div>
);
});
MentionList.displayName = "MentionList";
@@ -0,0 +1,51 @@
import { mergeAttributes } from "@tiptap/core";
import Mention, { MentionOptions } from "@tiptap/extension-mention";
// types
import { IMentionHighlight } from "@/types";
interface CustomMentionOptions extends MentionOptions {
mentionHighlights: () => Promise<IMentionHighlight[]>;
readonly?: boolean;
}
export const CustomMentionWithoutProps = () =>
Mention.extend<CustomMentionOptions>({
addAttributes() {
return {
id: {
default: null,
},
label: {
default: null,
},
target: {
default: null,
},
self: {
default: false,
},
redirect_uri: {
default: "/",
},
entity_identifier: {
default: null,
},
entity_name: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "mention-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["mention-component", mergeAttributes(HTMLAttributes)];
},
HTMLAttributes: {
class: "mention",
},
});
@@ -1,14 +0,0 @@
// plane types
import { TSearchEntities } from "@plane/types";
export enum EMentionComponentAttributeNames {
ID = "id",
ENTITY_IDENTIFIER = "entity_identifier",
ENTITY_NAME = "entity_name",
}
export type TMentionComponentAttributes = {
[EMentionComponentAttributeNames.ID]: string | null;
[EMentionComponentAttributeNames.ENTITY_IDENTIFIER]: string | null;
[EMentionComponentAttributeNames.ENTITY_NAME]: TSearchEntities | null;
};
@@ -1,72 +0,0 @@
import { Editor } from "@tiptap/core";
import { SuggestionOptions } from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import tippy from "tippy.js";
// helpers
import { CommandListInstance } from "@/helpers/tippy";
// types
import { TMentionHandler } from "@/types";
// local components
import { MentionsListDropdown, MentionsListDropdownProps } from "./mentions-list-dropdown";
export const renderMentionsDropdown =
(props: Pick<TMentionHandler, "searchCallback">): SuggestionOptions["render"] =>
// @ts-expect-error - Tiptap types are incorrect
() => {
const { searchCallback } = props;
let component: ReactRenderer<CommandListInstance, MentionsListDropdownProps> | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
if (!searchCallback) return;
if (!props.clientRect) return;
component = new ReactRenderer<CommandListInstance, MentionsListDropdownProps>(MentionsListDropdown, {
props: {
...props,
searchCallback,
},
editor: props.editor,
});
props.editor.storage.mentionsOpen = true;
// @ts-expect-error - Tippy types are incorrect
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () =>
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'),
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0]?.hide();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
if (navigationKeys.includes(props.event.key)) {
props.event?.stopPropagation();
if (component?.ref?.onKeyDown(props)) {
return true;
}
}
return false;
},
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
props.editor.storage.mentionsOpen = false;
popup?.[0]?.destroy();
component?.destroy();
},
};
};
@@ -19,7 +19,7 @@ import {
TableCell,
TableRow,
Table,
CustomMentionExtension,
CustomMention,
CustomReadOnlyImageExtension,
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
@@ -28,18 +28,20 @@ import {
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
import { TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
import { IMentionHighlight, TExtensions, TFileHandler } from "@/types";
// plane editor extensions
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
type Props = {
disabledExtensions: TExtensions[];
fileHandler: Pick<TFileHandler, "getAssetSrc">;
mentionHandler: TReadOnlyMentionHandler;
mentionConfig: {
mentionHighlights?: () => Promise<IMentionHighlight[]>;
};
};
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
const { disabledExtensions, fileHandler, mentionHandler } = props;
const { disabledExtensions, fileHandler, mentionConfig } = props;
return [
StarterKit.configure({
@@ -130,7 +132,10 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
TableHeader,
TableCell,
TableRow,
CustomMentionExtension(mentionHandler),
CustomMention({
mentionHighlights: mentionConfig.mentionHighlights,
readonly: true,
}),
CharacterCount,
CustomColorExtension,
CustomTextAlignExtension,
@@ -42,7 +42,7 @@ export const SideMenuExtension = (props: Props) => {
ai: aiEnabled,
dragDrop: dragDropEnabled,
},
scrollThreshold: { up: 200, down: 150 },
scrollThreshold: { up: 200, down: 100 },
}),
];
},
@@ -1,18 +1,14 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
import { Editor } from "@tiptap/core";
// helpers
import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
// components
import { TSlashCommandSection } from "./command-items-list";
import { CommandMenuItem } from "./command-menu-item";
export type SlashCommandsMenuProps = {
editor: Editor;
items: TSlashCommandSection[];
command: any;
};
export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) => {
export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
const { items: sections, command } = props;
// states
const [selectedIndex, setSelectedIndex] = useState({
@@ -45,12 +41,12 @@ export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref)
if (nextItem < 0) {
nextSection = currentSection - 1;
if (nextSection < 0) nextSection = sections.length - 1;
nextItem = sections[nextSection]?.items?.length - 1;
nextItem = sections[nextSection]?.items.length - 1;
}
}
if (e.key === "ArrowDown") {
nextItem = currentItem + 1;
if (nextItem >= sections[currentSection]?.items?.length) {
if (nextItem >= sections[currentSection].items.length) {
nextSection = currentSection + 1;
if (nextSection >= sections.length) nextSection = 0;
nextItem = 0;
@@ -88,26 +84,7 @@ export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref)
item?.scrollIntoView({ block: "nearest" });
}, [sections, selectedIndex]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (!DROPDOWN_NAVIGATION_KEYS.includes(event.key)) return;
event.preventDefault();
if (event.key === "Enter") {
selectItem(selectedIndex.section, selectedIndex.item);
return;
}
const newIndex = getNextValidIndex({
event,
sections,
selectedIndex,
});
setSelectedIndex(newIndex);
},
}));
const areSearchResultsEmpty = sections.map((s) => s.items?.length).reduce((acc, curr) => acc + curr, 0) === 0;
const areSearchResultsEmpty = sections.map((s) => s.items.length).reduce((acc, curr) => acc + curr, 0) === 0;
if (areSearchResultsEmpty) return null;
@@ -121,7 +98,7 @@ export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref)
<div key={section.key} className="space-y-2">
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
<div>
{section.items?.map((item, itemIndex) => (
{section.items.map((item, itemIndex) => (
<CommandMenuItem
key={item.key}
isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item}
@@ -145,6 +122,4 @@ export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref)
))}
</div>
);
});
SlashCommandsMenu.displayName = "SlashCommandsMenu";
};
@@ -2,8 +2,6 @@ import { Editor, Range, Extension } from "@tiptap/core";
import { ReactRenderer } from "@tiptap/react";
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
import tippy from "tippy.js";
// helpers
import { CommandListInstance } from "@/helpers/tippy";
// types
import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types";
// components
@@ -57,12 +55,16 @@ const Command = Extension.create<SlashCommandOptions>({
},
});
interface CommandListInstance {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
const renderItems = () => {
let component: ReactRenderer<CommandListInstance, SlashCommandsMenuProps> | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component = new ReactRenderer<CommandListInstance, SlashCommandsMenuProps>(SlashCommandsMenu, {
component = new ReactRenderer(SlashCommandsMenu, {
props,
editor: props.editor,
});
@@ -89,8 +91,10 @@ const renderItems = () => {
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
if (component?.ref?.onKeyDown(props)) {
return true;
}

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