Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
733b725b5e | ||
|
|
3bee051ee9 | ||
|
|
6b902d458e |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -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.
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
|
||||
Sign In
|
||||
</Link>
|
||||
now.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
|
||||
title: `Admin user does not exist`,
|
||||
message: () => (
|
||||
<div>
|
||||
Admin user does not exist.
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
|
||||
Sign In
|
||||
</Link>
|
||||
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} />,
|
||||
},
|
||||
];
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
|
||||
Sign In
|
||||
</Link>
|
||||
now.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
|
||||
title: `Admin user does not exist`,
|
||||
message: () => (
|
||||
<div>
|
||||
Admin user does not exist.
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
|
||||
Sign In
|
||||
</Link>
|
||||
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} />,
|
||||
},
|
||||
];
|
||||
@@ -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";
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./instance.helper";
|
||||
export * from "./user.helper";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -5,6 +5,7 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["core/*"],
|
||||
"@/helpers/*": ["helpers/*"],
|
||||
"@/public/*": ["public/*"],
|
||||
"@/plane-admin/*": ["ce/*"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -124,5 +124,3 @@ from .draft import (
|
||||
DraftIssueSerializer,
|
||||
DraftIssueDetailSerializer,
|
||||
)
|
||||
|
||||
from .sticky import StickySerializer
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
|
||||
|
||||
from plane.db.models import Sticky
|
||||
|
||||
|
||||
class StickySerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Sticky
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "owner"]
|
||||
extra_kwargs = {
|
||||
'name': {'required': False},
|
||||
}
|
||||
|
||||
@@ -13,12 +13,10 @@ from .page import urlpatterns as page_urls
|
||||
from .project import urlpatterns as project_urls
|
||||
from .search import urlpatterns as search_urls
|
||||
from .state import urlpatterns as state_urls
|
||||
from .sticky import urlpatterns as sticky_urls
|
||||
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,
|
||||
@@ -35,11 +33,9 @@ urlpatterns = [
|
||||
*project_urls,
|
||||
*search_urls,
|
||||
*state_urls,
|
||||
*sticky_urls,
|
||||
*user_urls,
|
||||
*view_urls,
|
||||
*workspace_urls,
|
||||
*api_urls,
|
||||
*webhook_urls,
|
||||
*timezone_urls,
|
||||
]
|
||||
|
||||
@@ -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>/projects/<uuid:project_id>/entity-search/",
|
||||
SearchEndpoint.as_view(),
|
||||
name="entity-search",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import StickyViewSet
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/stickies/",
|
||||
StickyViewSet.as_view({"get": "list", "post": "create"}),
|
||||
name="workspace-sticky",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/stickies/<uuid:pk>/",
|
||||
StickyViewSet.as_view(
|
||||
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
|
||||
),
|
||||
name="workspace-sticky",
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
]
|
||||
@@ -57,7 +57,6 @@ from .workspace.invite import (
|
||||
WorkspaceJoinEndpoint,
|
||||
UserWorkspaceInvitationsViewSet,
|
||||
)
|
||||
from .workspace.sticky import StickyViewSet
|
||||
from .workspace.label import WorkspaceLabelsEndpoint
|
||||
from .workspace.state import WorkspaceStatesEndpoint
|
||||
from .workspace.user import (
|
||||
@@ -159,7 +158,7 @@ from .page.base import (
|
||||
)
|
||||
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
|
||||
|
||||
@@ -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,7 +21,6 @@ from plane.db.models import (
|
||||
Module,
|
||||
Page,
|
||||
IssueView,
|
||||
ProjectMember,
|
||||
ProjectPage,
|
||||
)
|
||||
|
||||
@@ -249,217 +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, project_id):
|
||||
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))
|
||||
|
||||
response_data = {}
|
||||
|
||||
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 = (
|
||||
ProjectMember.objects.filter(
|
||||
q, is_active=True, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
.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,
|
||||
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,
|
||||
)
|
||||
.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,
|
||||
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)
|
||||
|
||||
else:
|
||||
return Response(
|
||||
{"error": f"Invalid query type: {query_type}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -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)
|
||||
@@ -1,51 +0,0 @@
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
from plane.app.permissions import WorkSpaceBasePermission, ROLE, allow_permission
|
||||
from plane.db.models import Sticky, Workspace
|
||||
from plane.app.serializers import StickySerializer
|
||||
|
||||
|
||||
class StickyViewSet(BaseViewSet):
|
||||
serializer_class = StickySerializer
|
||||
model = Sticky
|
||||
permission_classes = [WorkSpaceBasePermission]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(owner_id=self.request.user.id)
|
||||
.select_related("workspace", "owner")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def create(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = StickySerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def list(self, request, slug):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda stickies: StickySerializer(
|
||||
stickies, many=True
|
||||
).data,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], creator=True, level="WORKSPACE")
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export enum AI_EDITOR_TASKS {
|
||||
ASK_ANYTHING = "ASK_ANYTHING",
|
||||
}
|
||||
@@ -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 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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
@@ -143,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
export type CommandListInstance = {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
};
|
||||
|
||||
type TArgs = {
|
||||
event: KeyboardEvent;
|
||||
sections: {
|
||||
items: any[];
|
||||
}[];
|
||||
selectedIndex: {
|
||||
section: number;
|
||||
item: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const DROPDOWN_NAVIGATION_KEYS = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
|
||||
export const getNextValidIndex = (
|
||||
args: TArgs
|
||||
):
|
||||
| {
|
||||
section: number;
|
||||
item: number;
|
||||
}
|
||||
| undefined => {
|
||||
const { event, sections, selectedIndex } = args;
|
||||
const direction = event.key === "ArrowUp" ? "up" : "down";
|
||||
if (!sections.length) return { section: 0, item: 0 };
|
||||
// next available selection
|
||||
let nextSection = selectedIndex.section;
|
||||
let nextItem = selectedIndex.item;
|
||||
|
||||
if (direction === "up") {
|
||||
nextItem--;
|
||||
if (nextItem < 0) {
|
||||
// Move to previous section
|
||||
nextSection--;
|
||||
if (nextSection < 0) {
|
||||
// Wrap to last section
|
||||
nextSection = sections?.length - 1;
|
||||
}
|
||||
nextItem = sections?.[nextSection]?.items?.length - 1;
|
||||
}
|
||||
} else {
|
||||
nextItem++;
|
||||
if (nextItem >= sections?.[nextSection]?.items?.length) {
|
||||
// Move to next section
|
||||
nextSection++;
|
||||
if (nextSection >= sections?.length) {
|
||||
// Wrap to first section
|
||||
nextSection = 0;
|
||||
}
|
||||
nextItem = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { section: nextSection, item: nextItem };
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { useEditor as useTiptapEditor, Editor, Extensions } from "@tiptap/react";
|
||||
import { useEditor as useTiptapEditor, Editor } from "@tiptap/react";
|
||||
import * as Y from "yjs";
|
||||
// components
|
||||
import { EditorMenuItem, getEditorMenuItems } from "@/components/menus";
|
||||
@@ -19,10 +19,11 @@ import { CoreEditorProps } from "@/props";
|
||||
import type {
|
||||
TDocumentEventsServer,
|
||||
EditorRefApi,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TEditorCommands,
|
||||
TFileHandler,
|
||||
TExtensions,
|
||||
TMentionHandler,
|
||||
} from "@/types";
|
||||
|
||||
export interface CustomEditorProps {
|
||||
@@ -31,13 +32,16 @@ export interface CustomEditorProps {
|
||||
editorProps?: EditorProps;
|
||||
enableHistory: boolean;
|
||||
disabledExtensions: TExtensions[];
|
||||
extensions?: Extensions;
|
||||
extensions?: any;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: MutableRefObject<EditorRefApi | null>;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id?: string;
|
||||
initialValue?: string;
|
||||
mentionHandler: TMentionHandler;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onTransaction?: () => void;
|
||||
autofocus?: boolean;
|
||||
@@ -92,7 +96,10 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
disabledExtensions,
|
||||
enableHistory,
|
||||
fileHandler,
|
||||
mentionHandler,
|
||||
mentionConfig: {
|
||||
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
|
||||
mentionHighlights: mentionHandler.highlights,
|
||||
},
|
||||
placeholder,
|
||||
tabIndex,
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { useEditor as useCustomEditor, Editor, Extensions } from "@tiptap/react";
|
||||
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
||||
import * as Y from "yjs";
|
||||
// extensions
|
||||
import { CoreReadOnlyEditorExtensions } from "@/extensions";
|
||||
@@ -13,22 +13,24 @@ import { CoreReadOnlyEditorProps } from "@/props";
|
||||
// types
|
||||
import type {
|
||||
EditorReadOnlyRefApi,
|
||||
IMentionHighlight,
|
||||
TExtensions,
|
||||
TDocumentEventsServer,
|
||||
TFileHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
} from "@/types";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
disabledExtensions: TExtensions[];
|
||||
editorClassName: string;
|
||||
editorProps?: EditorProps;
|
||||
extensions?: Extensions;
|
||||
extensions?: any;
|
||||
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
initialValue?: string;
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
provider?: HocuspocusProvider;
|
||||
}
|
||||
|
||||
@@ -61,7 +63,9 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
extensions: [
|
||||
...CoreReadOnlyEditorExtensions({
|
||||
disabledExtensions,
|
||||
mentionHandler,
|
||||
mentionConfig: {
|
||||
mentionHighlights: mentionHandler.highlights,
|
||||
},
|
||||
fileHandler,
|
||||
}),
|
||||
...extensions,
|
||||
|
||||
@@ -6,10 +6,10 @@ import { TEmbedConfig } from "@/plane-editor/types";
|
||||
import {
|
||||
EditorReadOnlyRefApi,
|
||||
EditorRefApi,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
TMentionHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
TRealtimeConfig,
|
||||
TUserDetails,
|
||||
} from "@/types";
|
||||
@@ -27,6 +27,10 @@ type TCollaborativeEditorHookProps = {
|
||||
extensions?: Extensions;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
serverHandler?: TServerHandler;
|
||||
user: TUserDetails;
|
||||
@@ -37,7 +41,6 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
embedHandler?: TEmbedConfig;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
mentionHandler: TMentionHandler;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
};
|
||||
@@ -45,5 +48,4 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Extensions, JSONContent } from "@tiptap/core";
|
||||
import { JSONContent } from "@tiptap/core";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
// helpers
|
||||
import { IMarking } from "@/helpers/scroll-to-node";
|
||||
// types
|
||||
import {
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TAIHandler,
|
||||
TDisplayConfig,
|
||||
TDocumentEventEmitter,
|
||||
@@ -11,8 +13,6 @@ import {
|
||||
TEmbedConfig,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
TMentionHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
TServerHandler,
|
||||
} from "@/types";
|
||||
import { TTextAlign } from "@/extensions";
|
||||
@@ -114,7 +114,10 @@ export interface IEditorProps {
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
id: string;
|
||||
initialValue: string;
|
||||
mentionHandler: TMentionHandler;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onTransaction?: () => void;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
@@ -125,10 +128,10 @@ export interface IEditorProps {
|
||||
value?: string | null;
|
||||
}
|
||||
export interface ILiteTextEditor extends IEditorProps {
|
||||
extensions?: Extensions;
|
||||
extensions?: any[];
|
||||
}
|
||||
export interface IRichTextEditor extends IEditorProps {
|
||||
extensions?: Extensions;
|
||||
extensions?: any[];
|
||||
bubbleMenuEnabled?: boolean;
|
||||
dragDropEnabled?: boolean;
|
||||
}
|
||||
@@ -155,7 +158,9 @@ export interface IReadOnlyEditorProps {
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
id: string;
|
||||
initialValue: string;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
}
|
||||
|
||||
export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps;
|
||||
|
||||
@@ -5,7 +5,7 @@ export * from "./editor";
|
||||
export * from "./embed";
|
||||
export * from "./extensions";
|
||||
export * from "./image";
|
||||
export * from "./mention";
|
||||
export * from "./mention-suggestion";
|
||||
export * from "./slash-commands-suggestion";
|
||||
export * from "@/plane-editor/types";
|
||||
export * from "./document-collaborative-events";
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export type IMentionSuggestion = {
|
||||
id: string;
|
||||
type: string;
|
||||
entity_name: string;
|
||||
entity_identifier: string;
|
||||
avatar: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
redirect_uri: string;
|
||||
};
|
||||
|
||||
export type IMentionHighlight = string;
|
||||
@@ -1,27 +0,0 @@
|
||||
// plane types
|
||||
import { TSearchEntities } from "@plane/types";
|
||||
|
||||
export type TMentionSuggestion = {
|
||||
entity_identifier: string;
|
||||
entity_name: TSearchEntities;
|
||||
icon: React.ReactNode;
|
||||
id: string;
|
||||
subTitle?: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type TMentionSection = {
|
||||
key: string;
|
||||
title?: string;
|
||||
items: TMentionSuggestion[];
|
||||
};
|
||||
|
||||
export type TMentionComponentProps = Pick<TMentionSuggestion, "entity_identifier" | "entity_name">;
|
||||
|
||||
export type TReadOnlyMentionHandler = {
|
||||
renderComponent: (props: TMentionComponentProps) => React.ReactNode;
|
||||
};
|
||||
|
||||
export type TMentionHandler = TReadOnlyMentionHandler & {
|
||||
searchCallback?: (query: string) => Promise<TMentionSection[]>;
|
||||
};
|
||||
@@ -44,30 +44,6 @@ module.exports = {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-useless-empty-export": "error",
|
||||
"@typescript-eslint/prefer-ts-expect-error": "warn",
|
||||
"import/order": [
|
||||
"warn",
|
||||
{
|
||||
groups: ["builtin", "external", "internal", "parent", "sibling"],
|
||||
pathGroups: [
|
||||
{
|
||||
pattern: "@plane/**",
|
||||
group: "external",
|
||||
position: "after",
|
||||
},
|
||||
{
|
||||
pattern: "@/**",
|
||||
group: "internal",
|
||||
position: "before",
|
||||
},
|
||||
],
|
||||
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
|
||||
alphabetize: {
|
||||
order: "asc",
|
||||
caseInsensitive: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
ignorePatterns: [".*.js", "node_modules/", "dist/"],
|
||||
};
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
build/*
|
||||
dist/*
|
||||
out/*
|
||||
@@ -1,9 +0,0 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/library.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "@plane/services",
|
||||
"version": "0.24.1",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plane/constants": "*",
|
||||
"axios": "^1.4.0"
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// plane web constants
|
||||
import { AI_EDITOR_TASKS, API_BASE_URL } from "@plane/constants";
|
||||
// services
|
||||
import { APIService } from "@/api.service";
|
||||
|
||||
/**
|
||||
* Payload type for AI editor tasks
|
||||
* @typedef {Object} TTaskPayload
|
||||
* @property {number} [casual_score] - Optional score for casual tone analysis
|
||||
* @property {number} [formal_score] - Optional score for formal tone analysis
|
||||
* @property {AI_EDITOR_TASKS} task - Type of AI editor task to perform
|
||||
* @property {string} text_input - The input text to be processed
|
||||
*/
|
||||
export type TTaskPayload = {
|
||||
casual_score?: number;
|
||||
formal_score?: number;
|
||||
task: AI_EDITOR_TASKS;
|
||||
text_input: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Service class for handling AI-related API operations
|
||||
* Extends the base APIService class to interact with AI endpoints
|
||||
* @extends {APIService}
|
||||
*/
|
||||
export class AIService extends APIService {
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a GPT-based task for a specific workspace
|
||||
* @param {string} workspaceSlug - The unique identifier for the workspace
|
||||
* @param {Object} data - The data payload for the GPT task
|
||||
* @param {string} data.prompt - The prompt text for the GPT model
|
||||
* @param {string} data.task - The type of task to be performed
|
||||
* @returns {Promise<any>} The response data from the GPT task
|
||||
* @throws {Error} Throws the response error if the request fails
|
||||
*/
|
||||
async prompt(workspaceSlug: string, data: { prompt: string; task: string }): Promise<any> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/ai-assistant/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an editor-specific AI task for text processing
|
||||
* @param {string} workspaceSlug - The unique identifier for the workspace
|
||||
* @param {TTaskPayload} data - The task payload containing text and processing parameters
|
||||
* @returns {Promise<{response: string}>} The processed text response
|
||||
* @throws {Error} Throws the response data if the request fails
|
||||
*/
|
||||
async rephraseGrammar(
|
||||
workspaceSlug: string,
|
||||
data: TTaskPayload
|
||||
): Promise<{
|
||||
response: string;
|
||||
}> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/rephrase-grammar/`, data)
|
||||
.then((res) => res?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./ai.service";
|
||||
@@ -1,93 +0,0 @@
|
||||
// constants
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
// types
|
||||
import {
|
||||
IAnalyticsParams,
|
||||
IAnalyticsResponse,
|
||||
IDefaultAnalyticsResponse,
|
||||
IExportAnalyticsFormData,
|
||||
ISaveAnalyticsFormData,
|
||||
} from "@plane/types";
|
||||
// services
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
export class AnalyticsService extends APIService {
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves analytics data for a specific workspace
|
||||
* @param {string} workspaceSlug - The unique identifier for the workspace
|
||||
* @param {IAnalyticsParams} params - Parameters for filtering analytics data
|
||||
* @param {string|number} [params.project] - Optional project identifier that will be converted to string
|
||||
* @returns {Promise<IAnalyticsResponse>} The analytics data for the workspace
|
||||
* @throws {Error} Throws response data if the request fails
|
||||
*/
|
||||
async getAnalytics(workspaceSlug: string, params: IAnalyticsParams): Promise<IAnalyticsResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/analytics/`, {
|
||||
params: {
|
||||
...params,
|
||||
project: params?.project ? params.project.toString() : null,
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves default analytics data for a workspace
|
||||
* @param {string} workspaceSlug - The unique identifier for the workspace
|
||||
* @param {Partial<IAnalyticsParams>} [params] - Optional parameters for filtering default analytics
|
||||
* @param {string|number} [params.project] - Optional project identifier that will be converted to string
|
||||
* @returns {Promise<IDefaultAnalyticsResponse>} The default analytics data
|
||||
* @throws {Error} Throws response data if the request fails
|
||||
*/
|
||||
async getDefaultAnalytics(
|
||||
workspaceSlug: string,
|
||||
params?: Partial<IAnalyticsParams>
|
||||
): Promise<IDefaultAnalyticsResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/default-analytics/`, {
|
||||
params: {
|
||||
...params,
|
||||
project: params?.project ? params.project.toString() : null,
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves analytics view configuration for a workspace
|
||||
* @param {string} workspaceSlug - The unique identifier for the workspace
|
||||
* @param {ISaveAnalyticsFormData} data - The analytics configuration data to save
|
||||
* @returns {Promise<any>} The response from saving the analytics view
|
||||
* @throws {Error} Throws response data if the request fails
|
||||
*/
|
||||
async save(workspaceSlug: string, data: ISaveAnalyticsFormData): Promise<any> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/analytic-view/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports analytics data for a workspace
|
||||
* @param {string} workspaceSlug - The unique identifier for the workspace
|
||||
* @param {IExportAnalyticsFormData} data - Configuration for the analytics export
|
||||
* @returns {Promise<any>} The exported analytics data
|
||||
* @throws {Error} Throws response data if the request fails
|
||||
*/
|
||||
async export(workspaceSlug: string, data: IExportAnalyticsFormData): Promise<any> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/export-analytics/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./analytics.service";
|
||||
@@ -1,110 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
|
||||
|
||||
/**
|
||||
* Abstract base class for making HTTP requests using axios
|
||||
* @abstract
|
||||
*/
|
||||
export abstract class APIService {
|
||||
protected baseURL: string;
|
||||
private axiosInstance: AxiosInstance;
|
||||
|
||||
/**
|
||||
* Creates an instance of APIService
|
||||
* @param {string} baseURL - The base URL for all HTTP requests
|
||||
*/
|
||||
constructor(baseURL: string) {
|
||||
this.baseURL = baseURL;
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up axios interceptors for handling responses
|
||||
* Currently handles 401 unauthorized responses by redirecting to login
|
||||
* @private
|
||||
*/
|
||||
private setupInterceptors() {
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.replace(`/${currentPath ? `?next_path=${currentPath}` : ``}`);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a GET request to the specified URL
|
||||
* @param {string} url - The endpoint URL
|
||||
* @param {object} [params={}] - URL parameters
|
||||
* @param {AxiosRequestConfig} [config={}] - Additional axios configuration
|
||||
* @returns {Promise} Axios response promise
|
||||
*/
|
||||
get(url: string, params = {}, config: AxiosRequestConfig = {}) {
|
||||
return this.axiosInstance.get(url, {
|
||||
...params,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a POST request to the specified URL
|
||||
* @param {string} url - The endpoint URL
|
||||
* @param {object} [data={}] - Request body data
|
||||
* @param {AxiosRequestConfig} [config={}] - Additional axios configuration
|
||||
* @returns {Promise} Axios response promise
|
||||
*/
|
||||
post(url: string, data = {}, config: AxiosRequestConfig = {}) {
|
||||
return this.axiosInstance.post(url, data, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a PUT request to the specified URL
|
||||
* @param {string} url - The endpoint URL
|
||||
* @param {object} [data={}] - Request body data
|
||||
* @param {AxiosRequestConfig} [config={}] - Additional axios configuration
|
||||
* @returns {Promise} Axios response promise
|
||||
*/
|
||||
put(url: string, data = {}, config: AxiosRequestConfig = {}) {
|
||||
return this.axiosInstance.put(url, data, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a PATCH request to the specified URL
|
||||
* @param {string} url - The endpoint URL
|
||||
* @param {object} [data={}] - Request body data
|
||||
* @param {AxiosRequestConfig} [config={}] - Additional axios configuration
|
||||
* @returns {Promise} Axios response promise
|
||||
*/
|
||||
patch(url: string, data = {}, config: AxiosRequestConfig = {}) {
|
||||
return this.axiosInstance.patch(url, data, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a DELETE request to the specified URL
|
||||
* @param {string} url - The endpoint URL
|
||||
* @param {any} [data] - Request body data
|
||||
* @param {AxiosRequestConfig} [config={}] - Additional axios configuration
|
||||
* @returns {Promise} Axios response promise
|
||||
*/
|
||||
delete(url: string, data?: any, config: AxiosRequestConfig = {}) {
|
||||
return this.axiosInstance.delete(url, { data, ...config });
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a custom request with the provided configuration
|
||||
* @param {object} [config={}] - Axios request configuration
|
||||
* @returns {Promise} Axios response promise
|
||||
*/
|
||||
request(config = {}) {
|
||||
return this.axiosInstance(config);
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
// types
|
||||
import { ICsrfTokenData, IEmailCheckData, IEmailCheckResponse } from "@plane/types";
|
||||
// services
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
/**
|
||||
* Service class for handling authentication-related operations
|
||||
* Provides methods for user authentication, password management, and session handling
|
||||
* @extends {APIService}
|
||||
*/
|
||||
export default class AuthService extends APIService {
|
||||
/**
|
||||
* Creates an instance of AuthService
|
||||
* Initializes with the base API URL
|
||||
*/
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a CSRF token for form submission security
|
||||
* @returns {Promise<ICsrfTokenData>} Object containing the CSRF token
|
||||
* @throws {Error} Throws the complete error object if the request fails
|
||||
*/
|
||||
async requestCSRFToken(): Promise<ICsrfTokenData> {
|
||||
return this.get("/auth/get-csrf-token/")
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an email exists in the system
|
||||
* @param {IEmailCheckData} data - Email data to verify
|
||||
* @returns {Promise<IEmailCheckResponse>} Response indicating email status
|
||||
* @throws {Error} Throws response data if the request fails
|
||||
*/
|
||||
async emailCheck(data: IEmailCheckData): Promise<IEmailCheckResponse> {
|
||||
return this.post("/auth/email-check/", data, { headers: {} })
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a password reset link to the specified email address
|
||||
* @param {{ email: string }} data - Object containing the email address
|
||||
* @returns {Promise<any>} Response from the password reset request
|
||||
* @throws {Error} Throws response object if the request fails
|
||||
*/
|
||||
async sendResetPasswordLink(data: { email: string }): Promise<any> {
|
||||
return this.post(`/auth/forgot-password/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a new password using a reset token
|
||||
* @param {string} token - CSRF token for form submission security
|
||||
* @param {{ password: string }} data - Object containing the new password
|
||||
* @returns {Promise<any>} Response from the password update request
|
||||
* @throws {Error} Throws response data if the request fails
|
||||
*/
|
||||
async setPassword(token: string, data: { password: string }): Promise<any> {
|
||||
return this.post(`/auth/set-password/`, data, {
|
||||
headers: {
|
||||
"X-CSRFTOKEN": token,
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique code for magic link authentication
|
||||
* @param {{ email: string }} data - Object containing the email address
|
||||
* @returns {Promise<any>} Response containing the generated unique code
|
||||
* @throws {Error} Throws response data if the request fails
|
||||
*/
|
||||
async generateUniqueCode(data: { email: string }): Promise<any> {
|
||||
return this.post("/auth/magic-generate/", data, { headers: {} })
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs user sign out by submitting a form with CSRF token
|
||||
* Creates and submits a form dynamically to handle the sign-out process
|
||||
* @param {string} baseUrl - Base URL for the sign-out endpoint
|
||||
* @returns {Promise<any>} Resolves when sign-out is complete
|
||||
* @throws {Error} Throws error if CSRF token is not found
|
||||
*/
|
||||
async signOut(baseUrl: string): Promise<any> {
|
||||
await this.requestCSRFToken().then((data) => {
|
||||
const csrfToken = data?.csrf_token;
|
||||
|
||||
if (!csrfToken) throw Error("CSRF token not found");
|
||||
|
||||
const form = document.createElement("form");
|
||||
const element1 = document.createElement("input");
|
||||
|
||||
form.method = "POST";
|
||||
form.action = `${baseUrl}/auth/sign-out/`;
|
||||
|
||||
element1.value = csrfToken;
|
||||
element1.name = "csrfmiddlewaretoken";
|
||||
element1.type = "hidden";
|
||||
form.appendChild(element1);
|
||||
|
||||
document.body.appendChild(form);
|
||||
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./auth.service";
|
||||
@@ -1,78 +0,0 @@
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import type { TCycleDistribution, TProgressSnapshot, TCycleEstimateDistribution } from "@plane/types";
|
||||
import { APIService } from "@/api.service";
|
||||
|
||||
/**
|
||||
* Service class for managing cycles within a workspace and project context.
|
||||
* Extends APIService to handle HTTP requests to the cycle-related endpoints.
|
||||
* @extends {APIService}
|
||||
*/
|
||||
export class CycleAnalyticsService extends APIService {
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves analytics for active cycles in a workspace.
|
||||
* @param {string} workspaceSlug - The workspace identifier
|
||||
* @param {string} projectId - The project identifier
|
||||
* @param {string} cycleId - The cycle identifier
|
||||
* @param {string} [analytic_type="points"] - The type of analytics to retrieve
|
||||
* @returns {Promise<TCycleDistribution | TCycleEstimateDistribution>} The cycle analytics data
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async workspaceActiveCyclesAnalytics(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string,
|
||||
analytic_type: string = "points"
|
||||
): Promise<TCycleDistribution | TCycleEstimateDistribution> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/analytics?type=${analytic_type}`
|
||||
)
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves progress data for active cycles.
|
||||
* @param {string} workspaceSlug - The workspace identifier
|
||||
* @param {string} projectId - The project identifier
|
||||
* @param {string} cycleId - The cycle identifier
|
||||
* @returns {Promise<TProgressSnapshot>} The cycle progress data
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async workspaceActiveCyclesProgress(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string
|
||||
): Promise<TProgressSnapshot> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/progress/`)
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves advanced progress data for active cycles (Pro feature).
|
||||
* @param {string} workspaceSlug - The workspace identifier
|
||||
* @param {string} projectId - The project identifier
|
||||
* @param {string} cycleId - The cycle identifier
|
||||
* @returns {Promise<TProgressSnapshot>} The detailed cycle progress data
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async workspaceActiveCyclesProgressPro(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string
|
||||
): Promise<TProgressSnapshot> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-progress/`)
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user