feat: init
This commit is contained in:
39
apps/web/core/components/account/auth-forms/auth-banner.tsx
Normal file
39
apps/web/core/components/account/auth-forms/auth-banner.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { FC } from "react";
|
||||
import { Info, X } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// helpers
|
||||
import type { TAuthErrorInfo } from "@/helpers/authentication.helper";
|
||||
|
||||
type TAuthBanner = {
|
||||
bannerData: TAuthErrorInfo | undefined;
|
||||
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
|
||||
};
|
||||
|
||||
export const AuthBanner: FC<TAuthBanner> = (props) => {
|
||||
const { bannerData, handleBannerData } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!bannerData) return <></>;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="relative flex items-center p-2 rounded-md gap-2 border border-custom-primary-100/50 bg-custom-primary-100/10"
|
||||
>
|
||||
<div className="size-4 flex-shrink-0 grid place-items-center">
|
||||
<Info size={16} className="text-custom-primary-100" />
|
||||
</div>
|
||||
<p className="w-full text-sm font-medium text-custom-primary-100">{bannerData?.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="relative ml-auto size-6 rounded-sm grid place-items-center transition-all hover:bg-custom-primary-100/20 text-custom-primary-100/80"
|
||||
onClick={() => handleBannerData?.(undefined)}
|
||||
aria-label={t("aria_labels.auth_forms.close_alert")}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
112
apps/web/core/components/account/auth-forms/auth-header.tsx
Normal file
112
apps/web/core/components/account/auth-forms/auth-header.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IWorkspaceMemberInvitation } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { WorkspaceLogo } from "@/components/workspace/logo";
|
||||
// helpers
|
||||
import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper";
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// services
|
||||
|
||||
type TAuthHeader = {
|
||||
workspaceSlug: string | undefined;
|
||||
invitationId: string | undefined;
|
||||
invitationEmail: string | undefined;
|
||||
authMode: EAuthModes;
|
||||
currentAuthStep: EAuthSteps;
|
||||
};
|
||||
|
||||
const Titles = {
|
||||
[EAuthModes.SIGN_IN]: {
|
||||
[EAuthSteps.EMAIL]: {
|
||||
header: "Work in all dimensions.",
|
||||
subHeader: "Welcome back to Plane.",
|
||||
},
|
||||
[EAuthSteps.PASSWORD]: {
|
||||
header: "Work in all dimensions.",
|
||||
subHeader: "Welcome back to Plane.",
|
||||
},
|
||||
[EAuthSteps.UNIQUE_CODE]: {
|
||||
header: "Work in all dimensions.",
|
||||
subHeader: "Welcome back to Plane.",
|
||||
},
|
||||
},
|
||||
[EAuthModes.SIGN_UP]: {
|
||||
[EAuthSteps.EMAIL]: {
|
||||
header: "Work in all dimensions.",
|
||||
subHeader: "Create your Plane account.",
|
||||
},
|
||||
[EAuthSteps.PASSWORD]: {
|
||||
header: "Work in all dimensions.",
|
||||
subHeader: "Create your Plane account.",
|
||||
},
|
||||
[EAuthSteps.UNIQUE_CODE]: {
|
||||
header: "Work in all dimensions.",
|
||||
subHeader: "Create your Plane account.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const workSpaceService = new WorkspaceService();
|
||||
|
||||
export const AuthHeader: FC<TAuthHeader> = observer((props) => {
|
||||
const { workspaceSlug, invitationId, invitationEmail, authMode, currentAuthStep } = props;
|
||||
// plane imports
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: invitation, isLoading } = useSWR(
|
||||
workspaceSlug && invitationId ? `WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}` : null,
|
||||
async () => workspaceSlug && invitationId && workSpaceService.getWorkspaceInvitation(workspaceSlug, invitationId),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
);
|
||||
|
||||
const getHeaderSubHeader = (
|
||||
step: EAuthSteps,
|
||||
mode: EAuthModes,
|
||||
invitation: IWorkspaceMemberInvitation | undefined,
|
||||
email: string | undefined
|
||||
) => {
|
||||
if (invitation && email && invitation.email === email && invitation.workspace) {
|
||||
const workspace = invitation.workspace;
|
||||
return {
|
||||
header: (
|
||||
<div className="relative inline-flex items-center gap-2">
|
||||
{t("common.join")}{" "}
|
||||
<WorkspaceLogo logo={workspace?.logo_url} name={workspace?.name} classNames="size-9 flex-shrink-0" />{" "}
|
||||
{workspace.name}
|
||||
</div>
|
||||
),
|
||||
subHeader:
|
||||
mode == EAuthModes.SIGN_UP
|
||||
? "Create an account to start managing work with your team."
|
||||
: "Log in to start managing work with your team.",
|
||||
};
|
||||
}
|
||||
|
||||
return Titles[mode][step];
|
||||
};
|
||||
|
||||
const { header, subHeader } = getHeaderSubHeader(currentAuthStep, authMode, invitation || undefined, invitationEmail);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-2xl font-semibold text-custom-text-100 leading-7">
|
||||
{typeof header === "string" ? t(header) : header}
|
||||
</span>
|
||||
<span className="text-2xl font-semibold text-custom-text-400 leading-7">{subHeader}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
178
apps/web/core/components/account/auth-forms/auth-root.tsx
Normal file
178
apps/web/core/components/account/auth-forms/auth-root.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { OAuthOptions } from "@plane/ui";
|
||||
// assets
|
||||
import GithubLightLogo from "/public/logos/github-black.png";
|
||||
import GithubDarkLogo from "/public/logos/github-dark.svg";
|
||||
import GitlabLogo from "/public/logos/gitlab-logo.svg";
|
||||
import GoogleLogo from "/public/logos/google-logo.svg";
|
||||
// helpers
|
||||
import type { TAuthErrorInfo } from "@/helpers/authentication.helper";
|
||||
import {
|
||||
EAuthModes,
|
||||
EAuthSteps,
|
||||
EAuthenticationErrorCodes,
|
||||
EErrorAlertType,
|
||||
authErrorHandler,
|
||||
} from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
// local imports
|
||||
import { TermsAndConditions } from "../terms-and-conditions";
|
||||
import { AuthBanner } from "./auth-banner";
|
||||
import { AuthHeader } from "./auth-header";
|
||||
import { AuthFormRoot } from "./form-root";
|
||||
|
||||
type TAuthRoot = {
|
||||
authMode: EAuthModes;
|
||||
};
|
||||
|
||||
export const AuthRoot: FC<TAuthRoot> = observer((props) => {
|
||||
//router
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const emailParam = searchParams.get("email");
|
||||
const invitation_id = searchParams.get("invitation_id");
|
||||
const workspaceSlug = searchParams.get("slug");
|
||||
const error_code = searchParams.get("error_code");
|
||||
const next_path = searchParams.get("next_path");
|
||||
const { resolvedTheme } = useTheme();
|
||||
// props
|
||||
const { authMode: currentAuthMode } = props;
|
||||
// states
|
||||
const [authMode, setAuthMode] = useState<EAuthModes | undefined>(undefined);
|
||||
const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL);
|
||||
const [email, setEmail] = useState(emailParam ? emailParam.toString() : "");
|
||||
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
|
||||
|
||||
// hooks
|
||||
const { config } = useInstance();
|
||||
|
||||
// derived values
|
||||
const isOAuthEnabled =
|
||||
(config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!authMode && currentAuthMode) setAuthMode(currentAuthMode);
|
||||
}, [currentAuthMode, authMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error_code && authMode) {
|
||||
const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes);
|
||||
if (errorhandler) {
|
||||
// password error handler
|
||||
if ([EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP].includes(errorhandler.code)) {
|
||||
setAuthMode(EAuthModes.SIGN_UP);
|
||||
setAuthStep(EAuthSteps.PASSWORD);
|
||||
}
|
||||
if ([EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN].includes(errorhandler.code)) {
|
||||
setAuthMode(EAuthModes.SIGN_IN);
|
||||
setAuthStep(EAuthSteps.PASSWORD);
|
||||
}
|
||||
// magic_code error handler
|
||||
if (
|
||||
[
|
||||
EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_UP,
|
||||
EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP,
|
||||
EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP,
|
||||
EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP,
|
||||
].includes(errorhandler.code)
|
||||
) {
|
||||
setAuthMode(EAuthModes.SIGN_UP);
|
||||
setAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
}
|
||||
if (
|
||||
[
|
||||
EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_IN,
|
||||
EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN,
|
||||
EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN,
|
||||
EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN,
|
||||
].includes(errorhandler.code)
|
||||
) {
|
||||
setAuthMode(EAuthModes.SIGN_IN);
|
||||
setAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
}
|
||||
|
||||
setErrorInfo(errorhandler);
|
||||
}
|
||||
}
|
||||
}, [error_code, authMode]);
|
||||
|
||||
if (!authMode) return <></>;
|
||||
|
||||
const OauthButtonContent = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in";
|
||||
|
||||
const OAuthConfig = [
|
||||
{
|
||||
id: "google",
|
||||
text: `${OauthButtonContent} with Google`,
|
||||
icon: <Image src={GoogleLogo} height={18} width={18} alt="Google Logo" />,
|
||||
onClick: () => {
|
||||
window.location.assign(`${API_BASE_URL}/auth/google/${next_path ? `?next_path=${next_path}` : ``}`);
|
||||
},
|
||||
enabled: config?.is_google_enabled,
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
text: `${OauthButtonContent} with GitHub`,
|
||||
icon: (
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? GithubDarkLogo : GithubLightLogo}
|
||||
height={18}
|
||||
width={18}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
),
|
||||
onClick: () => {
|
||||
window.location.assign(`${API_BASE_URL}/auth/github/${next_path ? `?next_path=${next_path}` : ``}`);
|
||||
},
|
||||
enabled: config?.is_github_enabled,
|
||||
},
|
||||
{
|
||||
id: "gitlab",
|
||||
text: `${OauthButtonContent} with GitLab`,
|
||||
icon: <Image src={GitlabLogo} height={18} width={18} alt="GitLab Logo" />,
|
||||
onClick: () => {
|
||||
window.location.assign(`${API_BASE_URL}/auth/gitlab/${next_path ? `?next_path=${next_path}` : ``}`);
|
||||
},
|
||||
enabled: config?.is_gitlab_enabled,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
|
||||
<AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />
|
||||
)}
|
||||
<AuthHeader
|
||||
workspaceSlug={workspaceSlug?.toString() || undefined}
|
||||
invitationId={invitation_id?.toString() || undefined}
|
||||
invitationEmail={email || undefined}
|
||||
authMode={authMode}
|
||||
currentAuthStep={authStep}
|
||||
/>
|
||||
|
||||
{isOAuthEnabled && <OAuthOptions options={OAuthConfig} compact={authStep === EAuthSteps.PASSWORD} />}
|
||||
|
||||
<AuthFormRoot
|
||||
authStep={authStep}
|
||||
authMode={authMode}
|
||||
email={email}
|
||||
setEmail={(email) => setEmail(email)}
|
||||
setAuthMode={(authMode) => setAuthMode(authMode)}
|
||||
setAuthStep={(authStep) => setAuthStep(authStep)}
|
||||
setErrorInfo={(errorInfo) => setErrorInfo(errorInfo)}
|
||||
currentAuthMode={currentAuthMode}
|
||||
/>
|
||||
<TermsAndConditions authType={authMode} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
export const FormContainer = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
export const AuthFormHeader = ({ title, description }: { title: string; description: string }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-2xl font-semibold text-custom-text-100">{title}</span>
|
||||
<span className="text-2xl font-semibold text-custom-text-400">{description}</span>
|
||||
</div>
|
||||
);
|
||||
104
apps/web/core/components/account/auth-forms/email.tsx
Normal file
104
apps/web/core/components/account/auth-forms/email.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, FormEvent } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { CircleAlert, XCircle } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { IEmailCheckData } from "@plane/types";
|
||||
import { Input, Spinner } from "@plane/ui";
|
||||
import { cn, checkEmailValidity } from "@plane/utils";
|
||||
// helpers
|
||||
type TAuthEmailForm = {
|
||||
defaultEmail: string;
|
||||
onSubmit: (data: IEmailCheckData) => Promise<void>;
|
||||
};
|
||||
|
||||
export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
const { onSubmit, defaultEmail } = props;
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [email, setEmail] = useState(defaultEmail);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
const emailError = useMemo(
|
||||
() => (email && !checkEmailValidity(email) ? { email: "auth.common.email.errors.invalid" } : undefined),
|
||||
[email]
|
||||
);
|
||||
|
||||
const handleFormSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
const payload: IEmailCheckData = {
|
||||
email: email,
|
||||
};
|
||||
await onSubmit(payload);
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting;
|
||||
|
||||
const [isFocused, setIsFocused] = useState(true);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="email" className="text-sm text-custom-text-300 font-medium">
|
||||
{t("auth.common.email.label")}
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
`relative flex items-center rounded-md bg-custom-background-100 border`,
|
||||
!isFocused && Boolean(emailError?.email) ? `border-red-500` : `border-custom-border-300`
|
||||
)}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t("auth.common.email.placeholder")}
|
||||
className={`disable-autofill-style h-10 w-full placeholder:text-custom-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
/>
|
||||
{email.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmail("");
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="absolute right-3 size-5 grid place-items-center"
|
||||
aria-label={t("aria_labels.auth_forms.clear_email")}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<XCircle className="size-5 stroke-custom-text-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{emailError?.email && !isFocused && (
|
||||
<p className="flex items-center gap-1 text-xs text-red-600 px-0.5">
|
||||
<CircleAlert height={12} width={12} />
|
||||
{t(emailError.email)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : t("common.continue")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { X } from "lucide-react";
|
||||
import { Popover } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
|
||||
export const ForgotPasswordPopover = () => {
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "right-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<Popover.Button as={Fragment}>
|
||||
<button
|
||||
type="button"
|
||||
ref={setReferenceElement}
|
||||
className="text-xs font-medium text-custom-primary-100 outline-none"
|
||||
>
|
||||
{t("auth.common.forgot_password")}
|
||||
</button>
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="fixed z-10">
|
||||
{({ close }) => (
|
||||
<div
|
||||
className="border border-custom-border-300 bg-custom-background-100 rounded z-10 py-1 px-2 w-64 break-words flex items-start gap-3 text-left ml-3"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<span className="flex-shrink-0">🤥</span>
|
||||
<p className="text-xs">{t("auth.forgot_password.errors.smtp_not_enabled")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-3 grid place-items-center"
|
||||
onClick={() => close()}
|
||||
aria-label={t("aria_labels.auth_forms.close_popover")}
|
||||
>
|
||||
<X className="size-3 text-custom-text-200" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
148
apps/web/core/components/account/auth-forms/forgot-password.tsx
Normal file
148
apps/web/core/components/account/auth-forms/forgot-password.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// icons
|
||||
import { CircleCheck } from "lucide-react";
|
||||
// plane imports
|
||||
import { AUTH_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Input } from "@plane/ui";
|
||||
import { cn, checkEmailValidity } from "@plane/utils";
|
||||
// helpers
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import useTimer from "@/hooks/use-timer";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
// local components
|
||||
import { FormContainer } from "./common/container";
|
||||
import { AuthFormHeader } from "./common/header";
|
||||
|
||||
type TForgotPasswordFormValues = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const defaultValues: TForgotPasswordFormValues = {
|
||||
email: "",
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
export const ForgotPasswordForm = observer(() => {
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get("email");
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// timer
|
||||
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0);
|
||||
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
handleSubmit,
|
||||
} = useForm<TForgotPasswordFormValues>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
email: email?.toString() ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleForgotPassword = async (formData: TForgotPasswordFormValues) => {
|
||||
await authService
|
||||
.sendResetPasswordLink({
|
||||
email: formData.email,
|
||||
})
|
||||
.then(() => {
|
||||
captureSuccess({
|
||||
eventName: AUTH_TRACKER_EVENTS.forgot_password,
|
||||
payload: {
|
||||
email: formData.email,
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("auth.forgot_password.toast.success.title"),
|
||||
message: t("auth.forgot_password.toast.success.message"),
|
||||
});
|
||||
setResendCodeTimer(30);
|
||||
})
|
||||
.catch((err) => {
|
||||
captureError({
|
||||
eventName: AUTH_TRACKER_EVENTS.forgot_password,
|
||||
payload: {
|
||||
email: formData.email,
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("auth.forgot_password.toast.error.title"),
|
||||
message: err?.error ?? t("auth.forgot_password.toast.error.message"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FormContainer>
|
||||
<AuthFormHeader title="Reset password" description="Regain access to your account." />
|
||||
<form onSubmit={handleSubmit(handleForgotPassword)} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-custom-text-300" htmlFor="email">
|
||||
{t("auth.common.email.label")}
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
rules={{
|
||||
required: t("auth.common.email.errors.required"),
|
||||
validate: (value) => checkEmailValidity(value) || t("auth.common.email.errors.invalid"),
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.email)}
|
||||
placeholder={t("auth.common.email.placeholder")}
|
||||
className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
autoComplete="on"
|
||||
disabled={resendTimerCode > 0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{resendTimerCode > 0 && (
|
||||
<p className="flex items-start w-full gap-1 px-1 text-xs font-medium text-green-700">
|
||||
<CircleCheck height={12} width={12} className="mt-0.5" />
|
||||
{t("auth.forgot_password.email_sent")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting || resendTimerCode > 0}
|
||||
>
|
||||
{resendTimerCode > 0
|
||||
? t("auth.common.resend_in", { seconds: resendTimerCode })
|
||||
: t("auth.forgot_password.send_reset_link")}
|
||||
</Button>
|
||||
<Link href="/" className={cn("w-full", getButtonStyling("link-neutral", "lg"))}>
|
||||
{t("auth.common.back_to_sign_in")}
|
||||
</Link>
|
||||
</form>
|
||||
</FormContainer>
|
||||
);
|
||||
});
|
||||
134
apps/web/core/components/account/auth-forms/form-root.tsx
Normal file
134
apps/web/core/components/account/auth-forms/form-root.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { EAuthModes, EAuthSteps } from "@plane/constants";
|
||||
import type { IEmailCheckData } from "@plane/types";
|
||||
// helpers
|
||||
import type { TAuthErrorInfo } from "@/helpers/authentication.helper";
|
||||
import { authErrorHandler } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
// local components
|
||||
import { AuthEmailForm } from "./email";
|
||||
import { AuthPasswordForm } from "./password";
|
||||
import { AuthUniqueCodeForm } from "./unique-code";
|
||||
|
||||
type TAuthFormRoot = {
|
||||
authStep: EAuthSteps;
|
||||
authMode: EAuthModes;
|
||||
email: string;
|
||||
setEmail: (email: string) => void;
|
||||
setAuthMode: (authMode: EAuthModes) => void;
|
||||
setAuthStep: (authStep: EAuthSteps) => void;
|
||||
setErrorInfo: (errorInfo: TAuthErrorInfo | undefined) => void;
|
||||
currentAuthMode: EAuthModes;
|
||||
};
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export const AuthFormRoot = observer((props: TAuthFormRoot) => {
|
||||
const { authStep, authMode, email, setEmail, setAuthMode, setAuthStep, setErrorInfo, currentAuthMode } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// query params
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = searchParams.get("next_path");
|
||||
// states
|
||||
const [isExistingEmail, setIsExistingEmail] = useState(false);
|
||||
// hooks
|
||||
const { config } = useInstance();
|
||||
|
||||
const isSMTPConfigured = config?.is_smtp_configured || false;
|
||||
|
||||
// submit handler- email verification
|
||||
const handleEmailVerification = async (data: IEmailCheckData) => {
|
||||
setEmail(data.email);
|
||||
setErrorInfo(undefined);
|
||||
await authService
|
||||
.emailCheck(data)
|
||||
.then(async (response) => {
|
||||
if (response.existing) {
|
||||
if (currentAuthMode === EAuthModes.SIGN_UP) setAuthMode(EAuthModes.SIGN_IN);
|
||||
if (response.status === "MAGIC_CODE") {
|
||||
setAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
generateEmailUniqueCode(data.email);
|
||||
} else if (response.status === "CREDENTIAL") {
|
||||
setAuthStep(EAuthSteps.PASSWORD);
|
||||
}
|
||||
} else {
|
||||
if (currentAuthMode === EAuthModes.SIGN_IN) setAuthMode(EAuthModes.SIGN_UP);
|
||||
if (response.status === "MAGIC_CODE") {
|
||||
setAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
generateEmailUniqueCode(data.email);
|
||||
} else if (response.status === "CREDENTIAL") {
|
||||
setAuthStep(EAuthSteps.PASSWORD);
|
||||
}
|
||||
}
|
||||
setIsExistingEmail(response.existing);
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined);
|
||||
if (errorhandler?.type) setErrorInfo(errorhandler);
|
||||
});
|
||||
};
|
||||
|
||||
const handleEmailClear = () => {
|
||||
setAuthMode(currentAuthMode);
|
||||
setErrorInfo(undefined);
|
||||
setEmail("");
|
||||
setAuthStep(EAuthSteps.EMAIL);
|
||||
router.push(currentAuthMode === EAuthModes.SIGN_IN ? `/` : "/sign-up");
|
||||
};
|
||||
|
||||
// generating the unique code
|
||||
const generateEmailUniqueCode = async (email: string): Promise<{ code: string } | undefined> => {
|
||||
if (!isSMTPConfigured) return;
|
||||
const payload = { email: email };
|
||||
return await authService
|
||||
.generateUniqueCode(payload)
|
||||
.then(() => ({ code: "" }))
|
||||
.catch((error) => {
|
||||
const errorhandler = authErrorHandler(error?.error_code.toString());
|
||||
if (errorhandler?.type) setErrorInfo(errorhandler);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
if (authStep === EAuthSteps.EMAIL) {
|
||||
return <AuthEmailForm defaultEmail={email} onSubmit={handleEmailVerification} />;
|
||||
}
|
||||
if (authStep === EAuthSteps.UNIQUE_CODE) {
|
||||
return (
|
||||
<AuthUniqueCodeForm
|
||||
mode={authMode}
|
||||
email={email}
|
||||
isExistingEmail={isExistingEmail}
|
||||
handleEmailClear={handleEmailClear}
|
||||
generateEmailUniqueCode={generateEmailUniqueCode}
|
||||
nextPath={nextPath || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (authStep === EAuthSteps.PASSWORD) {
|
||||
return (
|
||||
<AuthPasswordForm
|
||||
mode={authMode}
|
||||
isSMTPConfigured={isSMTPConfigured}
|
||||
email={email}
|
||||
handleEmailClear={handleEmailClear}
|
||||
handleAuthStep={(step: EAuthSteps) => {
|
||||
if (step === EAuthSteps.UNIQUE_CODE) generateEmailUniqueCode(email);
|
||||
setAuthStep(step);
|
||||
}}
|
||||
nextPath={nextPath || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
});
|
||||
1
apps/web/core/components/account/auth-forms/index.ts
Normal file
1
apps/web/core/components/account/auth-forms/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./auth-root";
|
||||
327
apps/web/core/components/account/auth-forms/password.tsx
Normal file
327
apps/web/core/components/account/auth-forms/password.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
// icons
|
||||
import { Eye, EyeOff, Info, X, XCircle } from "lucide-react";
|
||||
// plane imports
|
||||
import { API_BASE_URL, E_PASSWORD_STRENGTH, AUTH_TRACKER_EVENTS, AUTH_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
// components
|
||||
import { ForgotPasswordPopover } from "@/components/account/auth-forms/forgot-password-popover";
|
||||
// constants
|
||||
// helpers
|
||||
import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
isSMTPConfigured: boolean;
|
||||
mode: EAuthModes;
|
||||
handleEmailClear: () => void;
|
||||
handleAuthStep: (step: EAuthSteps) => void;
|
||||
nextPath: string | undefined;
|
||||
};
|
||||
|
||||
type TPasswordFormValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
confirm_password?: string;
|
||||
};
|
||||
|
||||
const defaultValues: TPasswordFormValues = {
|
||||
email: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
|
||||
const { email, isSMTPConfigured, handleAuthStep, handleEmailClear, mode, nextPath } = props;
|
||||
// plane imports
|
||||
const { t } = useTranslation();
|
||||
// ref
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
// states
|
||||
const [csrfPromise, setCsrfPromise] = useState<Promise<{ csrf_token: string }> | undefined>(undefined);
|
||||
const [passwordFormData, setPasswordFormData] = useState<TPasswordFormValues>({ ...defaultValues, email });
|
||||
const [showPassword, setShowPassword] = useState({
|
||||
password: false,
|
||||
retypePassword: false,
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
|
||||
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
|
||||
const [isBannerMessage, setBannerMessage] = useState(false);
|
||||
|
||||
const handleShowPassword = (key: keyof typeof showPassword) =>
|
||||
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const handleFormChange = (key: keyof TPasswordFormValues, value: string) =>
|
||||
setPasswordFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfPromise === undefined) {
|
||||
const promise = authService.requestCSRFToken();
|
||||
setCsrfPromise(promise);
|
||||
}
|
||||
}, [csrfPromise]);
|
||||
|
||||
const redirectToUniqueCodeSignIn = async () => {
|
||||
handleAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
};
|
||||
|
||||
const passwordSupport =
|
||||
mode === EAuthModes.SIGN_IN ? (
|
||||
<div className="w-full">
|
||||
{isSMTPConfigured ? (
|
||||
<Link
|
||||
data-ph-element={AUTH_TRACKER_ELEMENTS.FORGOT_PASSWORD_FROM_SIGNIN}
|
||||
href={`/accounts/forgot-password?email=${encodeURIComponent(email)}`}
|
||||
className="text-xs font-medium text-custom-primary-100"
|
||||
>
|
||||
{t("auth.common.forgot_password")}
|
||||
</Link>
|
||||
) : (
|
||||
<ForgotPasswordPopover />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
passwordFormData.password.length > 0 &&
|
||||
getPasswordStrength(passwordFormData.password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
|
||||
<PasswordStrengthIndicator password={passwordFormData.password} isFocused={isPasswordInputFocused} />
|
||||
)
|
||||
);
|
||||
|
||||
const isButtonDisabled = useMemo(
|
||||
() =>
|
||||
!isSubmitting &&
|
||||
!!passwordFormData.password &&
|
||||
(mode === EAuthModes.SIGN_UP ? passwordFormData.password === passwordFormData.confirm_password : true)
|
||||
? false
|
||||
: true,
|
||||
[isSubmitting, mode, passwordFormData.confirm_password, passwordFormData.password]
|
||||
);
|
||||
|
||||
const password = passwordFormData?.password ?? "";
|
||||
const confirmPassword = passwordFormData?.confirm_password ?? "";
|
||||
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
|
||||
|
||||
const handleCSRFToken = async () => {
|
||||
if (!formRef || !formRef.current) return;
|
||||
const token = await csrfPromise;
|
||||
if (!token?.csrf_token) return;
|
||||
const csrfElement = formRef.current.querySelector("input[name=csrfmiddlewaretoken]");
|
||||
csrfElement?.setAttribute("value", token?.csrf_token);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isBannerMessage && mode === EAuthModes.SIGN_UP && (
|
||||
<div className="relative flex items-center p-2 rounded-md gap-2 border border-red-500/50 bg-red-500/10">
|
||||
<div className="w-4 h-4 flex-shrink-0 relative flex justify-center items-center">
|
||||
<Info size={16} className="text-red-500" />
|
||||
</div>
|
||||
<div className="w-full text-sm font-medium text-red-500">{t("auth.sign_up.errors.password.strength")}</div>
|
||||
<div
|
||||
className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-red-500/20 text-custom-primary-100/80"
|
||||
onClick={() => setBannerMessage(false)}
|
||||
>
|
||||
<X className="w-4 h-4 flex-shrink-0 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
ref={formRef}
|
||||
className="space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/auth/${mode === EAuthModes.SIGN_IN ? "sign-in" : "sign-up"}/`}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault(); // Prevent form from submitting by default
|
||||
await handleCSRFToken();
|
||||
const isPasswordValid =
|
||||
mode === EAuthModes.SIGN_UP
|
||||
? getPasswordStrength(passwordFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID
|
||||
: true;
|
||||
if (isPasswordValid) {
|
||||
setIsSubmitting(true);
|
||||
captureSuccess({
|
||||
eventName:
|
||||
mode === EAuthModes.SIGN_IN
|
||||
? AUTH_TRACKER_EVENTS.sign_in_with_password
|
||||
: AUTH_TRACKER_EVENTS.sign_up_with_password,
|
||||
payload: {
|
||||
email: passwordFormData.email,
|
||||
},
|
||||
});
|
||||
if (formRef.current) formRef.current.submit(); // Manually submit the form if the condition is met
|
||||
} else {
|
||||
setBannerMessage(true);
|
||||
}
|
||||
}}
|
||||
onError={() => {
|
||||
setIsSubmitting(false);
|
||||
captureError({
|
||||
eventName:
|
||||
mode === EAuthModes.SIGN_IN
|
||||
? AUTH_TRACKER_EVENTS.sign_in_with_password
|
||||
: AUTH_TRACKER_EVENTS.sign_up_with_password,
|
||||
payload: {
|
||||
email: passwordFormData.email,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" />
|
||||
<input type="hidden" value={passwordFormData.email} name="email" />
|
||||
{nextPath && <input type="hidden" value={nextPath} name="next_path" />}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="email" className="text-sm font-medium text-custom-text-300">
|
||||
{t("auth.common.email.label")}
|
||||
</label>
|
||||
<div
|
||||
className={`relative flex items-center rounded-md bg-custom-background-100 border border-custom-border-300`}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={passwordFormData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
placeholder={t("auth.common.email.placeholder")}
|
||||
className={`disable-autofill-style h-10 w-full placeholder:text-custom-text-400 border-0`}
|
||||
disabled
|
||||
/>
|
||||
{passwordFormData.email.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 size-5"
|
||||
onClick={handleEmailClear}
|
||||
aria-label={t("aria_labels.auth_forms.clear_email")}
|
||||
>
|
||||
<XCircle className="size-5 stroke-custom-text-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="password" className="text-sm text-custom-text-300 font-medium">
|
||||
{mode === EAuthModes.SIGN_IN ? t("auth.common.password.label") : t("auth.common.password.set_password")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
type={showPassword?.password ? "text" : "password"}
|
||||
id="password"
|
||||
name="password"
|
||||
value={passwordFormData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
placeholder={t("auth.common.password.placeholder")}
|
||||
className="disable-autofill-style h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
className="absolute right-3 size-5 grid place-items-center"
|
||||
aria-label={t(
|
||||
showPassword?.password ? "aria_labels.auth_forms.hide_password" : "aria_labels.auth_forms.show_password"
|
||||
)}
|
||||
>
|
||||
{showPassword?.password ? (
|
||||
<EyeOff className="size-5 stroke-custom-text-400" />
|
||||
) : (
|
||||
<Eye className="size-5 stroke-custom-text-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{passwordSupport}
|
||||
</div>
|
||||
|
||||
{mode === EAuthModes.SIGN_UP && (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="confirm-password" className="text-sm text-custom-text-300 font-medium">
|
||||
{t("auth.common.password.confirm_password.label")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
type={showPassword?.retypePassword ? "text" : "password"}
|
||||
id="confirm-password"
|
||||
name="confirm_password"
|
||||
value={passwordFormData.confirm_password}
|
||||
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
|
||||
placeholder={t("auth.common.password.confirm_password.placeholder")}
|
||||
className="disable-autofill-style h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 size-5 grid place-items-center"
|
||||
aria-label={t(
|
||||
showPassword?.retypePassword
|
||||
? "aria_labels.auth_forms.hide_password"
|
||||
: "aria_labels.auth_forms.show_password"
|
||||
)}
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
>
|
||||
{showPassword?.retypePassword ? (
|
||||
<EyeOff className="size-5 stroke-custom-text-400" />
|
||||
) : (
|
||||
<Eye className="size-5 stroke-custom-text-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{!!passwordFormData.confirm_password &&
|
||||
passwordFormData.password !== passwordFormData.confirm_password &&
|
||||
renderPasswordMatchError && (
|
||||
<span className="text-sm text-red-500">{t("auth.common.password.errors.match")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{mode === EAuthModes.SIGN_IN ? (
|
||||
<>
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? (
|
||||
<Spinner height="20px" width="20px" />
|
||||
) : isSMTPConfigured ? (
|
||||
t("common.continue")
|
||||
) : (
|
||||
t("common.go_to_workspace")
|
||||
)}
|
||||
</Button>
|
||||
{isSMTPConfigured && (
|
||||
<Button
|
||||
type="button"
|
||||
data-ph-element={AUTH_TRACKER_ELEMENTS.SIGN_IN_WITH_UNIQUE_CODE}
|
||||
onClick={redirectToUniqueCodeSignIn}
|
||||
variant="outline-primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{t("auth.common.sign_in_with_unique_code")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Create account"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
});
|
||||
199
apps/web/core/components/account/auth-forms/reset-password.tsx
Normal file
199
apps/web/core/components/account/auth-forms/reset-password.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// ui
|
||||
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { Input, PasswordStrengthIndicator } from "@plane/ui";
|
||||
// components
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
// helpers
|
||||
import type { EAuthenticationErrorCodes, TAuthErrorInfo } from "@/helpers/authentication.helper";
|
||||
import { EErrorAlertType, authErrorHandler } from "@/helpers/authentication.helper";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
// local imports
|
||||
import { AuthBanner } from "./auth-banner";
|
||||
import { FormContainer } from "./common/container";
|
||||
import { AuthFormHeader } from "./common/header";
|
||||
|
||||
type TResetPasswordFormValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
confirm_password?: string;
|
||||
};
|
||||
|
||||
const defaultValues: TResetPasswordFormValues = {
|
||||
email: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
export const ResetPasswordForm = observer(() => {
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
const uidb64 = searchParams.get("uidb64");
|
||||
const token = searchParams.get("token");
|
||||
const email = searchParams.get("email");
|
||||
const error_code = searchParams.get("error_code");
|
||||
// states
|
||||
const [showPassword, setShowPassword] = useState({
|
||||
password: false,
|
||||
retypePassword: false,
|
||||
});
|
||||
const [resetFormData, setResetFormData] = useState<TResetPasswordFormValues>({
|
||||
...defaultValues,
|
||||
email: email ? email.toString() : "",
|
||||
});
|
||||
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
|
||||
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
|
||||
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
|
||||
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleShowPassword = (key: keyof typeof showPassword) =>
|
||||
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) =>
|
||||
setResetFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfToken === undefined)
|
||||
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||
}, [csrfToken]);
|
||||
|
||||
const isButtonDisabled = useMemo(
|
||||
() =>
|
||||
!!resetFormData.password &&
|
||||
getPasswordStrength(resetFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID &&
|
||||
resetFormData.password === resetFormData.confirm_password
|
||||
? false
|
||||
: true,
|
||||
[resetFormData]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (error_code) {
|
||||
const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes);
|
||||
if (errorhandler) {
|
||||
setErrorInfo(errorhandler);
|
||||
}
|
||||
}
|
||||
}, [error_code]);
|
||||
|
||||
const password = resetFormData?.password ?? "";
|
||||
const confirmPassword = resetFormData?.confirm_password ?? "";
|
||||
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
|
||||
|
||||
return (
|
||||
<FormContainer>
|
||||
<AuthFormHeader title="Reset password" description="Create a new password." />
|
||||
|
||||
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
|
||||
<AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />
|
||||
)}
|
||||
<form
|
||||
className="space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/auth/reset-password/${uidb64?.toString()}/${token?.toString()}/`}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="email">
|
||||
{t("auth.common.email.label")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={resetFormData.email}
|
||||
//hasError={Boolean(errors.email)}
|
||||
placeholder={t("auth.common.email.placeholder")}
|
||||
className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 text-custom-text-400 cursor-not-allowed"
|
||||
autoComplete="on"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="password">
|
||||
{t("auth.common.password.label")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
type={showPassword.password ? "text" : "password"}
|
||||
name="password"
|
||||
value={resetFormData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
//hasError={Boolean(errors.password)}
|
||||
placeholder={t("auth.common.password.placeholder")}
|
||||
className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
minLength={8}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
{showPassword.password ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<PasswordStrengthIndicator password={resetFormData.password} isFocused={isPasswordInputFocused} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="confirm_password">
|
||||
{t("auth.common.password.confirm_password.label")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
type={showPassword.retypePassword ? "text" : "password"}
|
||||
name="confirm_password"
|
||||
value={resetFormData.confirm_password}
|
||||
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
|
||||
placeholder={t("auth.common.password.confirm_password.placeholder")}
|
||||
className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
/>
|
||||
{showPassword.retypePassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!resetFormData.confirm_password &&
|
||||
resetFormData.password !== resetFormData.confirm_password &&
|
||||
renderPasswordMatchError && (
|
||||
<span className="text-sm text-red-500">{t("auth.common.password.errors.match")}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{t("auth.common.password.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormContainer>
|
||||
);
|
||||
});
|
||||
216
apps/web/core/components/account/auth-forms/set-password.tsx
Normal file
216
apps/web/core/components/account/auth-forms/set-password.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import type { FormEvent } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane imports
|
||||
import { AUTH_TRACKER_ELEMENTS, AUTH_TRACKER_EVENTS, E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Input, PasswordStrengthIndicator } from "@plane/ui";
|
||||
// components
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
// helpers
|
||||
import { captureError, captureSuccess, captureView } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
// local components
|
||||
import { FormContainer } from "./common/container";
|
||||
import { AuthFormHeader } from "./common/header";
|
||||
|
||||
type TResetPasswordFormValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
confirm_password?: string;
|
||||
};
|
||||
|
||||
const defaultValues: TResetPasswordFormValues = {
|
||||
email: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
export const SetPasswordForm = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get("email");
|
||||
// states
|
||||
const [showPassword, setShowPassword] = useState({
|
||||
password: false,
|
||||
retypePassword: false,
|
||||
});
|
||||
const [passwordFormData, setPasswordFormData] = useState<TResetPasswordFormValues>({
|
||||
...defaultValues,
|
||||
email: email ? email.toString() : "",
|
||||
});
|
||||
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
|
||||
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
|
||||
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { data: user, handleSetPassword } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
captureView({
|
||||
elementName: AUTH_TRACKER_ELEMENTS.SET_PASSWORD_FORM,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfToken === undefined)
|
||||
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||
}, [csrfToken]);
|
||||
|
||||
const handleShowPassword = (key: keyof typeof showPassword) =>
|
||||
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) =>
|
||||
setPasswordFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const isButtonDisabled = useMemo(
|
||||
() =>
|
||||
!!passwordFormData.password &&
|
||||
getPasswordStrength(passwordFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID &&
|
||||
passwordFormData.password === passwordFormData.confirm_password
|
||||
? false
|
||||
: true,
|
||||
[passwordFormData]
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
try {
|
||||
e.preventDefault();
|
||||
if (!csrfToken) throw new Error("csrf token not found");
|
||||
await handleSetPassword(csrfToken, { password: passwordFormData.password });
|
||||
captureSuccess({
|
||||
eventName: AUTH_TRACKER_EVENTS.password_created,
|
||||
});
|
||||
router.push("/");
|
||||
} catch (error: unknown) {
|
||||
let message = undefined;
|
||||
if (error instanceof Error) {
|
||||
const err = error as Error & { error?: string };
|
||||
message = err.error;
|
||||
}
|
||||
captureError({
|
||||
eventName: AUTH_TRACKER_EVENTS.password_created,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("common.errors.default.title"),
|
||||
message: message ?? t("common.errors.default.message"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const password = passwordFormData?.password ?? "";
|
||||
const confirmPassword = passwordFormData?.confirm_password ?? "";
|
||||
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
|
||||
|
||||
return (
|
||||
<FormContainer>
|
||||
<AuthFormHeader title="Set password" description="Create a new password." />
|
||||
<form className="space-y-4" onSubmit={(e) => handleSubmit(e)}>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="email">
|
||||
{t("auth.common.email.label")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={user?.email}
|
||||
//hasError={Boolean(errors.email)}
|
||||
placeholder={t("auth.common.email.placeholder")}
|
||||
className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 text-custom-text-400 cursor-not-allowed"
|
||||
autoComplete="on"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="password">
|
||||
{t("auth.common.password.label")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
type={showPassword.password ? "text" : "password"}
|
||||
name="password"
|
||||
value={passwordFormData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
//hasError={Boolean(errors.password)}
|
||||
placeholder={t("auth.common.password.placeholder")}
|
||||
className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
minLength={8}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
{showPassword.password ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<PasswordStrengthIndicator password={passwordFormData.password} isFocused={isPasswordInputFocused} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="confirm_password">
|
||||
{t("auth.common.password.confirm_password.label")}
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-custom-background-100">
|
||||
<Input
|
||||
type={showPassword.retypePassword ? "text" : "password"}
|
||||
name="confirm_password"
|
||||
value={passwordFormData.confirm_password}
|
||||
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
|
||||
placeholder={t("auth.common.password.confirm_password.placeholder")}
|
||||
className="h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
/>
|
||||
{showPassword.retypePassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!passwordFormData.confirm_password &&
|
||||
passwordFormData.password !== passwordFormData.confirm_password &&
|
||||
renderPasswordMatchError && (
|
||||
<span className="text-sm text-red-500">{t("auth.common.password.errors.match")}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormContainer>
|
||||
);
|
||||
});
|
||||
208
apps/web/core/components/account/auth-forms/unique-code.tsx
Normal file
208
apps/web/core/components/account/auth-forms/unique-code.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { CircleCheck, XCircle } from "lucide-react";
|
||||
import { API_BASE_URL, AUTH_TRACKER_ELEMENTS, AUTH_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { Input, Spinner } from "@plane/ui";
|
||||
// constants
|
||||
// helpers
|
||||
import { EAuthModes } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import useTimer from "@/hooks/use-timer";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
type TAuthUniqueCodeForm = {
|
||||
mode: EAuthModes;
|
||||
email: string;
|
||||
isExistingEmail: boolean;
|
||||
handleEmailClear: () => void;
|
||||
generateEmailUniqueCode: (email: string) => Promise<{ code: string } | undefined>;
|
||||
nextPath: string | undefined;
|
||||
};
|
||||
|
||||
type TUniqueCodeFormValues = {
|
||||
email: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
const defaultValues: TUniqueCodeFormValues = {
|
||||
email: "",
|
||||
code: "",
|
||||
};
|
||||
|
||||
export const AuthUniqueCodeForm: React.FC<TAuthUniqueCodeForm> = (props) => {
|
||||
const { mode, email, handleEmailClear, generateEmailUniqueCode, isExistingEmail, nextPath } = props;
|
||||
// derived values
|
||||
const defaultResetTimerValue = 5;
|
||||
// states
|
||||
const [uniqueCodeFormData, setUniqueCodeFormData] = useState<TUniqueCodeFormValues>({ ...defaultValues, email });
|
||||
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
||||
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// timer
|
||||
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleFormChange = (key: keyof TUniqueCodeFormValues, value: string) =>
|
||||
setUniqueCodeFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const generateNewCode = async (email: string) => {
|
||||
try {
|
||||
setIsRequestingNewCode(true);
|
||||
const uniqueCode = await generateEmailUniqueCode(email);
|
||||
setResendCodeTimer(defaultResetTimerValue);
|
||||
handleFormChange("code", uniqueCode?.code || "");
|
||||
setIsRequestingNewCode(false);
|
||||
captureSuccess({
|
||||
eventName: AUTH_TRACKER_EVENTS.new_code_requested,
|
||||
payload: {
|
||||
email: email,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setResendCodeTimer(0);
|
||||
console.error("Error while requesting new code");
|
||||
setIsRequestingNewCode(false);
|
||||
captureError({
|
||||
eventName: AUTH_TRACKER_EVENTS.new_code_requested,
|
||||
payload: {
|
||||
email: email,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfToken === undefined)
|
||||
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||
}, [csrfToken]);
|
||||
|
||||
const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0;
|
||||
const isButtonDisabled = isRequestingNewCode || !uniqueCodeFormData.code || isSubmitting;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/auth/${mode === EAuthModes.SIGN_IN ? "magic-sign-in" : "magic-sign-up"}/`}
|
||||
onSubmit={() => {
|
||||
setIsSubmitting(true);
|
||||
captureSuccess({
|
||||
eventName: AUTH_TRACKER_EVENTS.code_verify,
|
||||
payload: {
|
||||
state: "SUCCESS",
|
||||
first_time: !isExistingEmail,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onError={() => {
|
||||
setIsSubmitting(false);
|
||||
captureError({
|
||||
eventName: AUTH_TRACKER_EVENTS.code_verify,
|
||||
payload: {
|
||||
state: "FAILED",
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
<input type="hidden" value={uniqueCodeFormData.email} name="email" />
|
||||
{nextPath && <input type="hidden" value={nextPath} name="next_path" />}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="email" className="text-sm font-medium text-custom-text-300">
|
||||
{t("auth.common.email.label")}
|
||||
</label>
|
||||
<div
|
||||
className={`relative flex items-center rounded-md bg-custom-background-100 border border-custom-border-300`}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={uniqueCodeFormData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
placeholder={t("auth.common.email.placeholder")}
|
||||
className="disable-autofill-style h-10 w-full placeholder:text-custom-text-400 border-0"
|
||||
autoComplete="on"
|
||||
disabled
|
||||
/>
|
||||
{uniqueCodeFormData.email.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 size-5 grid place-items-center"
|
||||
aria-label={t("aria_labels.auth_forms.clear_email")}
|
||||
onClick={handleEmailClear}
|
||||
>
|
||||
<XCircle className="size-5 stroke-custom-text-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="unique-code" className="text-sm font-medium text-custom-text-300">
|
||||
{t("auth.common.unique_code.label")}
|
||||
</label>
|
||||
<Input
|
||||
name="code"
|
||||
id="unique-code"
|
||||
value={uniqueCodeFormData.code}
|
||||
onChange={(e) => handleFormChange("code", e.target.value)}
|
||||
placeholder={t("auth.common.unique_code.placeholder")}
|
||||
className="disable-autofill-style h-10 w-full border border-custom-border-300 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between px-1 text-xs pt-1">
|
||||
<p className="flex items-center gap-1 font-medium text-green-700">
|
||||
<CircleCheck height={12} width={12} />
|
||||
{t("auth.common.unique_code.paste_code")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-ph-element={AUTH_TRACKER_ELEMENTS.REQUEST_NEW_CODE}
|
||||
onClick={() => generateNewCode(uniqueCodeFormData.email)}
|
||||
className={
|
||||
isRequestNewCodeDisabled
|
||||
? "text-custom-text-400"
|
||||
: "font-medium text-custom-primary-300 hover:text-custom-primary-200"
|
||||
}
|
||||
disabled={isRequestNewCodeDisabled}
|
||||
>
|
||||
{resendTimerCode > 0
|
||||
? t("auth.common.resend_in", { seconds: resendTimerCode })
|
||||
: isRequestingNewCode
|
||||
? t("auth.common.unique_code.requesting_new_code")
|
||||
: t("common.resend")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={isButtonDisabled}
|
||||
data-ph-element={AUTH_TRACKER_ELEMENTS.VERIFY_CODE}
|
||||
>
|
||||
{isRequestingNewCode ? (
|
||||
t("auth.common.unique_code.sending_code")
|
||||
) : isSubmitting ? (
|
||||
<Spinner height="20px" width="20px" />
|
||||
) : (
|
||||
t("common.continue")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
128
apps/web/core/components/account/deactivate-account-modal.tsx
Normal file
128
apps/web/core/components/account/deactivate-account-modal.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const DeactivateAccountModal: React.FC<Props> = (props) => {
|
||||
const router = useAppRouter();
|
||||
const { isOpen, onClose } = props;
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
const { deactivateAccount, signOut } = useUser();
|
||||
|
||||
// states
|
||||
const [isDeactivating, setIsDeactivating] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsDeactivating(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
setIsDeactivating(true);
|
||||
|
||||
await deactivateAccount()
|
||||
.then(() => {
|
||||
captureSuccess({
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.deactivate_account,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Account deactivated successfully.",
|
||||
});
|
||||
signOut();
|
||||
router.push("/");
|
||||
handleClose();
|
||||
})
|
||||
.catch((err: any) => {
|
||||
captureError({
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.deactivate_account,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.error,
|
||||
});
|
||||
})
|
||||
.finally(() => setIsDeactivating(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[40rem]">
|
||||
<div className="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="">
|
||||
<div className="flex items-start gap-x-4">
|
||||
<div className="mt-3 grid place-items-center rounded-full bg-red-500/20 p-2 sm:mt-3 sm:p-2 md:mt-0 md:p-4 lg:mt-0 lg:p-4 ">
|
||||
<Trash2
|
||||
className="h-4 w-4 text-red-600 sm:h-4 sm:w-4 md:h-6 md:w-6 lg:h-6 lg:w-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Dialog.Title as="h3" className="my-4 text-2xl font-medium leading-6 text-custom-text-100">
|
||||
{t("deactivate_your_account")}
|
||||
</Dialog.Title>
|
||||
<p className="mt-6 list-disc pr-4 text-base font-normal text-custom-text-200">
|
||||
{t("deactivate_your_account_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2 flex items-center justify-end gap-2 p-4 sm:px-6">
|
||||
<Button variant="neutral-primary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDeleteAccount}>
|
||||
{isDeactivating ? t("deactivating") : t("confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
35
apps/web/core/components/account/terms-and-conditions.tsx
Normal file
35
apps/web/core/components/account/terms-and-conditions.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { EAuthModes } from "@plane/constants";
|
||||
|
||||
interface TermsAndConditionsProps {
|
||||
authType?: EAuthModes;
|
||||
}
|
||||
|
||||
// Constants for better maintainability
|
||||
const LEGAL_LINKS = {
|
||||
termsOfService: "https://plane.so/legals/terms-and-conditions",
|
||||
privacyPolicy: "https://plane.so/legals/privacy-policy",
|
||||
} as const;
|
||||
|
||||
const MESSAGES = {
|
||||
[EAuthModes.SIGN_UP]: "By creating an account",
|
||||
[EAuthModes.SIGN_IN]: "By signing in",
|
||||
} as const;
|
||||
|
||||
// Reusable link component to reduce duplication
|
||||
const LegalLink: React.FC<{ href: string; children: React.ReactNode }> = ({ href, children }) => (
|
||||
<Link href={href} className="text-custom-text-200" target="_blank" rel="noopener noreferrer">
|
||||
<span className="text-sm font-medium underline hover:cursor-pointer">{children}</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
export const TermsAndConditions: React.FC<TermsAndConditionsProps> = ({ authType = EAuthModes.SIGN_IN }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<p className="text-center text-sm text-custom-text-300 whitespace-pre-line">
|
||||
{`${MESSAGES[authType]}, you understand and agree to \n our `}
|
||||
<LegalLink href={LEGAL_LINKS.termsOfService}>Terms of Service</LegalLink> and{" "}
|
||||
<LegalLink href={LEGAL_LINKS.privacyPolicy}>Privacy Policy</LegalLink>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
// plane web components
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useAnalytics } from "@/hooks/store/use-analytics";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
// components
|
||||
import DurationDropdown from "./select/duration";
|
||||
import { ProjectSelect } from "./select/project";
|
||||
|
||||
const AnalyticsFilterActions = observer(() => {
|
||||
const { selectedProjects, selectedDuration, updateSelectedProjects, updateSelectedDuration } = useAnalytics();
|
||||
const { joinedProjectIds } = useProject();
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ProjectSelect
|
||||
value={selectedProjects}
|
||||
onChange={(val) => {
|
||||
updateSelectedProjects(val ?? []);
|
||||
}}
|
||||
projectIds={joinedProjectIds}
|
||||
/>
|
||||
{/* <DurationDropdown
|
||||
buttonVariant="border-with-text"
|
||||
value={selectedDuration}
|
||||
onChange={(val) => {
|
||||
updateSelectedDuration(val);
|
||||
}}
|
||||
dropdownArrow
|
||||
/> */}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default AnalyticsFilterActions;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
subtitle?: string | null;
|
||||
actions?: React.ReactNode;
|
||||
headerClassName?: string;
|
||||
};
|
||||
|
||||
const AnalyticsSectionWrapper: React.FC<Props> = (props) => {
|
||||
const { title, children, className, subtitle, actions, headerClassName } = props;
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={cn("mb-6 flex items-center gap-2 text-nowrap ", headerClassName)}>
|
||||
{title && (
|
||||
<div className="flex items-center gap-2 ">
|
||||
<h1 className={"text-lg font-medium"}>{title}</h1>
|
||||
{/* {subtitle && <p className="text-lg text-custom-text-300"> • {subtitle}</p>} */}
|
||||
</div>
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsSectionWrapper;
|
||||
23
apps/web/core/components/analytics/analytics-wrapper.tsx
Normal file
23
apps/web/core/components/analytics/analytics-wrapper.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
i18nTitle: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const AnalyticsWrapper: React.FC<Props> = (props) => {
|
||||
const { i18nTitle, children, className } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cn("px-6 py-4", className)}>
|
||||
<h1 className={"mb-4 text-2xl font-bold md:mb-6"}>{t(i18nTitle)}</h1>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsWrapper;
|
||||
48
apps/web/core/components/analytics/empty-state.tsx
Normal file
48
apps/web/core/components/analytics/empty-state.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
// plane package imports
|
||||
import { cn } from "@plane/utils";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
assetPath?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const AnalyticsEmptyState = ({ title, description, assetPath, className }: Props) => {
|
||||
const backgroundReolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-grid-background" });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center overflow-y-auto rounded-lg border border-custom-border-100 px-5 py-10 md:px-20",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex flex-col items-center")}>
|
||||
{assetPath && (
|
||||
<div className="relative flex max-h-[200px] max-w-[200px] items-center justify-center">
|
||||
<Image src={assetPath} alt={title} width={100} height={100} layout="fixed" className="z-10 h-2/3 w-2/3" />
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
src={backgroundReolvedPath}
|
||||
alt={title}
|
||||
width={100}
|
||||
height={100}
|
||||
layout="fixed"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-shrink flex-col items-center gap-1.5 text-center">
|
||||
<h3 className={cn("text-xl font-semibold")}>{title}</h3>
|
||||
{description && <p className="text-sm text-custom-text-300 max-w-[350px]">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default AnalyticsEmptyState;
|
||||
26
apps/web/core/components/analytics/export.ts
Normal file
26
apps/web/core/components/analytics/export.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ColumnDef, Row } from "@tanstack/react-table";
|
||||
import { download, generateCsv, mkConfig } from "export-to-csv";
|
||||
|
||||
export const csvConfig = (workspaceSlug: string) =>
|
||||
mkConfig({
|
||||
fieldSeparator: ",",
|
||||
filename: `${workspaceSlug}-analytics`,
|
||||
decimalSeparator: ".",
|
||||
useKeysAsHeaders: true,
|
||||
});
|
||||
|
||||
export const exportCSV = <T>(rows: Row<T>[], columns: ColumnDef<T>[], workspaceSlug: string) => {
|
||||
const rowData = rows.map((row) => {
|
||||
const exportColumns = columns.map((col) => col.meta?.export);
|
||||
const cells = exportColumns.reduce((acc: Record<string, string | number>, col) => {
|
||||
if (col) {
|
||||
const cell = col?.value(row) ?? "-";
|
||||
acc[col.label ?? col.key] = cell;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
return cells;
|
||||
});
|
||||
const csv = generateCsv(csvConfig(workspaceSlug))(rowData);
|
||||
download(csvConfig(workspaceSlug))(csv);
|
||||
};
|
||||
30
apps/web/core/components/analytics/insight-card.tsx
Normal file
30
apps/web/core/components/analytics/insight-card.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
// plane package imports
|
||||
import React from "react";
|
||||
import type { IAnalyticsResponseFields } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export type InsightCardProps = {
|
||||
data?: IAnalyticsResponseFields;
|
||||
label: string;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
const InsightCard = (props: InsightCardProps) => {
|
||||
const { data, label, isLoading = false } = props;
|
||||
const count = data?.count ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm text-custom-text-300">{label}</div>
|
||||
{!isLoading ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-2xl font-bold text-custom-text-100">{count}</div>
|
||||
</div>
|
||||
) : (
|
||||
<Loader.Item height="50px" width="100%" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightCard;
|
||||
175
apps/web/core/components/analytics/insight-table/data-table.tsx
Normal file
175
apps/web/core/components/analytics/insight-table/data-table.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
Table as TanstackTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Search, X } from "lucide-react";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web components
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import AnalyticsEmptyState from "../empty-state";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
searchPlaceholder: string;
|
||||
actions?: (table: TanstackTable<TData>) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, actions }: DataTableProps<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const { t } = useTranslation();
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-table" });
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
},
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="relative flex max-w-[300px] items-center gap-4 ">
|
||||
{table.getHeaderGroups()?.[0]?.headers?.[0]?.id && (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap text-sm text-custom-text-400">
|
||||
{searchPlaceholder}
|
||||
</div>
|
||||
)}
|
||||
{!isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-5 grid place-items-center rounded p-2 text-custom-text-400 hover:bg-custom-background-80"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"mr-auto flex w-0 items-center justify-start gap-1 overflow-hidden rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 opacity-0 transition-[width] ease-linear",
|
||||
{
|
||||
"w-64 border-custom-border-200 px-2.5 py-1.5 opacity-100": isSearchOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
|
||||
placeholder="Search"
|
||||
value={table.getColumn(table.getHeaderGroups()?.[0]?.headers?.[0]?.id)?.getFilterValue() as string}
|
||||
onChange={(e) => {
|
||||
const columnId = table.getHeaderGroups()?.[0]?.headers?.[0]?.id;
|
||||
if (columnId) table.getColumn(columnId)?.setFilterValue(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setIsSearchOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center"
|
||||
onClick={() => {
|
||||
const columnId = table.getHeaderGroups()?.[0]?.headers?.[0]?.id;
|
||||
if (columnId) {
|
||||
table.getColumn(columnId)?.setFilterValue("");
|
||||
}
|
||||
setIsSearchOpen(false);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div>{actions(table)}</div>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} colSpan={header.colSpan} className="whitespace-nowrap">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: (flexRender(header.column.columnDef.header, header.getContext()) as any)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext()) as any}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="p-0">
|
||||
<div className="flex h-[350px] w-full items-center justify-center border border-custom-border-100 ">
|
||||
<AnalyticsEmptyState
|
||||
title={t("workspace_analytics.empty_state.customized_insights.title")}
|
||||
description={t("workspace_analytics.empty_state.customized_insights.description")}
|
||||
className="border-0"
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
34
apps/web/core/components/analytics/insight-table/loader.tsx
Normal file
34
apps/web/core/components/analytics/insight-table/loader.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as React from "react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table";
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
interface TableSkeletonProps {
|
||||
columns: ColumnDef<any>[];
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export const TableLoader: React.FC<TableSkeletonProps> = ({ columns, rows }) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column, index) => (
|
||||
<TableHead key={column.header?.toString() ?? index}>
|
||||
{typeof column.header === "string" ? column.header : ""}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<TableRow key={rowIndex}>
|
||||
{columns.map((_, colIndex) => (
|
||||
<TableCell key={colIndex}>
|
||||
<Loader.Item height="20px" width="100%" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
45
apps/web/core/components/analytics/insight-table/root.tsx
Normal file
45
apps/web/core/components/analytics/insight-table/root.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ColumnDef, Row, Table } from "@tanstack/react-table";
|
||||
import { Download } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { AnalyticsTableDataMap, TAnalyticsTabsBase } from "@plane/types";
|
||||
import { DataTable } from "./data-table";
|
||||
import { TableLoader } from "./loader";
|
||||
interface InsightTableProps<T extends Exclude<TAnalyticsTabsBase, "overview">> {
|
||||
analyticsType: T;
|
||||
data?: AnalyticsTableDataMap[T][];
|
||||
isLoading?: boolean;
|
||||
columns: ColumnDef<AnalyticsTableDataMap[T]>[];
|
||||
columnsLabels?: Record<string, string>;
|
||||
headerText: string;
|
||||
onExport?: (rows: Row<AnalyticsTableDataMap[T]>[]) => void;
|
||||
}
|
||||
|
||||
export const InsightTable = <T extends Exclude<TAnalyticsTabsBase, "overview">>(
|
||||
props: InsightTableProps<T>
|
||||
): React.ReactElement => {
|
||||
const { data, isLoading, columns, headerText, onExport } = props;
|
||||
const { t } = useTranslation();
|
||||
if (isLoading) {
|
||||
return <TableLoader columns={columns} rows={5} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data || []}
|
||||
searchPlaceholder={`${data?.length || 0} ${headerText}`}
|
||||
actions={(table: Table<AnalyticsTableDataMap[T]>) => (
|
||||
<Button
|
||||
variant="accent-primary"
|
||||
prependIcon={<Download className="h-3.5 w-3.5" />}
|
||||
onClick={() => onExport?.(table.getFilteredRowModel().rows)}
|
||||
>
|
||||
<div>{t("exporter.csv.short_description")}</div>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
apps/web/core/components/analytics/loaders.tsx
Normal file
23
apps/web/core/components/analytics/loaders.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const ProjectInsightsLoader = () => (
|
||||
<div className="flex h-[200px] gap-1">
|
||||
<Loader className="h-full w-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
<div className="flex h-full w-full flex-col gap-1">
|
||||
<Loader className="h-12 w-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
<Loader className="h-full w-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ChartLoader = () => (
|
||||
<Loader className="h-[350px] w-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ProjectIcon } from "@plane/propel/icons";
|
||||
// plane package imports
|
||||
import { cn } from "@plane/utils";
|
||||
import { Logo } from "@/components/common/logo";
|
||||
// plane web hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
|
||||
type Props = {
|
||||
project: {
|
||||
id: string;
|
||||
completed_issues?: number;
|
||||
total_issues?: number;
|
||||
};
|
||||
isLoading?: boolean;
|
||||
};
|
||||
const CompletionPercentage = ({ percentage }: { percentage: number }) => {
|
||||
const percentageColor = percentage > 50 ? "bg-green-500/30 text-green-500" : "bg-red-500/30 text-red-500";
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 rounded p-1 text-xs", percentageColor)}>
|
||||
<span>{percentage}%</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ActiveProjectItem = (props: Props) => {
|
||||
const { project } = props;
|
||||
const { getProjectById } = useProject();
|
||||
const { id, completed_issues, total_issues } = project;
|
||||
|
||||
const projectDetails = getProjectById(id);
|
||||
|
||||
if (!projectDetails) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 ">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-custom-background-80">
|
||||
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
|
||||
{projectDetails?.logo_props ? (
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
) : (
|
||||
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
|
||||
<ProjectIcon className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{projectDetails?.name}</p>
|
||||
</div>
|
||||
<CompletionPercentage
|
||||
percentage={completed_issues && total_issues ? Math.round((completed_issues / total_issues) * 100) : 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveProjectItem;
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Loader } from "@plane/ui";
|
||||
// plane web hooks
|
||||
import { useAnalytics } from "@/hooks/store/use-analytics";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
// plane web components
|
||||
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||
import ActiveProjectItem from "./active-project-item";
|
||||
|
||||
const ActiveProjects = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const { fetchProjectAnalyticsCount } = useProject();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { selectedDurationLabel } = useAnalytics();
|
||||
const { data: projectAnalyticsCount, isLoading: isProjectAnalyticsCountLoading } = useSWR(
|
||||
workspaceSlug ? ["projectAnalyticsCount", workspaceSlug] : null,
|
||||
workspaceSlug
|
||||
? () =>
|
||||
fetchProjectAnalyticsCount(workspaceSlug.toString(), {
|
||||
fields: "total_work_items,total_completed_work_items",
|
||||
})
|
||||
: null
|
||||
);
|
||||
return (
|
||||
<AnalyticsSectionWrapper
|
||||
title={`${t("workspace_analytics.active_projects")}`}
|
||||
subtitle={selectedDurationLabel}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<div className="flex flex-col gap-4 h-[350px] overflow-auto">
|
||||
{isProjectAnalyticsCountLoading &&
|
||||
Array.from({ length: 5 }).map((_, index) => <Loader.Item key={index} height="40px" width="100%" />)}
|
||||
{!isProjectAnalyticsCountLoading &&
|
||||
projectAnalyticsCount?.map((project) => <ActiveProjectItem key={project.id} project={project} />)}
|
||||
</div>
|
||||
</AnalyticsSectionWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export default ActiveProjects;
|
||||
1
apps/web/core/components/analytics/overview/index.ts
Normal file
1
apps/web/core/components/analytics/overview/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
117
apps/web/core/components/analytics/overview/project-insights.tsx
Normal file
117
apps/web/core/components/analytics/overview/project-insights.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { observer } from "mobx-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TChartData } from "@plane/types";
|
||||
// hooks
|
||||
import { useAnalytics } from "@/hooks/store/use-analytics";
|
||||
// services
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { AnalyticsService } from "@/services/analytics.service";
|
||||
// plane web components
|
||||
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||
import AnalyticsEmptyState from "../empty-state";
|
||||
import { ProjectInsightsLoader } from "../loaders";
|
||||
|
||||
const RadarChart = dynamic(() =>
|
||||
import("@plane/propel/charts/radar-chart").then((mod) => ({
|
||||
default: mod.RadarChart,
|
||||
}))
|
||||
);
|
||||
|
||||
const analyticsService = new AnalyticsService();
|
||||
|
||||
const ProjectInsights = observer(() => {
|
||||
const params = useParams();
|
||||
const { t } = useTranslation();
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } =
|
||||
useAnalytics();
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-radar" });
|
||||
|
||||
const { data: projectInsightsData, isLoading: isLoadingProjectInsight } = useSWR(
|
||||
`radar-chart-project-insights-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
|
||||
() =>
|
||||
analyticsService.getAdvanceAnalyticsCharts<TChartData<string, string>[]>(
|
||||
workspaceSlug,
|
||||
"projects",
|
||||
{
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
},
|
||||
isPeekView
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<AnalyticsSectionWrapper
|
||||
title={`${t("workspace_analytics.project_insights")}`}
|
||||
subtitle={selectedDurationLabel}
|
||||
className="md:col-span-3"
|
||||
>
|
||||
{isLoadingProjectInsight ? (
|
||||
<ProjectInsightsLoader />
|
||||
) : projectInsightsData && projectInsightsData?.length == 0 ? (
|
||||
<AnalyticsEmptyState
|
||||
title={t("workspace_analytics.empty_state.project_insights.title")}
|
||||
description={t("workspace_analytics.empty_state.project_insights.description")}
|
||||
className="h-[300px]"
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
) : (
|
||||
<div className="gap-8 lg:flex">
|
||||
{projectInsightsData && (
|
||||
<RadarChart
|
||||
className="h-[350px] w-full lg:w-3/5"
|
||||
data={projectInsightsData}
|
||||
dataKey="key"
|
||||
radars={[
|
||||
{
|
||||
key: "count",
|
||||
name: "Count",
|
||||
fill: "rgba(var(--color-primary-300))",
|
||||
stroke: "rgba(var(--color-primary-300))",
|
||||
fillOpacity: 0.6,
|
||||
dot: {
|
||||
r: 4,
|
||||
fillOpacity: 1,
|
||||
},
|
||||
},
|
||||
]}
|
||||
margin={{ top: 0, right: 40, bottom: 10, left: 40 }}
|
||||
showTooltip
|
||||
angleAxis={{
|
||||
key: "name",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="w-full lg:w-2/5">
|
||||
<div className="text-sm text-custom-text-300">{t("workspace_analytics.summary_of_projects")}</div>
|
||||
<div className=" mb-3 border-b border-custom-border-100 py-2">{t("workspace_analytics.all_projects")}</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between text-sm text-custom-text-300">
|
||||
<div>{t("workspace_analytics.trend_on_charts")}</div>
|
||||
<div>{t("common.work_items")}</div>
|
||||
</div>
|
||||
{projectInsightsData?.map((item) => (
|
||||
<div key={item.key} className="flex items-center justify-between text-sm text-custom-text-100">
|
||||
<div>{item.name}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* <TrendPiece key={item.key} size='xs' /> */}
|
||||
<div className="text-custom-text-200">{item.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnalyticsSectionWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectInsights;
|
||||
19
apps/web/core/components/analytics/overview/root.tsx
Normal file
19
apps/web/core/components/analytics/overview/root.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import AnalyticsWrapper from "../analytics-wrapper";
|
||||
import TotalInsights from "../total-insights";
|
||||
import ActiveProjects from "./active-projects";
|
||||
import ProjectInsights from "./project-insights";
|
||||
|
||||
const Overview: React.FC = () => (
|
||||
<AnalyticsWrapper i18nTitle="common.overview">
|
||||
<div className="flex flex-col gap-14">
|
||||
<TotalInsights analyticsType="overview" />
|
||||
<div className="grid grid-cols-1 gap-14 md:grid-cols-5 ">
|
||||
<ProjectInsights />
|
||||
<ActiveProjects />
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWrapper>
|
||||
);
|
||||
|
||||
export { Overview };
|
||||
102
apps/web/core/components/analytics/select/analytics-params.tsx
Normal file
102
apps/web/core/components/analytics/select/analytics-params.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { Control, UseFormSetValue } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { Calendar, SlidersHorizontal } from "lucide-react";
|
||||
// plane package imports
|
||||
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "@plane/constants";
|
||||
import type { IAnalyticsParams } from "@plane/types";
|
||||
import { ChartYAxisMetric } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web components
|
||||
import { SelectXAxis } from "./select-x-axis";
|
||||
import { SelectYAxis } from "./select-y-axis";
|
||||
|
||||
type Props = {
|
||||
control: Control<IAnalyticsParams, unknown>;
|
||||
setValue: UseFormSetValue<IAnalyticsParams>;
|
||||
params: IAnalyticsParams;
|
||||
workspaceSlug: string;
|
||||
classNames?: string;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const AnalyticsSelectParams: React.FC<Props> = observer((props) => {
|
||||
const { control, params, classNames, isEpic } = props;
|
||||
const xAxisOptions = useMemo(
|
||||
() => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.group_by),
|
||||
[params.group_by]
|
||||
);
|
||||
const groupByOptions = useMemo(
|
||||
() => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.x_axis),
|
||||
[params.x_axis]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex w-full justify-between", classNames)}>
|
||||
<div className={`flex items-center gap-2`}>
|
||||
<Controller
|
||||
name="y_axis"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectYAxis
|
||||
value={value}
|
||||
onChange={(val: ChartYAxisMetric | null) => {
|
||||
onChange(val);
|
||||
}}
|
||||
options={ANALYTICS_Y_AXIS_VALUES}
|
||||
hiddenOptions={[
|
||||
ChartYAxisMetric.ESTIMATE_POINT_COUNT,
|
||||
isEpic ? ChartYAxisMetric.WORK_ITEM_COUNT : ChartYAxisMetric.EPIC_WORK_ITEM_COUNT,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="x_axis"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectXAxis
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span className={cn("text-custom-text-200", value && "text-custom-text-100")}>
|
||||
{xAxisOptions.find((v) => v.value === value)?.label || "Add Property"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
options={xAxisOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="group_by"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectXAxis
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
<span className={cn("text-custom-text-200", value && "text-custom-text-100")}>
|
||||
{groupByOptions.find((v) => v.value === value)?.label || "Add Property"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
options={groupByOptions}
|
||||
placeholder="Group By"
|
||||
allowNoValue
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
51
apps/web/core/components/analytics/select/duration.tsx
Normal file
51
apps/web/core/components/analytics/select/duration.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
// plane package imports
|
||||
import type { ReactNode } from "react";
|
||||
import React from "react";
|
||||
import { Calendar } from "lucide-react";
|
||||
// plane package imports
|
||||
import { ANALYTICS_DURATION_FILTER_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CustomSearchSelect } from "@plane/ui";
|
||||
// types
|
||||
import type { TDropdownProps } from "@/components/dropdowns/types";
|
||||
|
||||
type Props = TDropdownProps & {
|
||||
value: string | null;
|
||||
onChange: (val: (typeof ANALYTICS_DURATION_FILTER_OPTIONS)[number]["value"]) => void;
|
||||
//optional
|
||||
button?: ReactNode;
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
onClose?: () => void;
|
||||
renderByDefault?: boolean;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
function DurationDropdown({ placeholder = "Duration", onChange, value }: Props) {
|
||||
useTranslation();
|
||||
|
||||
const options = ANALYTICS_DURATION_FILTER_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
query: option.name,
|
||||
content: (
|
||||
<div className="flex max-w-[300px] items-center gap-2">
|
||||
<span className="flex-grow truncate">{option.name}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={value ? [value] : []}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
label={
|
||||
<div className="flex items-center gap-2 p-1 ">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{value ? ANALYTICS_DURATION_FILTER_OPTIONS.find((opt) => opt.value === value)?.name : placeholder}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DurationDropdown;
|
||||
62
apps/web/core/components/analytics/select/project.tsx
Normal file
62
apps/web/core/components/analytics/select/project.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { ProjectIcon } from "@plane/propel/icons";
|
||||
// plane package imports
|
||||
import { CustomSearchSelect } from "@plane/ui";
|
||||
// components
|
||||
import { Logo } from "@/components/common/logo";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
|
||||
type Props = {
|
||||
value: string[] | undefined;
|
||||
onChange: (val: string[] | null) => void;
|
||||
projectIds: string[] | undefined;
|
||||
};
|
||||
|
||||
export const ProjectSelect: React.FC<Props> = observer((props) => {
|
||||
const { value, onChange, projectIds } = props;
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
const options = projectIds?.map((projectId) => {
|
||||
const projectDetails = getProjectById(projectId);
|
||||
|
||||
return {
|
||||
value: projectDetails?.id,
|
||||
query: `${projectDetails?.name} ${projectDetails?.identifier}`,
|
||||
content: (
|
||||
<div className="flex max-w-[300px] items-center gap-2">
|
||||
{projectDetails?.logo_props ? (
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
) : (
|
||||
<ProjectIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span className="flex-grow truncate">{projectDetails?.name}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={value ?? []}
|
||||
onChange={(val: string[]) => onChange(val)}
|
||||
options={options}
|
||||
label={
|
||||
<div className="flex items-center gap-2 p-1 ">
|
||||
<ProjectIcon className="h-4 w-4" />
|
||||
{value && value.length > 3
|
||||
? `3+ projects`
|
||||
: value && value.length > 0
|
||||
? projectIds
|
||||
?.filter((p) => value.includes(p))
|
||||
.map((p) => getProjectById(p)?.name)
|
||||
.join(", ")
|
||||
: "All projects"}
|
||||
</div>
|
||||
}
|
||||
multiple
|
||||
/>
|
||||
);
|
||||
});
|
||||
31
apps/web/core/components/analytics/select/select-x-axis.tsx
Normal file
31
apps/web/core/components/analytics/select/select-x-axis.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
// plane package imports
|
||||
import type { ChartXAxisProperty } from "@plane/types";
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
value?: ChartXAxisProperty;
|
||||
onChange: (val: ChartXAxisProperty | null) => void;
|
||||
options: { value: ChartXAxisProperty; label: string }[];
|
||||
placeholder?: string;
|
||||
hiddenOptions?: ChartXAxisProperty[];
|
||||
allowNoValue?: boolean;
|
||||
label?: string | React.ReactNode;
|
||||
};
|
||||
|
||||
export const SelectXAxis: React.FC<Props> = (props) => {
|
||||
const { value, onChange, options, hiddenOptions, allowNoValue, label } = props;
|
||||
return (
|
||||
<CustomSelect value={value} label={label} onChange={onChange} maxHeight="lg">
|
||||
{allowNoValue && <CustomSelect.Option value={null}>No value</CustomSelect.Option>}
|
||||
{options.map((item) => {
|
||||
if (hiddenOptions?.includes(item.value)) return null;
|
||||
return (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
);
|
||||
};
|
||||
66
apps/web/core/components/analytics/select/select-y-axis.tsx
Normal file
66
apps/web/core/components/analytics/select/select-y-axis.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { EEstimateSystem } from "@plane/constants";
|
||||
import { ProjectIcon } from "@plane/propel/icons";
|
||||
import type { ChartYAxisMetric } from "@plane/types";
|
||||
// plane package imports
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProjectEstimates } from "@/hooks/store/estimates";
|
||||
// plane web constants
|
||||
type Props = {
|
||||
value: ChartYAxisMetric;
|
||||
onChange: (val: ChartYAxisMetric | null) => void;
|
||||
hiddenOptions?: ChartYAxisMetric[];
|
||||
options: { value: ChartYAxisMetric; label: string }[];
|
||||
};
|
||||
|
||||
export const SelectYAxis: React.FC<Props> = observer(({ value, onChange, hiddenOptions, options }) => {
|
||||
// hooks
|
||||
const { projectId } = useParams();
|
||||
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
||||
|
||||
const isEstimateEnabled = (analyticsOption: string) => {
|
||||
if (analyticsOption === "estimate") {
|
||||
if (
|
||||
projectId &&
|
||||
currentActiveEstimateId &&
|
||||
areEstimateEnabledByProjectId(projectId.toString()) &&
|
||||
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
<ProjectIcon className="h-3 w-3" />
|
||||
<span>{options.find((v) => v.value === value)?.label ?? "Add Metric"}</span>
|
||||
</div>
|
||||
}
|
||||
onChange={onChange}
|
||||
maxHeight="lg"
|
||||
>
|
||||
{options.map((item) => {
|
||||
if (hiddenOptions?.includes(item.value)) return null;
|
||||
return (
|
||||
isEstimateEnabled(item.value) && (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
);
|
||||
});
|
||||
94
apps/web/core/components/analytics/total-insights.tsx
Normal file
94
apps/web/core/components/analytics/total-insights.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
// plane package imports
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import type { IInsightField } from "@plane/constants";
|
||||
import { ANALYTICS_INSIGHTS_FIELDS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IAnalyticsResponse, TAnalyticsTabsBase } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAnalytics } from "@/hooks/store/use-analytics";
|
||||
// services
|
||||
import { AnalyticsService } from "@/services/analytics.service";
|
||||
// local imports
|
||||
import InsightCard from "./insight-card";
|
||||
|
||||
const analyticsService = new AnalyticsService();
|
||||
|
||||
const getInsightLabel = (
|
||||
analyticsType: TAnalyticsTabsBase,
|
||||
item: IInsightField,
|
||||
isEpic: boolean | undefined,
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
) => {
|
||||
if (analyticsType === "work-items") {
|
||||
return isEpic
|
||||
? t(item.i18nKey, { entity: t("common.epics") })
|
||||
: t(item.i18nKey, { entity: t("common.work_items") });
|
||||
}
|
||||
|
||||
// Get the base translation with entity
|
||||
const baseTranslation = t(item.i18nKey, {
|
||||
...item.i18nProps,
|
||||
entity: item.i18nProps?.entity && t(item.i18nProps?.entity),
|
||||
});
|
||||
|
||||
// Add prefix if available
|
||||
const prefix = item.i18nProps?.prefix ? `${t(item.i18nProps.prefix)} ` : "";
|
||||
|
||||
// Add suffix if available
|
||||
const suffix = item.i18nProps?.suffix ? ` ${t(item.i18nProps.suffix)}` : "";
|
||||
|
||||
// Combine prefix, base translation, and suffix
|
||||
return `${prefix}${baseTranslation}${suffix}`;
|
||||
};
|
||||
|
||||
const TotalInsights: React.FC<{
|
||||
analyticsType: TAnalyticsTabsBase;
|
||||
peekView?: boolean;
|
||||
}> = observer(({ analyticsType, peekView }) => {
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
const { t } = useTranslation();
|
||||
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics();
|
||||
const { data: totalInsightsData, isLoading } = useSWR(
|
||||
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isEpic}`,
|
||||
() =>
|
||||
analyticsService.getAdvanceAnalytics<IAnalyticsResponse>(
|
||||
workspaceSlug,
|
||||
analyticsType,
|
||||
{
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
...(isEpic ? { epic: true } : {}),
|
||||
},
|
||||
isPeekView
|
||||
)
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-8 sm:grid-cols-2 md:gap-10",
|
||||
!peekView
|
||||
? ANALYTICS_INSIGHTS_FIELDS[analyticsType]?.length % 5 === 0
|
||||
? "gap-10 lg:grid-cols-5"
|
||||
: "gap-8 lg:grid-cols-4"
|
||||
: "grid-cols-2"
|
||||
)}
|
||||
>
|
||||
{ANALYTICS_INSIGHTS_FIELDS[analyticsType]?.map((item) => (
|
||||
<InsightCard
|
||||
key={`${analyticsType}-${item.key}`}
|
||||
isLoading={isLoading}
|
||||
data={totalInsightsData?.[item.key]}
|
||||
label={getInsightLabel(analyticsType, item, isEpic, t)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TotalInsights;
|
||||
80
apps/web/core/components/analytics/trend-piece.tsx
Normal file
80
apps/web/core/components/analytics/trend-piece.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
// plane package imports
|
||||
import React from "react";
|
||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web components
|
||||
|
||||
type Props = {
|
||||
percentage: number;
|
||||
className?: string;
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
trendIconVisible?: boolean;
|
||||
variant?: "simple" | "outlined" | "tinted";
|
||||
};
|
||||
|
||||
const sizeConfig = {
|
||||
xs: {
|
||||
text: "text-xs",
|
||||
icon: "w-3 h-3",
|
||||
},
|
||||
sm: {
|
||||
text: "text-sm",
|
||||
icon: "w-4 h-4",
|
||||
},
|
||||
md: {
|
||||
text: "text-base",
|
||||
icon: "w-5 h-5",
|
||||
},
|
||||
lg: {
|
||||
text: "text-lg",
|
||||
icon: "w-6 h-6",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const variants: Record<NonNullable<Props["variant"]>, Record<"ontrack" | "offtrack" | "atrisk", string>> = {
|
||||
simple: {
|
||||
ontrack: "text-green-500",
|
||||
offtrack: "text-yellow-500",
|
||||
atrisk: "text-red-500",
|
||||
},
|
||||
outlined: {
|
||||
ontrack: "text-green-500 border border-green-500",
|
||||
offtrack: "text-yellow-500 border border-yellow-500",
|
||||
atrisk: "text-red-500 border border-red-500",
|
||||
},
|
||||
tinted: {
|
||||
ontrack: "text-green-500 bg-green-500/10",
|
||||
offtrack: "text-yellow-500 bg-yellow-500/10",
|
||||
atrisk: "text-red-500 bg-red-500/10",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const TrendPiece = (props: Props) => {
|
||||
const { percentage, className, trendIconVisible = true, size = "sm", variant = "simple" } = props;
|
||||
const isOnTrack = percentage >= 66;
|
||||
const isOffTrack = percentage >= 33 && percentage < 66;
|
||||
const config = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 p-1 rounded-md",
|
||||
variants[variant][isOnTrack ? "ontrack" : isOffTrack ? "offtrack" : "atrisk"],
|
||||
config.text,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{trendIconVisible &&
|
||||
(isOnTrack ? (
|
||||
<TrendingUp className={config.icon} />
|
||||
) : isOffTrack ? (
|
||||
<TrendingDown className={config.icon} />
|
||||
) : (
|
||||
<TrendingDown className={config.icon} />
|
||||
))}
|
||||
{Math.round(Math.abs(percentage))}%
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendPiece;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AreaChart } from "@plane/propel/charts/area-chart";
|
||||
import type { IChartResponse, TChartData } from "@plane/types";
|
||||
import { renderFormattedDate } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAnalytics } from "@/hooks/store/use-analytics";
|
||||
// services
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { AnalyticsService } from "@/services/analytics.service";
|
||||
// plane web components
|
||||
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||
import AnalyticsEmptyState from "../empty-state";
|
||||
import { ChartLoader } from "../loaders";
|
||||
|
||||
const analyticsService = new AnalyticsService();
|
||||
const CreatedVsResolved = observer(() => {
|
||||
const {
|
||||
selectedDuration,
|
||||
selectedDurationLabel,
|
||||
selectedProjects,
|
||||
selectedCycle,
|
||||
selectedModule,
|
||||
isPeekView,
|
||||
isEpic,
|
||||
} = useAnalytics();
|
||||
const params = useParams();
|
||||
const { t } = useTranslation();
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-area" });
|
||||
const { data: createdVsResolvedData, isLoading: isCreatedVsResolvedLoading } = useSWR(
|
||||
`created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}-${isEpic}`,
|
||||
() =>
|
||||
analyticsService.getAdvanceAnalyticsCharts<IChartResponse>(
|
||||
workspaceSlug,
|
||||
"work-items",
|
||||
{
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
...(isEpic ? { epic: true } : {}),
|
||||
},
|
||||
isPeekView
|
||||
)
|
||||
);
|
||||
const parsedData: TChartData<string, string>[] = useMemo(() => {
|
||||
if (!createdVsResolvedData?.data) return [];
|
||||
return createdVsResolvedData.data.map((datum) => ({
|
||||
...datum,
|
||||
[datum.key]: datum.count,
|
||||
name: renderFormattedDate(datum.key) ?? datum.key,
|
||||
}));
|
||||
}, [createdVsResolvedData]);
|
||||
|
||||
const areas = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "completed_issues",
|
||||
label: "Resolved",
|
||||
fill: "#19803833",
|
||||
fillOpacity: 1,
|
||||
stackId: "bar-one",
|
||||
showDot: false,
|
||||
smoothCurves: true,
|
||||
strokeColor: "#198038",
|
||||
strokeOpacity: 1,
|
||||
},
|
||||
{
|
||||
key: "created_issues",
|
||||
label: "Created",
|
||||
fill: "#1192E833",
|
||||
fillOpacity: 1,
|
||||
stackId: "bar-one",
|
||||
showDot: false,
|
||||
smoothCurves: true,
|
||||
strokeColor: "#1192E8",
|
||||
strokeOpacity: 1,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<AnalyticsSectionWrapper
|
||||
title={t("workspace_analytics.created_vs_resolved")}
|
||||
subtitle={selectedDurationLabel}
|
||||
className="col-span-1"
|
||||
>
|
||||
{isCreatedVsResolvedLoading ? (
|
||||
<ChartLoader />
|
||||
) : parsedData && parsedData.length > 0 ? (
|
||||
<AreaChart
|
||||
className="h-[350px] w-full"
|
||||
data={parsedData}
|
||||
areas={areas}
|
||||
xAxis={{
|
||||
key: "name",
|
||||
label: t("date"),
|
||||
}}
|
||||
yAxis={{
|
||||
key: "count",
|
||||
label: t("common.no_of", { entity: isEpic ? t("epics") : t("work_items") }),
|
||||
offset: -60,
|
||||
dx: -24,
|
||||
}}
|
||||
legend={{
|
||||
align: "left",
|
||||
verticalAlign: "bottom",
|
||||
layout: "horizontal",
|
||||
wrapperStyles: {
|
||||
justifyContent: "start",
|
||||
alignContent: "start",
|
||||
paddingLeft: "40px",
|
||||
paddingTop: "10px",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<AnalyticsEmptyState
|
||||
title={t("workspace_analytics.empty_state.created_vs_resolved.title")}
|
||||
description={t("workspace_analytics.empty_state.created_vs_resolved.description")}
|
||||
className="h-[350px]"
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsSectionWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export default CreatedVsResolved;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IAnalyticsParams } from "@plane/types";
|
||||
import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web components
|
||||
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||
import { AnalyticsSelectParams } from "../select/analytics-params";
|
||||
import PriorityChart from "./priority-chart";
|
||||
|
||||
const CustomizedInsights = observer(({ peekView, isEpic }: { peekView?: boolean; isEpic?: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { control, watch, setValue } = useForm<IAnalyticsParams>({
|
||||
defaultValues: {
|
||||
x_axis: ChartXAxisProperty.PRIORITY,
|
||||
y_axis: isEpic ? ChartYAxisMetric.EPIC_WORK_ITEM_COUNT : ChartYAxisMetric.WORK_ITEM_COUNT,
|
||||
},
|
||||
});
|
||||
|
||||
const params = {
|
||||
x_axis: watch("x_axis"),
|
||||
y_axis: watch("y_axis"),
|
||||
group_by: watch("group_by"),
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsSectionWrapper
|
||||
title={t("workspace_analytics.customized_insights")}
|
||||
className="col-span-1"
|
||||
headerClassName={cn(peekView ? "flex-col items-start" : "")}
|
||||
actions={
|
||||
<AnalyticsSelectParams
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
params={params}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PriorityChart x_axis={params.x_axis} y_axis={params.y_axis} group_by={params.group_by} />
|
||||
</AnalyticsSectionWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export default CustomizedInsights;
|
||||
1
apps/web/core/components/analytics/work-items/index.ts
Normal file
1
apps/web/core/components/analytics/work-items/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// plane package imports
|
||||
import type { ICycle, IModule, IProject } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// hooks
|
||||
import { useAnalytics } from "@/hooks/store/use-analytics";
|
||||
// plane web components
|
||||
import TotalInsights from "../../total-insights";
|
||||
import CreatedVsResolved from "../created-vs-resolved";
|
||||
import CustomizedInsights from "../customized-insights";
|
||||
import WorkItemsInsightTable from "../workitems-insight-table";
|
||||
|
||||
type Props = {
|
||||
fullScreen: boolean;
|
||||
projectDetails: IProject | undefined;
|
||||
cycleDetails: ICycle | undefined;
|
||||
moduleDetails: IModule | undefined;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const WorkItemsModalMainContent: React.FC<Props> = observer((props) => {
|
||||
const { projectDetails, cycleDetails, moduleDetails, fullScreen, isEpic } = props;
|
||||
const { updateSelectedProjects, updateSelectedCycle, updateSelectedModule, updateIsPeekView } = useAnalytics();
|
||||
const [isModalConfigured, setIsModalConfigured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateIsPeekView(true);
|
||||
|
||||
// Handle project selection
|
||||
if (projectDetails?.id) {
|
||||
updateSelectedProjects([projectDetails.id]);
|
||||
}
|
||||
|
||||
// Handle cycle selection
|
||||
if (cycleDetails?.id) {
|
||||
updateSelectedCycle(cycleDetails.id);
|
||||
}
|
||||
|
||||
// Handle module selection
|
||||
if (moduleDetails?.id) {
|
||||
updateSelectedModule(moduleDetails.id);
|
||||
}
|
||||
setIsModalConfigured(true);
|
||||
|
||||
// Cleanup fields
|
||||
return () => {
|
||||
updateSelectedProjects([]);
|
||||
updateSelectedCycle("");
|
||||
updateSelectedModule("");
|
||||
updateIsPeekView(false);
|
||||
};
|
||||
}, [
|
||||
projectDetails?.id,
|
||||
cycleDetails?.id,
|
||||
moduleDetails?.id,
|
||||
updateSelectedProjects,
|
||||
updateSelectedCycle,
|
||||
updateSelectedModule,
|
||||
updateIsPeekView,
|
||||
]);
|
||||
|
||||
if (!isModalConfigured)
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tab.Group as={React.Fragment}>
|
||||
<div className="flex flex-col gap-14 overflow-y-auto p-6">
|
||||
<TotalInsights analyticsType="work-items" peekView={!fullScreen} />
|
||||
<CreatedVsResolved />
|
||||
<CustomizedInsights peekView={!fullScreen} isEpic={isEpic} />
|
||||
<WorkItemsInsightTable />
|
||||
</div>
|
||||
</Tab.Group>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane package imports
|
||||
import { Expand, Shrink, X } from "lucide-react";
|
||||
import type { ICycle, IModule } from "@plane/types";
|
||||
// icons
|
||||
|
||||
type Props = {
|
||||
fullScreen: boolean;
|
||||
handleClose: () => void;
|
||||
setFullScreen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
title: string;
|
||||
cycle?: ICycle;
|
||||
module?: IModule;
|
||||
};
|
||||
|
||||
export const WorkItemsModalHeader: React.FC<Props> = observer((props) => {
|
||||
const { fullScreen, handleClose, setFullScreen, title, cycle, module } = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 bg-custom-background-100 px-5 py-4 text-sm">
|
||||
<h3 className="break-words">
|
||||
Analytics for {title} {cycle && `in ${cycle.name}`} {module && `in ${module.name}`}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="hidden place-items-center p-1 text-custom-text-200 hover:text-custom-text-100 md:grid"
|
||||
onClick={() => setFullScreen((prevData) => !prevData)}
|
||||
>
|
||||
{fullScreen ? <Shrink size={14} strokeWidth={2} /> : <Expand size={14} strokeWidth={2} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane package imports
|
||||
import { ModalPortal, EPortalWidth, EPortalPosition } from "@plane/propel/portal";
|
||||
import type { ICycle, IModule, IProject } from "@plane/types";
|
||||
import { useAnalytics } from "@/hooks/store/use-analytics";
|
||||
// plane web components
|
||||
import { WorkItemsModalMainContent } from "./content";
|
||||
import { WorkItemsModalHeader } from "./header";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectDetails?: IProject | undefined;
|
||||
cycleDetails?: ICycle | undefined;
|
||||
moduleDetails?: IModule | undefined;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const WorkItemsModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose, projectDetails, moduleDetails, cycleDetails, isEpic } = props;
|
||||
const { updateIsEpic, isPeekView } = useAnalytics();
|
||||
const [fullScreen, setFullScreen] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
setFullScreen(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateIsEpic(isPeekView ? (isEpic ?? false) : false);
|
||||
}, [isEpic, updateIsEpic, isPeekView]);
|
||||
|
||||
return (
|
||||
<ModalPortal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
width={fullScreen ? EPortalWidth.FULL : EPortalWidth.THREE_QUARTER}
|
||||
position={EPortalPosition.RIGHT}
|
||||
fullScreen={fullScreen}
|
||||
>
|
||||
<div
|
||||
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
|
||||
fullScreen ? "rounded-lg border" : "border-l"
|
||||
}`}
|
||||
>
|
||||
<WorkItemsModalHeader
|
||||
fullScreen={fullScreen}
|
||||
handleClose={handleClose}
|
||||
setFullScreen={setFullScreen}
|
||||
title={projectDetails?.name ?? ""}
|
||||
cycle={cycleDetails}
|
||||
module={moduleDetails}
|
||||
/>
|
||||
<WorkItemsModalMainContent
|
||||
fullScreen={fullScreen}
|
||||
projectDetails={projectDetails}
|
||||
cycleDetails={cycleDetails}
|
||||
moduleDetails={moduleDetails}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
});
|
||||
246
apps/web/core/components/analytics/work-items/priority-chart.tsx
Normal file
246
apps/web/core/components/analytics/work-items/priority-chart.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ColumnDef, Row, RowData, Table } from "@tanstack/react-table";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
// plane package imports
|
||||
import { Download } from "lucide-react";
|
||||
import type { ChartXAxisDateGrouping } from "@plane/constants";
|
||||
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, CHART_COLOR_PALETTES, EChartModels } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { BarChart } from "@plane/propel/charts/bar-chart";
|
||||
import type { TBarItem, TChart, TChartDatum, ChartXAxisProperty, ChartYAxisMetric } from "@plane/types";
|
||||
// plane web components
|
||||
import { generateExtendedColors, parseChartData } from "@/components/chart/utils";
|
||||
// hooks
|
||||
import { useAnalytics } from "@/hooks/store/use-analytics";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { AnalyticsService } from "@/services/analytics.service";
|
||||
import AnalyticsEmptyState from "../empty-state";
|
||||
import { exportCSV } from "../export";
|
||||
import { DataTable } from "../insight-table/data-table";
|
||||
import { ChartLoader } from "../loaders";
|
||||
import { generateBarColor } from "./utils";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
export: {
|
||||
key: string;
|
||||
value: (row: Row<TData>) => string | number;
|
||||
label?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
x_axis: ChartXAxisProperty;
|
||||
y_axis: ChartYAxisMetric;
|
||||
group_by?: ChartXAxisProperty;
|
||||
x_axis_date_grouping?: ChartXAxisDateGrouping;
|
||||
}
|
||||
|
||||
const analyticsService = new AnalyticsService();
|
||||
const PriorityChart = observer((props: Props) => {
|
||||
const { x_axis, y_axis, group_by } = props;
|
||||
const { t } = useTranslation();
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-bar" });
|
||||
// store hooks
|
||||
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics();
|
||||
const { workspaceStates } = useProjectState();
|
||||
const { resolvedTheme } = useTheme();
|
||||
// router
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
|
||||
const { data: priorityChartData, isLoading: priorityChartLoading } = useSWR(
|
||||
`customized-insights-chart-${workspaceSlug}-${selectedDuration}-
|
||||
${selectedProjects}-${selectedCycle}-${selectedModule}-${props.x_axis}-${props.y_axis}-${props.group_by}-${isPeekView}-${isEpic}`,
|
||||
() =>
|
||||
analyticsService.getAdvanceAnalyticsCharts<TChart>(
|
||||
workspaceSlug,
|
||||
"custom-work-items",
|
||||
{
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
...(isEpic ? { epic: true } : {}),
|
||||
...props,
|
||||
},
|
||||
isPeekView
|
||||
)
|
||||
);
|
||||
const parsedData = useMemo(
|
||||
() =>
|
||||
priorityChartData && parseChartData(priorityChartData, props.x_axis, props.group_by, props.x_axis_date_grouping),
|
||||
[priorityChartData, props.x_axis, props.group_by, props.x_axis_date_grouping]
|
||||
);
|
||||
const chart_model = props.group_by ? EChartModels.STACKED : EChartModels.BASIC;
|
||||
|
||||
const bars: TBarItem<string>[] = useMemo(() => {
|
||||
if (!parsedData) return [];
|
||||
let parsedBars: TBarItem<string>[];
|
||||
const schemaKeys = Object.keys(parsedData.schema);
|
||||
const baseColors = CHART_COLOR_PALETTES[0]?.[resolvedTheme === "dark" ? "dark" : "light"];
|
||||
const extendedColors = generateExtendedColors(baseColors ?? [], schemaKeys.length);
|
||||
if (chart_model === EChartModels.BASIC) {
|
||||
parsedBars = [
|
||||
{
|
||||
key: "count",
|
||||
label: "Count",
|
||||
stackId: "bar-one",
|
||||
fill: (payload) => generateBarColor(payload.key, { x_axis, y_axis, group_by }, baseColors, workspaceStates),
|
||||
textClassName: "",
|
||||
showPercentage: false,
|
||||
showTopBorderRadius: () => true,
|
||||
showBottomBorderRadius: () => true,
|
||||
},
|
||||
];
|
||||
} else if (chart_model === EChartModels.STACKED && parsedData.schema) {
|
||||
const parsedExtremes: {
|
||||
[key: string]: {
|
||||
top: string | null;
|
||||
bottom: string | null;
|
||||
};
|
||||
} = {};
|
||||
parsedData.data.forEach((datum) => {
|
||||
let top = null;
|
||||
let bottom = null;
|
||||
for (let i = 0; i < schemaKeys.length; i++) {
|
||||
const key = schemaKeys[i];
|
||||
if (datum[key] === 0) continue;
|
||||
if (!bottom) bottom = key;
|
||||
top = key;
|
||||
}
|
||||
parsedExtremes[datum.key] = { top, bottom };
|
||||
});
|
||||
|
||||
parsedBars = schemaKeys.map((key, index) => ({
|
||||
key: key,
|
||||
label: parsedData.schema[key],
|
||||
stackId: "bar-one",
|
||||
fill: extendedColors[index],
|
||||
textClassName: "",
|
||||
showPercentage: false,
|
||||
showTopBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].top === value,
|
||||
showBottomBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].bottom === value,
|
||||
}));
|
||||
} else {
|
||||
parsedBars = [];
|
||||
}
|
||||
return parsedBars;
|
||||
}, [chart_model, group_by, parsedData, resolvedTheme, workspaceStates, x_axis, y_axis]);
|
||||
|
||||
const yAxisLabel = useMemo(
|
||||
() => ANALYTICS_Y_AXIS_VALUES.find((item) => item.value === props.y_axis)?.label ?? props.y_axis,
|
||||
[props.y_axis]
|
||||
);
|
||||
const xAxisLabel = useMemo(
|
||||
() => ANALYTICS_X_AXIS_VALUES.find((item) => item.value === props.x_axis)?.label ?? props.x_axis,
|
||||
[props.x_axis]
|
||||
);
|
||||
|
||||
const defaultColumns: ColumnDef<TChartDatum>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: () => xAxisLabel,
|
||||
meta: {
|
||||
export: {
|
||||
key: xAxisLabel,
|
||||
value: (row) => row.original.name,
|
||||
label: xAxisLabel,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "count",
|
||||
header: () => <div className="text-right">Count</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.count}</div>,
|
||||
meta: {
|
||||
export: {
|
||||
key: "Count",
|
||||
value: (row) => row.original.count,
|
||||
label: "Count",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[xAxisLabel]
|
||||
);
|
||||
|
||||
const columns: ColumnDef<TChartDatum>[] = useMemo(
|
||||
() =>
|
||||
parsedData
|
||||
? Object.keys(parsedData?.schema ?? {}).map((key) => ({
|
||||
accessorKey: key,
|
||||
header: () => <div className="text-right">{parsedData.schema[key]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original[key]}</div>,
|
||||
meta: {
|
||||
export: {
|
||||
key,
|
||||
value: (row) => row.original[key],
|
||||
label: parsedData.schema[key],
|
||||
},
|
||||
},
|
||||
}))
|
||||
: [],
|
||||
[parsedData]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-12 ">
|
||||
{priorityChartLoading ? (
|
||||
<ChartLoader />
|
||||
) : parsedData?.data && parsedData.data.length > 0 ? (
|
||||
<>
|
||||
<BarChart
|
||||
className="h-[370px] w-full"
|
||||
data={parsedData.data}
|
||||
bars={bars}
|
||||
margin={{
|
||||
bottom: 30,
|
||||
}}
|
||||
xAxis={{
|
||||
key: "name",
|
||||
label: xAxisLabel.replace("_", " "),
|
||||
dy: 30,
|
||||
}}
|
||||
yAxis={{
|
||||
key: "count",
|
||||
label: t("common.no_of", { entity: yAxisLabel.replace("_", " ") }),
|
||||
offset: -60,
|
||||
dx: -26,
|
||||
}}
|
||||
/>
|
||||
<DataTable
|
||||
data={parsedData.data}
|
||||
columns={[...defaultColumns, ...columns]}
|
||||
searchPlaceholder={`${parsedData.data.length} ${xAxisLabel}`}
|
||||
actions={(table: Table<TChartDatum>) => (
|
||||
<Button
|
||||
variant="accent-primary"
|
||||
prependIcon={<Download className="h-3.5 w-3.5" />}
|
||||
onClick={() => exportCSV(table.getRowModel().rows, [...defaultColumns, ...columns], workspaceSlug)}
|
||||
>
|
||||
<div>{t("exporter.csv.short_description")}</div>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<AnalyticsEmptyState
|
||||
title={t("workspace_analytics.empty_state.customized_insights.title")}
|
||||
description={t("workspace_analytics.empty_state.customized_insights.description")}
|
||||
className="h-[350px]"
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default PriorityChart;
|
||||
19
apps/web/core/components/analytics/work-items/root.tsx
Normal file
19
apps/web/core/components/analytics/work-items/root.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import AnalyticsWrapper from "../analytics-wrapper";
|
||||
import TotalInsights from "../total-insights";
|
||||
import CreatedVsResolved from "./created-vs-resolved";
|
||||
import CustomizedInsights from "./customized-insights";
|
||||
import WorkItemsInsightTable from "./workitems-insight-table";
|
||||
|
||||
const WorkItems: React.FC = () => (
|
||||
<AnalyticsWrapper i18nTitle="sidebar.work_items">
|
||||
<div className="flex flex-col gap-14">
|
||||
<TotalInsights analyticsType="work-items" />
|
||||
<CreatedVsResolved />
|
||||
<CustomizedInsights />
|
||||
<WorkItemsInsightTable />
|
||||
</div>
|
||||
</AnalyticsWrapper>
|
||||
);
|
||||
|
||||
export { WorkItems };
|
||||
47
apps/web/core/components/analytics/work-items/utils.ts
Normal file
47
apps/web/core/components/analytics/work-items/utils.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// plane package imports
|
||||
import type { ChartYAxisMetric, IState } from "@plane/types";
|
||||
import { ChartXAxisProperty } from "@plane/types";
|
||||
|
||||
interface ParamsProps {
|
||||
x_axis: ChartXAxisProperty;
|
||||
y_axis: ChartYAxisMetric;
|
||||
group_by?: ChartXAxisProperty;
|
||||
}
|
||||
|
||||
export const generateBarColor = (
|
||||
value: string | null | undefined,
|
||||
params: ParamsProps,
|
||||
baseColors: string[],
|
||||
workspaceStates?: IState[]
|
||||
): string => {
|
||||
if (!value) return baseColors[0];
|
||||
let color = baseColors[0];
|
||||
// Priority
|
||||
if (params.x_axis === ChartXAxisProperty.PRIORITY) {
|
||||
color =
|
||||
value === "urgent"
|
||||
? "#ef4444"
|
||||
: value === "high"
|
||||
? "#f97316"
|
||||
: value === "medium"
|
||||
? "#eab308"
|
||||
: value === "low"
|
||||
? "#22c55e"
|
||||
: "#ced4da";
|
||||
}
|
||||
|
||||
// State
|
||||
if (params.x_axis === ChartXAxisProperty.STATES) {
|
||||
if (workspaceStates && workspaceStates.length > 0) {
|
||||
const state = workspaceStates.find((s) => s.id === value);
|
||||
if (state) {
|
||||
color = state.color;
|
||||
} else {
|
||||
const index = Math.abs(value.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)) % baseColors.length;
|
||||
color = baseColors[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return color;
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { UserRound } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ProjectIcon } from "@plane/propel/icons";
|
||||
// plane package imports
|
||||
import type { AnalyticsTableDataMap, WorkItemInsightColumns } from "@plane/types";
|
||||
// plane web components
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
import { Logo } from "@/components/common/logo";
|
||||
// hooks
|
||||
import { useAnalytics } from "@/hooks/store/use-analytics";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { AnalyticsService } from "@/services/analytics.service";
|
||||
// plane web components
|
||||
import { exportCSV } from "../export";
|
||||
import { InsightTable } from "../insight-table";
|
||||
|
||||
const analyticsService = new AnalyticsService();
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
export: {
|
||||
key: string;
|
||||
value: (row: Row<TData>) => string | number;
|
||||
label?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const WorkItemsInsightTable = observer(() => {
|
||||
// router
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics();
|
||||
const { data: workItemsData, isLoading } = useSWR(
|
||||
`insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}-${isEpic}`,
|
||||
() =>
|
||||
analyticsService.getAdvanceAnalyticsStats<WorkItemInsightColumns[]>(
|
||||
workspaceSlug,
|
||||
"work-items",
|
||||
{
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
...(isEpic ? { epic: true } : {}),
|
||||
},
|
||||
isPeekView
|
||||
)
|
||||
);
|
||||
// derived values
|
||||
const columnsLabels: Record<keyof Omit<WorkItemInsightColumns, "project_id" | "avatar_url" | "assignee_id">, string> =
|
||||
useMemo(
|
||||
() => ({
|
||||
backlog_work_items: t("workspace_projects.state.backlog"),
|
||||
started_work_items: t("workspace_projects.state.started"),
|
||||
un_started_work_items: t("workspace_projects.state.unstarted"),
|
||||
completed_work_items: t("workspace_projects.state.completed"),
|
||||
cancelled_work_items: t("workspace_projects.state.cancelled"),
|
||||
project__name: t("common.project"),
|
||||
display_name: t("common.assignee"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
const columns: ColumnDef<AnalyticsTableDataMap["work-items"]>[] = useMemo(
|
||||
() => [
|
||||
!isPeekView
|
||||
? {
|
||||
accessorKey: "project__name",
|
||||
header: () => <div className="text-left">{columnsLabels["project__name"]}</div>,
|
||||
cell: ({ row }) => {
|
||||
const project = getProjectById(row.original.project_id);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{project?.logo_props ? (
|
||||
<Logo logo={project.logo_props} size={18} />
|
||||
) : (
|
||||
<ProjectIcon className="h-4 w-4" />
|
||||
)}
|
||||
{project?.name}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
export: {
|
||||
key: columnsLabels["project__name"],
|
||||
value: (row) => row.original.project__name?.toString() ?? "",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
accessorKey: "display_name",
|
||||
header: () => <div className="text-left">{columnsLabels["display_name"]}</div>,
|
||||
cell: ({ row }: { row: Row<WorkItemInsightColumns> }) => (
|
||||
<div className="text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
{row.original.avatar_url && row.original.avatar_url !== "" ? (
|
||||
<Avatar
|
||||
name={row.original.display_name}
|
||||
src={getFileURL(row.original.avatar_url)}
|
||||
size={24}
|
||||
shape="circle"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-custom-background-80 capitalize overflow-hidden">
|
||||
{row.original.display_name ? (
|
||||
row.original.display_name?.[0]
|
||||
) : (
|
||||
<UserRound className="text-custom-text-200 " size={12} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="break-words text-custom-text-200">
|
||||
{row.original.display_name ?? t(`Unassigned`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
export: {
|
||||
key: columnsLabels["display_name"],
|
||||
value: (row) => row.original.display_name?.toString() ?? "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "backlog_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["backlog_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.backlog_work_items}</div>,
|
||||
meta: {
|
||||
export: {
|
||||
key: columnsLabels["backlog_work_items"],
|
||||
value: (row) => row.original.backlog_work_items.toString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "started_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["started_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.started_work_items}</div>,
|
||||
meta: {
|
||||
export: {
|
||||
key: columnsLabels["started_work_items"],
|
||||
value: (row) => row.original.started_work_items.toString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "un_started_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["un_started_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.un_started_work_items}</div>,
|
||||
meta: {
|
||||
export: {
|
||||
key: columnsLabels["un_started_work_items"],
|
||||
value: (row) => row.original.un_started_work_items.toString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "completed_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["completed_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.completed_work_items}</div>,
|
||||
meta: {
|
||||
export: {
|
||||
key: columnsLabels["completed_work_items"],
|
||||
value: (row) => row.original.completed_work_items.toString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "cancelled_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["cancelled_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.cancelled_work_items}</div>,
|
||||
meta: {
|
||||
export: {
|
||||
key: columnsLabels["cancelled_work_items"],
|
||||
value: (row) => row.original.cancelled_work_items.toString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[columnsLabels, getProjectById, isPeekView, t]
|
||||
);
|
||||
return (
|
||||
<InsightTable<"work-items">
|
||||
analyticsType="work-items"
|
||||
data={workItemsData}
|
||||
isLoading={isLoading}
|
||||
columns={columns}
|
||||
columnsLabels={columnsLabels}
|
||||
headerText={isPeekView ? t("common.assignee") : t("common.projects")}
|
||||
onExport={(rows) => workItemsData && exportCSV(rows, columns, workspaceSlug)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkItemsInsightTable;
|
||||
93
apps/web/core/components/api-token/delete-token-modal.tsx
Normal file
93
apps/web/core/components/api-token/delete-token-modal.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { mutate } from "swr";
|
||||
// types
|
||||
import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { APITokenService } from "@plane/services";
|
||||
import type { IApiToken } from "@plane/types";
|
||||
// ui
|
||||
import { AlertModalCore } from "@plane/ui";
|
||||
// fetch-keys
|
||||
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tokenId: string;
|
||||
};
|
||||
|
||||
const apiTokenService = new APITokenService();
|
||||
|
||||
export const DeleteApiTokenModal: FC<Props> = (props) => {
|
||||
const { isOpen, onClose, tokenId } = props;
|
||||
// states
|
||||
const [deleteLoading, setDeleteLoading] = useState<boolean>(false);
|
||||
// router params
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setDeleteLoading(false);
|
||||
};
|
||||
|
||||
const handleDeletion = async () => {
|
||||
setDeleteLoading(true);
|
||||
|
||||
await apiTokenService
|
||||
.destroy(tokenId)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("workspace_settings.settings.api_tokens.delete.success.title"),
|
||||
message: t("workspace_settings.settings.api_tokens.delete.success.message"),
|
||||
});
|
||||
|
||||
mutate<IApiToken[]>(
|
||||
API_TOKENS_LIST,
|
||||
(prevData) => (prevData ?? []).filter((token) => token.id !== tokenId),
|
||||
false
|
||||
);
|
||||
captureSuccess({
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted,
|
||||
payload: {
|
||||
token: tokenId,
|
||||
},
|
||||
});
|
||||
|
||||
handleClose();
|
||||
})
|
||||
.catch((err) =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("workspace_settings.settings.api_tokens.delete.error.title"),
|
||||
message: err?.message ?? t("workspace_settings.settings.api_tokens.delete.error.message"),
|
||||
})
|
||||
)
|
||||
.catch((err) => {
|
||||
captureError({
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted,
|
||||
payload: {
|
||||
token: tokenId,
|
||||
},
|
||||
error: err as Error,
|
||||
});
|
||||
})
|
||||
.finally(() => setDeleteLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertModalCore
|
||||
handleClose={handleClose}
|
||||
handleSubmit={handleDeletion}
|
||||
isSubmitting={deleteLoading}
|
||||
isOpen={isOpen}
|
||||
title={t("workspace_settings.settings.api_tokens.delete.title")}
|
||||
content={<>{t("workspace_settings.settings.api_tokens.delete.description")} </>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
33
apps/web/core/components/api-token/empty-state.tsx
Normal file
33
apps/web/core/components/api-token/empty-state.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
// ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
// assets
|
||||
import emptyApiTokens from "@/public/empty-state/api-token.svg";
|
||||
|
||||
type Props = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export const ApiTokenEmptyState: React.FC<Props> = (props) => {
|
||||
const { onClick } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mx-auto flex w-full items-center justify-center rounded-sm border border-custom-border-200 bg-custom-background-90 px-16 py-10 lg:w-3/4`}
|
||||
>
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
<Image src={emptyApiTokens} className="w-52 sm:w-60" alt="empty" />
|
||||
<h6 className="mb-3 mt-6 text-xl font-semibold sm:mt-8">No API tokens</h6>
|
||||
<p className="mb-7 text-custom-text-300 sm:mb-8">
|
||||
Create API tokens for safe and easy data sharing with external apps, maintaining control and security.
|
||||
</p>
|
||||
<Button className="flex items-center gap-1.5" onClick={onClick}>
|
||||
Add token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
107
apps/web/core/components/api-token/modal/create-token-modal.tsx
Normal file
107
apps/web/core/components/api-token/modal/create-token-modal.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { mutate } from "swr";
|
||||
// plane imports
|
||||
import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { APITokenService } from "@plane/services";
|
||||
import type { IApiToken } from "@plane/types";
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { renderFormattedDate, csvDownload } from "@plane/utils";
|
||||
// constants
|
||||
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
|
||||
// helpers
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// local imports
|
||||
import { CreateApiTokenForm } from "./form";
|
||||
import { GeneratedTokenDetails } from "./generated-token-details";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// services
|
||||
const apiTokenService = new APITokenService();
|
||||
|
||||
export const CreateApiTokenModal: React.FC<Props> = (props) => {
|
||||
const { isOpen, onClose } = props;
|
||||
// states
|
||||
const [neverExpires, setNeverExpires] = useState<boolean>(false);
|
||||
const [generatedToken, setGeneratedToken] = useState<IApiToken | null | undefined>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
|
||||
setTimeout(() => {
|
||||
setNeverExpires(false);
|
||||
setGeneratedToken(null);
|
||||
}, 350);
|
||||
};
|
||||
|
||||
const downloadSecretKey = (data: IApiToken) => {
|
||||
const csvData = {
|
||||
Title: data.label,
|
||||
Description: data.description,
|
||||
Expiry: data.expired_at ? (renderFormattedDate(data.expired_at)?.replace(",", " ") ?? "") : "Never expires",
|
||||
"Secret key": data.token ?? "",
|
||||
};
|
||||
|
||||
csvDownload(csvData, `secret-key-${Date.now()}`);
|
||||
};
|
||||
|
||||
const handleCreateToken = async (data: Partial<IApiToken>) => {
|
||||
// make the request to generate the token
|
||||
await apiTokenService
|
||||
.create(data)
|
||||
.then((res) => {
|
||||
setGeneratedToken(res);
|
||||
downloadSecretKey(res);
|
||||
|
||||
mutate<IApiToken[]>(
|
||||
API_TOKENS_LIST,
|
||||
(prevData) => {
|
||||
if (!prevData) return;
|
||||
|
||||
return [res, ...prevData];
|
||||
},
|
||||
false
|
||||
);
|
||||
captureSuccess({
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_created,
|
||||
payload: {
|
||||
token: res.id,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err.message || err.detail,
|
||||
});
|
||||
|
||||
captureError({
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_created,
|
||||
});
|
||||
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={() => {}} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
{generatedToken ? (
|
||||
<GeneratedTokenDetails handleClose={handleClose} tokenDetails={generatedToken} />
|
||||
) : (
|
||||
<CreateApiTokenForm
|
||||
handleClose={handleClose}
|
||||
neverExpires={neverExpires}
|
||||
toggleNeverExpires={() => setNeverExpires((prevData) => !prevData)}
|
||||
onSubmit={handleCreateToken}
|
||||
/>
|
||||
)}
|
||||
</ModalCore>
|
||||
);
|
||||
};
|
||||
256
apps/web/core/components/api-token/modal/form.tsx
Normal file
256
apps/web/core/components/api-token/modal/form.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { add } from "date-fns";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Calendar } from "lucide-react";
|
||||
// types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IApiToken } from "@plane/types";
|
||||
// ui
|
||||
import { CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui";
|
||||
import { cn, renderFormattedDate, renderFormattedTime } from "@plane/utils";
|
||||
// components
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
// helpers
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
neverExpires: boolean;
|
||||
toggleNeverExpires: () => void;
|
||||
onSubmit: (data: Partial<IApiToken>) => Promise<void>;
|
||||
};
|
||||
|
||||
const EXPIRY_DATE_OPTIONS = [
|
||||
{
|
||||
key: "1_week",
|
||||
label: "1 week",
|
||||
value: { weeks: 1 },
|
||||
},
|
||||
{
|
||||
key: "1_month",
|
||||
label: "1 month",
|
||||
value: { months: 1 },
|
||||
},
|
||||
{
|
||||
key: "3_months",
|
||||
label: "3 months",
|
||||
value: { months: 3 },
|
||||
},
|
||||
{
|
||||
key: "1_year",
|
||||
label: "1 year",
|
||||
value: { years: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const defaultValues: Partial<IApiToken> = {
|
||||
label: "",
|
||||
description: "",
|
||||
expired_at: null,
|
||||
};
|
||||
|
||||
const getExpiryDate = (val: string): Date | null | undefined => {
|
||||
const today = new Date();
|
||||
const dateToAdd = EXPIRY_DATE_OPTIONS.find((option) => option.key === val)?.value;
|
||||
if (dateToAdd) return add(today, dateToAdd);
|
||||
return null;
|
||||
};
|
||||
|
||||
const getFormattedDate = (date: Date): Date => {
|
||||
const now = new Date();
|
||||
const hours = now.getHours();
|
||||
const minutes = now.getMinutes();
|
||||
const seconds = now.getSeconds();
|
||||
return add(date, { hours, minutes, seconds });
|
||||
};
|
||||
|
||||
export const CreateApiTokenForm: React.FC<Props> = (props) => {
|
||||
const { handleClose, neverExpires, toggleNeverExpires, onSubmit } = props;
|
||||
// states
|
||||
const [customDate, setCustomDate] = useState<Date | null>(null);
|
||||
// form
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
} = useForm<IApiToken>({ defaultValues });
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleFormSubmit = async (data: IApiToken) => {
|
||||
// if never expires is toggled off, and the user has not selected a custom date or a predefined date, show an error
|
||||
if (!neverExpires && (!data.expired_at || (data.expired_at === "custom" && !customDate)))
|
||||
return setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Please select an expiration date.",
|
||||
});
|
||||
|
||||
const payload: Partial<IApiToken> = {
|
||||
label: data.label,
|
||||
description: data.description,
|
||||
};
|
||||
|
||||
// if never expires is toggled on, set expired_at to null
|
||||
if (neverExpires) payload.expired_at = null;
|
||||
// if never expires is toggled off, and the user has selected a custom date, set expired_at to the custom date
|
||||
else if (data.expired_at === "custom") {
|
||||
payload.expired_at = customDate && getFormattedDate(customDate).toISOString();
|
||||
}
|
||||
// if never expires is toggled off, and the user has selected a predefined date, set expired_at to the predefined date
|
||||
else {
|
||||
const expiryDate = getExpiryDate(data.expired_at ?? "");
|
||||
if (expiryDate) payload.expired_at = expiryDate.toISOString();
|
||||
}
|
||||
|
||||
await onSubmit(payload).then(() => {
|
||||
reset(defaultValues);
|
||||
setCustomDate(null);
|
||||
});
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
const tomorrow = add(today, { days: 1 });
|
||||
const expiredAt = watch("expired_at");
|
||||
const expiryDate = getExpiryDate(expiredAt ?? "");
|
||||
const customDateFormatted = customDate && getFormattedDate(customDate);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="space-y-5 p-5">
|
||||
<h3 className="text-xl font-medium text-custom-text-200">
|
||||
{t("workspace_settings.settings.api_tokens.create_token")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="label"
|
||||
rules={{
|
||||
required: t("title_is_required"),
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: t("title_should_be_less_than_255_characters"),
|
||||
},
|
||||
validate: (val) => val.trim() !== "" || t("title_is_required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.label)}
|
||||
placeholder={t("title")}
|
||||
className="w-full text-base"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.label && <span className="text-xs text-red-500">{errors.label.message}</span>}
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TextArea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.description)}
|
||||
placeholder={t("description")}
|
||||
className="w-full text-base resize-none min-h-24"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="expired_at"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const selectedOption = EXPIRY_DATE_OPTIONS.find((option) => option.key === value);
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
customButton={
|
||||
<div
|
||||
className={cn(
|
||||
"h-7 flex items-center gap-2 rounded border-[0.5px] border-custom-border-300 px-2 py-0.5",
|
||||
{
|
||||
"text-custom-text-400": neverExpires,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Calendar className="h-3 w-3" />
|
||||
{value === "custom"
|
||||
? "Custom date"
|
||||
: selectedOption
|
||||
? selectedOption.label
|
||||
: "Set expiration date"}
|
||||
</div>
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={neverExpires}
|
||||
>
|
||||
{EXPIRY_DATE_OPTIONS.map((option) => (
|
||||
<CustomSelect.Option key={option.key} value={option.key}>
|
||||
{option.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
<CustomSelect.Option value="custom">Custom</CustomSelect.Option>
|
||||
</CustomSelect>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{expiredAt === "custom" && (
|
||||
<div className="h-7">
|
||||
<DateDropdown
|
||||
value={customDate}
|
||||
onChange={(date) => setCustomDate(date)}
|
||||
minDate={tomorrow}
|
||||
icon={<Calendar className="h-3 w-3" />}
|
||||
buttonVariant="border-with-text"
|
||||
placeholder="Set date"
|
||||
disabled={neverExpires}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!neverExpires && (
|
||||
<span className="text-xs text-custom-text-400">
|
||||
{expiredAt === "custom"
|
||||
? customDate
|
||||
? `Expires ${renderFormattedDate(customDateFormatted ?? "")} at ${renderFormattedTime(customDateFormatted ?? "")}`
|
||||
: null
|
||||
: expiredAt
|
||||
? `Expires ${renderFormattedDate(expiryDate ?? "")} at ${renderFormattedTime(expiryDate ?? "")}`
|
||||
: null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-between gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<div className="flex cursor-pointer items-center gap-1.5" onClick={toggleNeverExpires}>
|
||||
<div className="flex cursor-pointer items-center justify-center">
|
||||
<ToggleSwitch value={neverExpires} onChange={() => {}} size="sm" />
|
||||
</div>
|
||||
<span className="text-xs">{t("workspace_settings.settings.api_tokens.never_expires")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{isSubmitting
|
||||
? t("workspace_settings.settings.api_tokens.generating")
|
||||
: t("workspace_settings.settings.api_tokens.generate_token")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { Copy } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IApiToken } from "@plane/types";
|
||||
// ui
|
||||
import { renderFormattedDate, renderFormattedTime, copyTextToClipboard } from "@plane/utils";
|
||||
// helpers
|
||||
// types
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// hooks
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
tokenDetails: IApiToken;
|
||||
};
|
||||
|
||||
export const GeneratedTokenDetails: React.FC<Props> = (props) => {
|
||||
const { handleClose, tokenDetails } = props;
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { t } = useTranslation();
|
||||
const copyApiToken = (token: string) => {
|
||||
copyTextToClipboard(token).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: `${t("success")}!`,
|
||||
message: t("workspace_settings.token_copied"),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full p-5">
|
||||
<div className="w-full space-y-3 text-wrap">
|
||||
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{t("workspace_settings.key_created")}</h3>
|
||||
<p className="text-sm text-custom-text-400">{t("workspace_settings.copy_key")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyApiToken(tokenDetails.token ?? "")}
|
||||
className="mt-4 flex truncate w-full items-center justify-between rounded-md border-[0.5px] border-custom-border-200 px-3 py-2 text-sm font-medium outline-none"
|
||||
>
|
||||
<span className="truncate pr-2">{tokenDetails.token}</span>
|
||||
<Tooltip tooltipContent="Copy secret key" isMobile={isMobile}>
|
||||
<Copy className="h-4 w-4 text-custom-text-400 flex-shrink-0" />
|
||||
</Tooltip>
|
||||
</button>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<p className="text-xs text-custom-text-400">
|
||||
{tokenDetails.expired_at
|
||||
? `Expires ${renderFormattedDate(tokenDetails.expired_at!)} at ${renderFormattedTime(tokenDetails.expired_at!)}`
|
||||
: "Never expires"}
|
||||
</p>
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
64
apps/web/core/components/api-token/token-list-item.tsx
Normal file
64
apps/web/core/components/api-token/token-list-item.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { XCircle } from "lucide-react";
|
||||
// plane imports
|
||||
import { PROFILE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IApiToken } from "@plane/types";
|
||||
import { renderFormattedDate, calculateTimeAgo, renderFormattedTime } from "@plane/utils";
|
||||
// components
|
||||
import { DeleteApiTokenModal } from "@/components/api-token/delete-token-modal";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type Props = {
|
||||
token: IApiToken;
|
||||
};
|
||||
|
||||
export const ApiTokenListItem: React.FC<Props> = (props) => {
|
||||
const { token } = props;
|
||||
// states
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteApiTokenModal isOpen={deleteModalOpen} onClose={() => setDeleteModalOpen(false)} tokenId={token.id} />
|
||||
<div className="group relative flex flex-col justify-center border-b border-custom-border-200 py-3">
|
||||
<Tooltip tooltipContent="Delete token" isMobile={isMobile}>
|
||||
<button
|
||||
onClick={() => setDeleteModalOpen(true)}
|
||||
className="absolute right-4 hidden place-items-center group-hover:grid"
|
||||
data-ph-element={PROFILE_SETTINGS_TRACKER_ELEMENTS.LIST_ITEM_DELETE_ICON}
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="flex w-4/5 items-center">
|
||||
<h5 className="truncate text-sm font-medium">{token.label}</h5>
|
||||
<span
|
||||
className={`${
|
||||
token.is_active ? "bg-green-500/10 text-green-500" : "bg-custom-background-80 text-custom-text-400"
|
||||
} ml-2 flex h-4 max-h-fit items-center rounded-sm px-2 text-xs font-medium`}
|
||||
>
|
||||
{token.is_active ? "Active" : "Expired"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex w-full flex-col justify-center">
|
||||
{token.description.trim() !== "" && (
|
||||
<p className="mb-1 max-w-[70%] break-words text-sm">{token.description}</p>
|
||||
)}
|
||||
<p className="mb-1 text-xs leading-6 text-custom-text-400">
|
||||
{token.is_active
|
||||
? token.expired_at
|
||||
? `Expires ${renderFormattedDate(token.expired_at!)} at ${renderFormattedTime(token.expired_at!)}`
|
||||
: "Never expires"
|
||||
: `Expired ${calculateTimeAgo(token.expired_at)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
64
apps/web/core/components/archives/archive-tabs-list.tsx
Normal file
64
apps/web/core/components/archives/archive-tabs-list.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// types
|
||||
import type { IProject } from "@plane/types";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
|
||||
const ARCHIVES_TAB_LIST: {
|
||||
key: string;
|
||||
label: string;
|
||||
shouldRender: (projectDetails: IProject) => boolean;
|
||||
}[] = [
|
||||
{
|
||||
key: "issues",
|
||||
label: "Work items",
|
||||
shouldRender: () => true,
|
||||
},
|
||||
{
|
||||
key: "cycles",
|
||||
label: "Cycles",
|
||||
shouldRender: (projectDetails) => projectDetails.cycle_view,
|
||||
},
|
||||
{
|
||||
key: "modules",
|
||||
label: "Modules",
|
||||
shouldRender: (projectDetails) => projectDetails.module_view,
|
||||
},
|
||||
];
|
||||
|
||||
export const ArchiveTabsList: FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
// derived values
|
||||
if (!projectId) return null;
|
||||
const projectDetails = getProjectById(projectId?.toString());
|
||||
if (!projectDetails) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ARCHIVES_TAB_LIST.map(
|
||||
(tab) =>
|
||||
tab.shouldRender(projectDetails) && (
|
||||
<Link key={tab.key} href={`/${workspaceSlug}/projects/${projectId}/archives/${tab.key}`}>
|
||||
<span
|
||||
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 py-4 px-4 text-sm font-medium outline-none ${
|
||||
pathname.includes(tab.key)
|
||||
? "border-custom-primary-100 text-custom-primary-100"
|
||||
: "border-transparent hover:border-custom-border-200 text-custom-text-300 hover:text-custom-text-400"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
1
apps/web/core/components/archives/index.ts
Normal file
1
apps/web/core/components/archives/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./archive-tabs-list";
|
||||
18
apps/web/core/components/auth-screens/auth-base.tsx
Normal file
18
apps/web/core/components/auth-screens/auth-base.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { AuthRoot } from "@/components/account/auth-forms/auth-root";
|
||||
import type { EAuthModes } from "@/helpers/authentication.helper";
|
||||
import { AuthFooter } from "./footer";
|
||||
import { AuthHeader } from "./header";
|
||||
|
||||
type AuthBaseProps = {
|
||||
authType: EAuthModes;
|
||||
};
|
||||
|
||||
export const AuthBase = ({ authType }: AuthBaseProps) => (
|
||||
<div className="relative z-10 flex flex-col items-center w-screen h-screen overflow-hidden overflow-y-auto pt-6 pb-10 px-8">
|
||||
<AuthHeader type={authType} />
|
||||
<AuthRoot authMode={authType} />
|
||||
<AuthFooter />
|
||||
</div>
|
||||
);
|
||||
38
apps/web/core/components/auth-screens/footer.tsx
Normal file
38
apps/web/core/components/auth-screens/footer.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { AccentureLogo, DolbyLogo, SonyLogo, ZerodhaLogo } from "@plane/propel/icons";
|
||||
|
||||
const BRAND_LOGOS: {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
id: "zerodha",
|
||||
icon: <ZerodhaLogo className="h-7 w-24 text-[#387ED1]" />,
|
||||
},
|
||||
{
|
||||
id: "sony",
|
||||
icon: <SonyLogo className="h-7 w-16 dark:text-white" />,
|
||||
},
|
||||
{
|
||||
id: "dolby",
|
||||
icon: <DolbyLogo className="h-7 w-16 dark:text-white" />,
|
||||
},
|
||||
{
|
||||
id: "accenture",
|
||||
icon: <AccentureLogo className="h-7 w-24 dark:text-white" />,
|
||||
},
|
||||
];
|
||||
|
||||
export const AuthFooter = () => (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<span className="text-sm text-custom-text-300 whitespace-nowrap">Join 10,000+ teams building with Plane</span>
|
||||
<div className="flex items-center justify-center gap-x-10 gap-y-4 w-full flex-wrap">
|
||||
{BRAND_LOGOS.map((brand) => (
|
||||
<div className="flex items-center justify-center h-7 flex-1" key={brand.id}>
|
||||
{brand.icon}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
60
apps/web/core/components/auth-screens/header.tsx
Normal file
60
apps/web/core/components/auth-screens/header.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { AUTH_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PlaneLockup } from "@plane/propel/icons";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { EAuthModes } from "@/helpers/authentication.helper";
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
|
||||
const authContentMap = {
|
||||
[EAuthModes.SIGN_IN]: {
|
||||
pageTitle: "Sign up",
|
||||
text: "auth.common.new_to_plane",
|
||||
linkText: "Sign up",
|
||||
linkHref: "/sign-up",
|
||||
},
|
||||
[EAuthModes.SIGN_UP]: {
|
||||
pageTitle: "Sign in",
|
||||
text: "auth.common.already_have_an_account",
|
||||
linkText: "Sign in",
|
||||
linkHref: "/sign-in",
|
||||
},
|
||||
};
|
||||
|
||||
type AuthHeaderProps = {
|
||||
type: EAuthModes;
|
||||
};
|
||||
|
||||
export const AuthHeader = observer(({ type }: AuthHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
// store
|
||||
const { config } = useInstance();
|
||||
// derived values
|
||||
const enableSignUpConfig = config?.enable_signup ?? false;
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t(authContentMap[type].pageTitle) + " - Plane"} />
|
||||
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
|
||||
<Link href="/">
|
||||
<PlaneLockup height={20} width={95} className="text-custom-text-100" />
|
||||
</Link>
|
||||
{enableSignUpConfig && (
|
||||
<div className="flex flex-col items-end text-sm font-medium text-center sm:items-center sm:gap-2 sm:flex-row text-custom-text-300">
|
||||
{t(authContentMap[type].text)}
|
||||
<Link
|
||||
data-ph-element={AUTH_TRACKER_ELEMENTS.NAVIGATE_TO_SIGN_UP}
|
||||
href={authContentMap[type].linkHref}
|
||||
className="font-semibold text-custom-primary-100 hover:underline"
|
||||
>
|
||||
{t(authContentMap[type].linkText)}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
// layouts
|
||||
import DefaultLayout from "@/layouts/default-layout";
|
||||
// images
|
||||
import ProjectNotAuthorizedImg from "@/public/auth/project-not-authorized.svg";
|
||||
import Unauthorized from "@/public/auth/unauthorized.svg";
|
||||
import WorkspaceNotAuthorizedImg from "@/public/auth/workspace-not-authorized.svg";
|
||||
|
||||
type Props = {
|
||||
actionButton?: React.ReactNode;
|
||||
section?: "settings" | "general";
|
||||
isProjectView?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const NotAuthorizedView: React.FC<Props> = observer((props) => {
|
||||
const { actionButton, section = "general", isProjectView = false, className } = props;
|
||||
|
||||
// assets
|
||||
const settingAsset = isProjectView ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg;
|
||||
const asset = section === "settings" ? settingAsset : Unauthorized;
|
||||
|
||||
return (
|
||||
<DefaultLayout className={className}>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-custom-background-100 text-center">
|
||||
<div className="h-44 w-72">
|
||||
<Image src={asset} height="176" width="288" alt="ProjectSettingImg" />
|
||||
</div>
|
||||
<h1 className="text-xl font-medium text-custom-text-100">Oops! You are not authorized to view this page</h1>
|
||||
{actionButton}
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
// plane imports
|
||||
import { Button } from "@plane/propel/button";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// assets
|
||||
import Unauthorized from "@/public/auth/unauthorized.svg";
|
||||
|
||||
type Props = {
|
||||
projectId?: string;
|
||||
isPrivateProject?: boolean;
|
||||
};
|
||||
|
||||
export const JoinProject: React.FC<Props> = (props) => {
|
||||
const { projectId, isPrivateProject = false } = props;
|
||||
// states
|
||||
const [isJoiningProject, setIsJoiningProject] = useState(false);
|
||||
// store hooks
|
||||
const { joinProject } = useUserPermissions();
|
||||
const { fetchProjectDetails } = useProject();
|
||||
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
const handleJoin = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setIsJoiningProject(true);
|
||||
|
||||
joinProject(workspaceSlug.toString(), projectId.toString())
|
||||
.then(() => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()))
|
||||
.finally(() => setIsJoiningProject(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-custom-background-100 text-center">
|
||||
<div className="h-44 w-72">
|
||||
<Image src={Unauthorized} height="176" width="288" alt="JoinProject" />
|
||||
</div>
|
||||
<h1 className="text-xl font-medium text-custom-text-100">
|
||||
{!isPrivateProject ? `You are not a member of this project yet.` : `You are not a member of this project.`}
|
||||
</h1>
|
||||
|
||||
<div className="w-full max-w-md text-base text-custom-text-200">
|
||||
<p className="mx-auto w-full text-sm md:w-3/4">
|
||||
{!isPrivateProject
|
||||
? `Click the button below to join it.`
|
||||
: `This is a private project. \n We can't tell you more about this project to protect confidentiality.`}
|
||||
</p>
|
||||
</div>
|
||||
{!isPrivateProject && (
|
||||
<div>
|
||||
<Button
|
||||
variant="primary"
|
||||
prependIcon={<ClipboardList color="white" />}
|
||||
loading={isJoiningProject}
|
||||
onClick={handleJoin}
|
||||
>
|
||||
{isJoiningProject ? "Taking you in" : "Click to join"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
// ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
// layouts
|
||||
import DefaultLayout from "@/layouts/default-layout";
|
||||
|
||||
export const NotAWorkspaceMember = () => (
|
||||
<DefaultLayout>
|
||||
<div className="grid h-full place-items-center p-4">
|
||||
<div className="space-y-8 text-center">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Not Authorized!</h3>
|
||||
<p className="mx-auto w-1/2 text-sm text-custom-text-200">
|
||||
You{"'"}re not a member of this workspace. Please contact the workspace admin to get an invitation or check
|
||||
your pending invitations.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Link href="/invitations">
|
||||
<span>
|
||||
<Button variant="neutral-primary">Check pending invites</Button>
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/create-workspace">
|
||||
<span>
|
||||
<Button variant="primary">Create new workspace</Button>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
149
apps/web/core/components/automation/auto-archive-automation.tsx
Normal file
149
apps/web/core/components/automation/auto-archive-automation.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ArchiveRestore } from "lucide-react";
|
||||
// types
|
||||
import {
|
||||
PROJECT_AUTOMATION_MONTHS,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
PROJECT_SETTINGS_TRACKER_ELEMENTS,
|
||||
PROJECT_SETTINGS_TRACKER_EVENTS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IProject } from "@plane/types";
|
||||
// ui
|
||||
import { CustomSelect, Loader, ToggleSwitch } from "@plane/ui";
|
||||
// component
|
||||
import { SelectMonthModal } from "@/components/automation";
|
||||
// constants
|
||||
// hooks
|
||||
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
||||
type Props = {
|
||||
handleChange: (formData: Partial<IProject>) => Promise<void>;
|
||||
};
|
||||
|
||||
const initialValues: Partial<IProject> = { archive_in: 1 };
|
||||
|
||||
export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
|
||||
const { handleChange } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// states
|
||||
const [monthModal, setmonthModal] = useState(false);
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
const isAdmin = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug?.toString(),
|
||||
currentProjectDetails?.id
|
||||
);
|
||||
|
||||
const autoArchiveStatus = useMemo(() => {
|
||||
if (currentProjectDetails?.archive_in === undefined) return false;
|
||||
return currentProjectDetails.archive_in !== 0;
|
||||
}, [currentProjectDetails]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectMonthModal
|
||||
type="auto-archive"
|
||||
initialValues={initialValues}
|
||||
isOpen={monthModal}
|
||||
handleClose={() => setmonthModal(false)}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 border-b border-custom-border-100 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center rounded bg-custom-background-90 p-3">
|
||||
<ArchiveRestore className="h-4 w-4 flex-shrink-0 text-custom-text-100" />
|
||||
</div>
|
||||
<div className="">
|
||||
<h4 className="text-sm font-medium">{t("project_settings.automations.auto-archive.title")}</h4>
|
||||
<p className="text-sm tracking-tight text-custom-text-200">
|
||||
{t("project_settings.automations.auto-archive.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={autoArchiveStatus}
|
||||
onChange={async () => {
|
||||
if (currentProjectDetails?.archive_in === 0) {
|
||||
await handleChange({ archive_in: 1 });
|
||||
} else {
|
||||
await handleChange({ archive_in: 0 });
|
||||
}
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.AUTOMATIONS_ARCHIVE_TOGGLE_BUTTON,
|
||||
},
|
||||
event: {
|
||||
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.auto_archive_workitems,
|
||||
state: "SUCCESS",
|
||||
},
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{currentProjectDetails ? (
|
||||
autoArchiveStatus && (
|
||||
<div className="mx-6">
|
||||
<div className="flex w-full items-center justify-between gap-2 rounded border border-custom-border-200 bg-custom-background-90 px-5 py-4">
|
||||
<div className="w-1/2 text-sm font-medium">
|
||||
{t("project_settings.automations.auto-archive.duration")}
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<CustomSelect
|
||||
value={currentProjectDetails?.archive_in}
|
||||
label={`${currentProjectDetails?.archive_in} ${
|
||||
currentProjectDetails?.archive_in === 1 ? "month" : "months"
|
||||
}`}
|
||||
onChange={(val: number) => {
|
||||
handleChange({ archive_in: val });
|
||||
}}
|
||||
input
|
||||
disabled={!isAdmin}
|
||||
>
|
||||
<>
|
||||
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
||||
<CustomSelect.Option key={month.i18n_label} value={month.value}>
|
||||
<span className="text-sm">{t(month.i18n_label, { months: month.value })}</span>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={() => setmonthModal(true)}
|
||||
>
|
||||
{t("common.customize_time_range")}
|
||||
</button>
|
||||
</>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="mx-6">
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
218
apps/web/core/components/automation/auto-close-automation.tsx
Normal file
218
apps/web/core/components/automation/auto-close-automation.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { ArchiveX } from "lucide-react";
|
||||
// types
|
||||
import {
|
||||
PROJECT_AUTOMATION_MONTHS,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
EIconSize,
|
||||
PROJECT_SETTINGS_TRACKER_ELEMENTS,
|
||||
PROJECT_SETTINGS_TRACKER_EVENTS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { StateGroupIcon, DoubleCircleIcon } from "@plane/propel/icons";
|
||||
import type { IProject } from "@plane/types";
|
||||
// ui
|
||||
import { CustomSelect, CustomSearchSelect, ToggleSwitch, Loader } from "@plane/ui";
|
||||
// component
|
||||
import { SelectMonthModal } from "@/components/automation";
|
||||
// constants
|
||||
// hooks
|
||||
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
||||
type Props = {
|
||||
handleChange: (formData: Partial<IProject>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
|
||||
const { handleChange } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// states
|
||||
const [monthModal, setmonthModal] = useState(false);
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// const stateGroups = projectStateStore.groupedProjectStates ?? undefined;
|
||||
|
||||
const options = projectStates
|
||||
?.filter((state) => state.group === "cancelled")
|
||||
.map((state) => ({
|
||||
value: state.id,
|
||||
query: state.name,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} size={EIconSize.LG} />
|
||||
{state.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const multipleOptions = (options ?? []).length > 1;
|
||||
|
||||
const defaultState = projectStates?.find((s) => s.group === "cancelled")?.id || null;
|
||||
|
||||
const selectedOption = projectStates?.find((s) => s.id === (currentProjectDetails?.default_state ?? defaultState));
|
||||
const currentDefaultState = projectStates?.find((s) => s.id === defaultState);
|
||||
|
||||
const initialValues: Partial<IProject> = {
|
||||
close_in: 1,
|
||||
default_state: defaultState,
|
||||
};
|
||||
|
||||
const isAdmin = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug?.toString(),
|
||||
currentProjectDetails?.id
|
||||
);
|
||||
|
||||
const autoCloseStatus = useMemo(() => {
|
||||
if (currentProjectDetails?.close_in === undefined) return false;
|
||||
return currentProjectDetails.close_in !== 0;
|
||||
}, [currentProjectDetails]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectMonthModal
|
||||
type="auto-close"
|
||||
initialValues={initialValues}
|
||||
isOpen={monthModal}
|
||||
handleClose={() => setmonthModal(false)}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center rounded bg-custom-background-90 p-3">
|
||||
<ArchiveX className="h-4 w-4 flex-shrink-0 text-red-500" />
|
||||
</div>
|
||||
<div className="">
|
||||
<h4 className="text-sm font-medium">{t("project_settings.automations.auto-close.title")}</h4>
|
||||
<p className="text-sm tracking-tight text-custom-text-200">
|
||||
{t("project_settings.automations.auto-close.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={autoCloseStatus}
|
||||
onChange={async () => {
|
||||
if (currentProjectDetails?.close_in === 0) {
|
||||
await handleChange({ close_in: 1, default_state: defaultState });
|
||||
} else {
|
||||
await handleChange({ close_in: 0, default_state: null });
|
||||
}
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.AUTOMATIONS_CLOSE_TOGGLE_BUTTON,
|
||||
},
|
||||
event: {
|
||||
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.auto_close_workitems,
|
||||
state: "SUCCESS",
|
||||
},
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{currentProjectDetails ? (
|
||||
autoCloseStatus && (
|
||||
<div className="mx-6">
|
||||
<div className="flex flex-col rounded border border-custom-border-200 bg-custom-background-90">
|
||||
<div className="flex w-full items-center justify-between gap-2 px-5 py-4">
|
||||
<div className="w-1/2 text-sm font-medium">
|
||||
{t("project_settings.automations.auto-close.duration")}
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<CustomSelect
|
||||
value={currentProjectDetails?.close_in}
|
||||
label={`${currentProjectDetails?.close_in} ${
|
||||
currentProjectDetails?.close_in === 1 ? "month" : "months"
|
||||
}`}
|
||||
onChange={(val: number) => {
|
||||
handleChange({ close_in: val });
|
||||
}}
|
||||
input
|
||||
disabled={!isAdmin}
|
||||
>
|
||||
<>
|
||||
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
||||
<CustomSelect.Option key={month.i18n_label} value={month.value}>
|
||||
{t(month.i18n_label, { months: month.value })}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={() => setmonthModal(true)}
|
||||
>
|
||||
{t("common.customize_time_range")}
|
||||
</button>
|
||||
</>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ppy sm:py-10 flex w-full items-center justify-between gap-2 px-5 py-4">
|
||||
<div className="w-1/2 text-sm font-medium">
|
||||
{t("project_settings.automations.auto-close.auto_close_status")}
|
||||
</div>
|
||||
<div className="w-1/2 ">
|
||||
<CustomSearchSelect
|
||||
value={currentProjectDetails?.default_state ?? defaultState}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedOption ? (
|
||||
<StateGroupIcon
|
||||
stateGroup={selectedOption.group}
|
||||
color={selectedOption.color}
|
||||
size={EIconSize.LG}
|
||||
/>
|
||||
) : currentDefaultState ? (
|
||||
<StateGroupIcon
|
||||
stateGroup={currentDefaultState.group}
|
||||
color={currentDefaultState.color}
|
||||
size={EIconSize.LG}
|
||||
/>
|
||||
) : (
|
||||
<DoubleCircleIcon className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
)}
|
||||
{selectedOption?.name
|
||||
? selectedOption.name
|
||||
: (currentDefaultState?.name ?? <span className="text-custom-text-200">{t("state")}</span>)}
|
||||
</div>
|
||||
}
|
||||
onChange={(val: string) => {
|
||||
handleChange({ default_state: val });
|
||||
}}
|
||||
options={options}
|
||||
disabled={!multipleOptions}
|
||||
input
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="mx-6">
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
3
apps/web/core/components/automation/index.ts
Normal file
3
apps/web/core/components/automation/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./auto-close-automation";
|
||||
export * from "./auto-archive-automation";
|
||||
export * from "./select-month-modal";
|
||||
168
apps/web/core/components/automation/select-month-modal.tsx
Normal file
168
apps/web/core/components/automation/select-month-modal.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { IProject } from "@plane/types";
|
||||
// ui
|
||||
import { Input } from "@plane/ui";
|
||||
// types
|
||||
|
||||
// types
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
type: "auto-close" | "auto-archive";
|
||||
initialValues: Partial<IProject>;
|
||||
handleClose: () => void;
|
||||
handleChange: (formData: Partial<IProject>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen, handleClose, handleChange }) => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
} = useForm<IProject>({
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
reset(initialValues);
|
||||
};
|
||||
|
||||
const onSubmit = (formData: Partial<IProject>) => {
|
||||
if (!workspaceSlug && !projectId) return;
|
||||
handleChange(formData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-30" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 px-4 pb-4 pt-5 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
Customize time range
|
||||
</Dialog.Title>
|
||||
<div className="mt-8 flex items-center gap-2">
|
||||
<div className="flex w-full flex-col justify-center gap-1">
|
||||
{type === "auto-close" ? (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="close_in"
|
||||
rules={{
|
||||
required: "Select a month between 1 and 12.",
|
||||
min: 1,
|
||||
max: 12,
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<div className="relative flex w-full flex-col justify-center gap-1">
|
||||
<Input
|
||||
id="close_in"
|
||||
name="close_in"
|
||||
type="number"
|
||||
value={value?.toString()}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.close_in)}
|
||||
placeholder="Enter Months"
|
||||
className="w-full border-custom-border-200"
|
||||
min={1}
|
||||
max={12}
|
||||
/>
|
||||
<span className="absolute right-8 top-2.5 text-sm text-custom-text-200">Months</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{errors.close_in && (
|
||||
<span className="px-1 text-sm text-red-500">Select a month between 1 and 12.</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="archive_in"
|
||||
rules={{
|
||||
required: "Select a month between 1 and 12.",
|
||||
min: 1,
|
||||
max: 12,
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<div className="relative flex w-full flex-col justify-center gap-1">
|
||||
<Input
|
||||
id="archive_in"
|
||||
name="archive_in"
|
||||
type="number"
|
||||
value={value?.toString()}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.archive_in)}
|
||||
placeholder="Enter Months"
|
||||
className="w-full border-custom-border-200"
|
||||
min={1}
|
||||
max={12}
|
||||
/>
|
||||
<span className="absolute right-8 top-2.5 text-sm text-custom-text-200">Months</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.archive_in && (
|
||||
<span className="px-1 text-sm text-red-500">Select a month between 1 and 12.</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Submitting..." : "Submit"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
172
apps/web/core/components/chart/utils.ts
Normal file
172
apps/web/core/components/chart/utils.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { getWeekOfMonth, isValid } from "date-fns";
|
||||
import { CHART_X_AXIS_DATE_PROPERTIES, ChartXAxisDateGrouping, TO_CAPITALIZE_PROPERTIES } from "@plane/constants";
|
||||
import type { ChartXAxisProperty, TChart, TChartDatum } from "@plane/types";
|
||||
import {
|
||||
capitalizeFirstLetter,
|
||||
hexToHsl,
|
||||
hslToHex,
|
||||
renderFormattedDate,
|
||||
renderFormattedDateWithoutYear,
|
||||
} from "@plane/utils";
|
||||
//
|
||||
|
||||
const getDateGroupingName = (date: string, dateGrouping: ChartXAxisDateGrouping): string => {
|
||||
if (!date || ["none", "null"].includes(date.toLowerCase())) return "None";
|
||||
|
||||
const formattedData = new Date(date);
|
||||
const isValidDate = isValid(formattedData);
|
||||
|
||||
if (!isValidDate) return date;
|
||||
|
||||
const year = formattedData.getFullYear();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const isCurrentYear = year === currentYear;
|
||||
|
||||
let parsedName: string | undefined;
|
||||
|
||||
switch (dateGrouping) {
|
||||
case ChartXAxisDateGrouping.DAY:
|
||||
if (isCurrentYear) parsedName = renderFormattedDateWithoutYear(formattedData);
|
||||
else parsedName = renderFormattedDate(formattedData);
|
||||
break;
|
||||
case ChartXAxisDateGrouping.WEEK: {
|
||||
const month = renderFormattedDate(formattedData, "MMM");
|
||||
parsedName = `${month}, Week ${getWeekOfMonth(formattedData)}`;
|
||||
break;
|
||||
}
|
||||
case ChartXAxisDateGrouping.MONTH:
|
||||
if (isCurrentYear) parsedName = renderFormattedDate(formattedData, "MMM");
|
||||
else parsedName = renderFormattedDate(formattedData, "MMM, yyyy");
|
||||
break;
|
||||
case ChartXAxisDateGrouping.YEAR:
|
||||
parsedName = `${year}`;
|
||||
break;
|
||||
default:
|
||||
parsedName = date;
|
||||
}
|
||||
|
||||
return parsedName ?? date;
|
||||
};
|
||||
|
||||
export const parseChartData = (
|
||||
data: TChart | null | undefined,
|
||||
xAxisProperty: ChartXAxisProperty | null | undefined,
|
||||
groupByProperty: ChartXAxisProperty | null | undefined,
|
||||
xAxisDateGrouping: ChartXAxisDateGrouping | null | undefined
|
||||
): TChart => {
|
||||
if (!data) {
|
||||
return {
|
||||
data: [],
|
||||
schema: {},
|
||||
};
|
||||
}
|
||||
const widgetData = structuredClone(data.data);
|
||||
const schema = structuredClone(data.schema);
|
||||
const allKeys = Object.keys(schema);
|
||||
const updatedWidgetData: TChartDatum[] = widgetData.map((datum) => {
|
||||
const keys = Object.keys(datum);
|
||||
const missingKeys = allKeys.filter((key) => !keys.includes(key));
|
||||
const missingValues: Record<string, number> = Object.fromEntries(missingKeys.map((key) => [key, 0]));
|
||||
|
||||
if (xAxisProperty) {
|
||||
// capitalize first letter if xAxisProperty is in TO_CAPITALIZE_PROPERTIES and no groupByProperty is set
|
||||
if (TO_CAPITALIZE_PROPERTIES.includes(xAxisProperty)) {
|
||||
datum.name = capitalizeFirstLetter(datum.name);
|
||||
}
|
||||
|
||||
// parse timestamp to visual date if xAxisProperty is in WIDGET_X_AXIS_DATE_PROPERTIES
|
||||
if (CHART_X_AXIS_DATE_PROPERTIES.includes(xAxisProperty)) {
|
||||
datum.name = getDateGroupingName(datum.name, xAxisDateGrouping ?? ChartXAxisDateGrouping.DAY);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...datum,
|
||||
...missingValues,
|
||||
};
|
||||
});
|
||||
|
||||
// capitalize first letter if groupByProperty is in TO_CAPITALIZE_PROPERTIES
|
||||
const updatedSchema = schema;
|
||||
if (groupByProperty) {
|
||||
if (TO_CAPITALIZE_PROPERTIES.includes(groupByProperty)) {
|
||||
Object.keys(updatedSchema).forEach((key) => {
|
||||
updatedSchema[key] = capitalizeFirstLetter(updatedSchema[key]);
|
||||
});
|
||||
}
|
||||
|
||||
if (CHART_X_AXIS_DATE_PROPERTIES.includes(groupByProperty)) {
|
||||
Object.keys(updatedSchema).forEach((key) => {
|
||||
updatedSchema[key] = getDateGroupingName(updatedSchema[key], xAxisDateGrouping ?? ChartXAxisDateGrouping.DAY);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: updatedWidgetData,
|
||||
schema: updatedSchema,
|
||||
};
|
||||
};
|
||||
|
||||
export const generateExtendedColors = (baseColorSet: string[], targetCount: number) => {
|
||||
const colors = [...baseColorSet];
|
||||
const baseCount = baseColorSet.length;
|
||||
|
||||
if (targetCount <= baseCount) {
|
||||
return colors.slice(0, targetCount);
|
||||
}
|
||||
|
||||
// Convert base colors to HSL
|
||||
const baseHSL = baseColorSet.map(hexToHsl);
|
||||
|
||||
// Calculate average saturation and lightness from base colors
|
||||
const avgSat = baseHSL.reduce((sum, hsl) => sum + hsl.s, 0) / baseHSL.length;
|
||||
const avgLight = baseHSL.reduce((sum, hsl) => sum + hsl.l, 0) / baseHSL.length;
|
||||
|
||||
// Sort base colors by hue for better distribution
|
||||
const sortedBaseHSL = [...baseHSL].sort((a, b) => a.h - b.h);
|
||||
|
||||
// Generate additional colors for each base color
|
||||
const colorsNeeded = targetCount - baseCount;
|
||||
const colorsPerBase = Math.ceil(colorsNeeded / baseCount);
|
||||
|
||||
for (let i = 0; i < baseCount; i++) {
|
||||
const baseColor = sortedBaseHSL[i];
|
||||
const nextBaseColor = sortedBaseHSL[(i + 1) % baseCount];
|
||||
|
||||
// Calculate hue distance to next base color
|
||||
const hueDistance = (nextBaseColor.h - baseColor.h + 360) % 360;
|
||||
const hueParts = colorsPerBase + 1;
|
||||
|
||||
// Narrower ranges for more consistency
|
||||
const satRange = [Math.max(40, avgSat - 5), Math.min(60, avgSat + 5)];
|
||||
const lightRange = [Math.max(40, avgLight - 5), Math.min(60, avgLight + 5)];
|
||||
|
||||
for (let j = 1; j <= colorsPerBase; j++) {
|
||||
if (colors.length >= targetCount) break;
|
||||
|
||||
// Create evenly spaced hue variations between base colors
|
||||
const hueStep = (hueDistance / hueParts) * j;
|
||||
const newHue = (baseColor.h + hueStep) % 360;
|
||||
|
||||
// Keep saturation and lightness closer to base color
|
||||
const newSat = baseColor.s * 0.8 + avgSat * 0.2;
|
||||
const newLight = baseColor.l * 0.8 + avgLight * 0.2;
|
||||
|
||||
// Ensure values stay within desired ranges
|
||||
const finalSat = Math.max(satRange[0], Math.min(satRange[1], newSat));
|
||||
const finalLight = Math.max(lightRange[0], Math.min(lightRange[1], newLight));
|
||||
|
||||
colors.push(
|
||||
hslToHex({
|
||||
h: newHue,
|
||||
s: finalSat,
|
||||
l: finalLight,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return colors.slice(0, targetCount);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { GithubIcon, MessageSquare, Rocket } from "lucide-react";
|
||||
// ui
|
||||
import { DiscordIcon, PageIcon } from "@plane/propel/icons";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useTransient } from "@/hooks/store/use-transient";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteHelpActions: React.FC<Props> = observer((props) => {
|
||||
const { closePalette } = props;
|
||||
// hooks
|
||||
const { toggleShortcutModal } = useCommandPalette();
|
||||
const { toggleIntercom } = useTransient();
|
||||
|
||||
return (
|
||||
<Command.Group heading="Help">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
toggleShortcutModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Rocket className="h-3.5 w-3.5" />
|
||||
Open keyboard shortcuts
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
window.open("https://docs.plane.so/", "_blank");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<PageIcon className="h-3.5 w-3.5" />
|
||||
Open Plane documentation
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<DiscordIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
||||
Join our Discord
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<GithubIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
||||
Report a bug
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
toggleIntercom(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Chat with us
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
25
apps/web/core/components/command-palette/actions/helper.tsx
Normal file
25
apps/web/core/components/command-palette/actions/helper.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { store } from "@/lib/store-context";
|
||||
|
||||
export const openProjectAndScrollToSidebar = (itemProjectId: string | undefined) => {
|
||||
if (!itemProjectId) {
|
||||
console.warn("No project id provided. Cannot open project and scroll to sidebar.");
|
||||
return;
|
||||
}
|
||||
// open the project list
|
||||
store.commandPalette.toggleProjectListOpen(itemProjectId, true);
|
||||
// scroll to the element
|
||||
const scrollElementId = `sidebar-${itemProjectId}-JOINED`;
|
||||
const scrollElement = document.getElementById(scrollElementId);
|
||||
// if the element exists, scroll to it
|
||||
if (scrollElement) {
|
||||
setTimeout(() => {
|
||||
scrollElement.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
// Restart the highlight animation every time
|
||||
scrollElement.style.animation = "none";
|
||||
// Trigger a reflow to ensure the animation is restarted
|
||||
void scrollElement.offsetWidth;
|
||||
// Restart the highlight animation
|
||||
scrollElement.style.animation = "highlight 2s ease-in-out";
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./issue-actions";
|
||||
export * from "./help-actions";
|
||||
export * from "./project-actions";
|
||||
export * from "./search-results";
|
||||
export * from "./theme-actions";
|
||||
export * from "./workspace-settings-actions";
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users } from "lucide-react";
|
||||
import { DoubleCircleIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// hooks
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "@plane/utils";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
issueDetails: TIssue | undefined;
|
||||
pages: string[];
|
||||
setPages: (pages: string[]) => void;
|
||||
setPlaceholder: (placeholder: string) => void;
|
||||
setSearchTerm: (searchTerm: string) => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteIssueActions: React.FC<Props> = observer((props) => {
|
||||
const { closePalette, issueDetails, pages, setPages, setPlaceholder, setSearchTerm } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// hooks
|
||||
const { updateIssue } = useIssueDetail(issueDetails?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
|
||||
const { toggleCommandPaletteModal, toggleDeleteIssueModal } = useCommandPalette();
|
||||
const { data: currentUser } = useUser();
|
||||
// derived values
|
||||
const issueId = issueDetails?.id;
|
||||
const projectId = issueDetails?.project_id;
|
||||
|
||||
const handleUpdateIssue = async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetails) return;
|
||||
|
||||
const payload = { ...formData };
|
||||
await updateIssue(workspaceSlug.toString(), projectId.toString(), issueDetails.id, payload).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueAssignees = (assignee: string) => {
|
||||
if (!issueDetails || !assignee) return;
|
||||
|
||||
closePalette();
|
||||
const updatedAssignees = issueDetails.assignee_ids ?? [];
|
||||
|
||||
if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
|
||||
else updatedAssignees.push(assignee);
|
||||
|
||||
handleUpdateIssue({ assignee_ids: updatedAssignees });
|
||||
};
|
||||
|
||||
const deleteIssue = () => {
|
||||
toggleCommandPaletteModal(false);
|
||||
toggleDeleteIssueModal(true);
|
||||
};
|
||||
|
||||
const copyIssueUrlToClipboard = () => {
|
||||
if (!issueId) return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToast({ type: TOAST_TYPE.SUCCESS, title: "Copied to clipboard" });
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({ type: TOAST_TYPE.ERROR, title: "Some error occurred" });
|
||||
});
|
||||
};
|
||||
|
||||
const actionHeading = issueDetails?.is_epic ? "Epic actions" : "Work item actions";
|
||||
const entityType = issueDetails?.is_epic ? "epic" : "work item";
|
||||
|
||||
return (
|
||||
<Command.Group heading={actionHeading}>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Change state...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-state"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<DoubleCircleIcon className="h-3.5 w-3.5" />
|
||||
Change state...
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Change priority...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-priority"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Signal className="h-3.5 w-3.5" />
|
||||
Change priority...
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Assign to...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-assignee"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Assign to...
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
handleIssueAssignees(currentUser?.id ?? "");
|
||||
setSearchTerm("");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
{issueDetails?.assignee_ids.includes(currentUser?.id ?? "") ? (
|
||||
<>
|
||||
<UserMinus2 className="h-3.5 w-3.5" />
|
||||
Un-assign from me
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus2 className="h-3.5 w-3.5" />
|
||||
Assign to me
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{`Delete ${entityType}`}
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
copyIssueUrlToClipboard();
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
{`Copy ${entityType} URL`}
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Check } from "lucide-react";
|
||||
// plane types
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// plane ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// helpers
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
|
||||
type Props = { closePalette: () => void; issue: TIssue };
|
||||
|
||||
export const ChangeIssueAssignee: React.FC<Props> = observer((props) => {
|
||||
const { closePalette, issue } = props;
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
// store
|
||||
const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
|
||||
const {
|
||||
project: { getProjectMemberIds, getProjectMemberDetails },
|
||||
} = useMember();
|
||||
// derived values
|
||||
const projectId = issue?.project_id ?? "";
|
||||
const projectMemberIds = getProjectMemberIds(projectId, false);
|
||||
|
||||
const options =
|
||||
projectMemberIds
|
||||
?.map((userId) => {
|
||||
if (!projectId) return;
|
||||
const memberDetails = getProjectMemberDetails(userId, projectId.toString());
|
||||
|
||||
return {
|
||||
value: `${memberDetails?.member?.id}`,
|
||||
query: `${memberDetails?.member?.display_name}`,
|
||||
content: (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={memberDetails?.member?.display_name}
|
||||
src={getFileURL(memberDetails?.member?.avatar_url ?? "")}
|
||||
showTooltip={false}
|
||||
/>
|
||||
{memberDetails?.member?.display_name}
|
||||
</div>
|
||||
{issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && (
|
||||
<div>
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((o) => o !== undefined) ?? [];
|
||||
|
||||
const handleUpdateIssue = async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issue) return;
|
||||
|
||||
const payload = { ...formData };
|
||||
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueAssignees = (assignee: string) => {
|
||||
const updatedAssignees = issue.assignee_ids ?? [];
|
||||
|
||||
if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
|
||||
else updatedAssignees.push(assignee);
|
||||
|
||||
handleUpdateIssue({ assignee_ids: updatedAssignees });
|
||||
closePalette();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map(
|
||||
(option) =>
|
||||
option && (
|
||||
<Command.Item
|
||||
key={option.value}
|
||||
onSelect={() => handleIssueAssignees(option.value)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
{option.content}
|
||||
</Command.Item>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Check } from "lucide-react";
|
||||
// plane constants
|
||||
import { ISSUE_PRIORITIES } from "@plane/constants";
|
||||
// plane types
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
import type { TIssue, TIssuePriorities } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// mobx store
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// ui
|
||||
// types
|
||||
// constants
|
||||
|
||||
type Props = { closePalette: () => void; issue: TIssue };
|
||||
|
||||
export const ChangeIssuePriority: React.FC<Props> = observer((props) => {
|
||||
const { closePalette, issue } = props;
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
|
||||
// derived values
|
||||
const projectId = issue?.project_id;
|
||||
|
||||
const submitChanges = async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issue) return;
|
||||
|
||||
const payload = { ...formData };
|
||||
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueState = (priority: TIssuePriorities) => {
|
||||
submitChanges({ priority });
|
||||
closePalette();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{ISSUE_PRIORITIES.map((priority) => (
|
||||
<Command.Item key={priority.key} onSelect={() => handleIssueState(priority.key)} className="focus:outline-none">
|
||||
<div className="flex items-center space-x-3">
|
||||
<PriorityIcon priority={priority.key} />
|
||||
<span className="capitalize">{priority.title ?? "None"}</span>
|
||||
</div>
|
||||
<div>{priority.key === issue.priority && <Check className="h-3 w-3" />}</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// store hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// plane web imports
|
||||
import { ChangeWorkItemStateList } from "@/plane-web/components/command-palette/actions/work-item-actions";
|
||||
|
||||
type Props = { closePalette: () => void; issue: TIssue };
|
||||
|
||||
export const ChangeIssueState: React.FC<Props> = observer((props) => {
|
||||
const { closePalette, issue } = props;
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
|
||||
// derived values
|
||||
const projectId = issue?.project_id;
|
||||
const currentStateId = issue?.state_id;
|
||||
|
||||
const submitChanges = async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issue) return;
|
||||
|
||||
const payload = { ...formData };
|
||||
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueState = (stateId: string) => {
|
||||
submitChanges({ state_id: stateId });
|
||||
closePalette();
|
||||
};
|
||||
|
||||
return (
|
||||
<ChangeWorkItemStateList
|
||||
projectId={projectId}
|
||||
currentStateId={currentStateId}
|
||||
handleStateChange={handleIssueState}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./actions-list";
|
||||
export * from "./change-state";
|
||||
export * from "./change-priority";
|
||||
export * from "./change-assignee";
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
// hooks
|
||||
import {
|
||||
CYCLE_TRACKER_ELEMENTS,
|
||||
MODULE_TRACKER_ELEMENTS,
|
||||
PROJECT_PAGE_TRACKER_ELEMENTS,
|
||||
PROJECT_VIEW_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
import { CycleIcon, ModuleIcon, PageIcon, ViewsIcon } from "@plane/propel/icons";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
// ui
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
|
||||
const { closePalette } = props;
|
||||
// store hooks
|
||||
const { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal } =
|
||||
useCommandPalette();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Command.Group heading="Cycle">
|
||||
<Command.Item
|
||||
data-ph-element={CYCLE_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM}
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
toggleCreateCycleModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<CycleIcon className="h-3.5 w-3.5" />
|
||||
Create new cycle
|
||||
</div>
|
||||
<kbd>Q</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="Module">
|
||||
<Command.Item
|
||||
data-ph-element={MODULE_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM}
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
toggleCreateModuleModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<ModuleIcon className="h-3.5 w-3.5" />
|
||||
Create new module
|
||||
</div>
|
||||
<kbd>M</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="View">
|
||||
<Command.Item
|
||||
data-ph-element={PROJECT_VIEW_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM}
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
toggleCreateViewModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<ViewsIcon className="h-3.5 w-3.5" />
|
||||
Create new view
|
||||
</div>
|
||||
<kbd>V</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="Page">
|
||||
<Command.Item
|
||||
data-ph-element={PROJECT_PAGE_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON}
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
toggleCreatePageModal({ isOpen: true });
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<PageIcon className="h-3.5 w-3.5" />
|
||||
Create new page
|
||||
</div>
|
||||
<kbd>D</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import type { IWorkspaceSearchResults } from "@plane/types";
|
||||
// hooks
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web imports
|
||||
import { commandGroups } from "@/plane-web/components/command-palette";
|
||||
// helpers
|
||||
import { openProjectAndScrollToSidebar } from "./helper";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
results: IWorkspaceSearchResults;
|
||||
};
|
||||
|
||||
export const CommandPaletteSearchResults: React.FC<Props> = observer((props) => {
|
||||
const { closePalette, results } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { projectId: routerProjectId } = useParams();
|
||||
// derived values
|
||||
const projectId = routerProjectId?.toString();
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.keys(results.results).map((key) => {
|
||||
// TODO: add type for results
|
||||
const section = (results.results as any)[key];
|
||||
const currentSection = commandGroups[key];
|
||||
if (!currentSection) return null;
|
||||
if (section.length > 0) {
|
||||
return (
|
||||
<Command.Group key={key} heading={`${currentSection.title} search`}>
|
||||
{section.map((item: any) => (
|
||||
<Command.Item
|
||||
key={item.id}
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
router.push(currentSection.path(item, projectId));
|
||||
const itemProjectId =
|
||||
item?.project_id ||
|
||||
(Array.isArray(item?.project_ids) && item?.project_ids?.length > 0
|
||||
? item?.project_ids[0]
|
||||
: undefined);
|
||||
if (itemProjectId) openProjectAndScrollToSidebar(itemProjectId);
|
||||
}}
|
||||
value={`${key}-${item?.id}-${item.name}-${item.project__identifier ?? ""}-${item.sequence_id ?? ""}`}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden text-custom-text-200">
|
||||
{currentSection.icon}
|
||||
<p className="block flex-1 truncate">{currentSection.itemName(item)}</p>
|
||||
</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Settings } from "lucide-react";
|
||||
// plane imports
|
||||
import { THEME_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteThemeActions: FC<Props> = observer((props) => {
|
||||
const { closePalette } = props;
|
||||
const { setTheme } = useTheme();
|
||||
// hooks
|
||||
const { updateUserTheme } = useUserProfile();
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const updateTheme = async (newTheme: string) => {
|
||||
setTheme(newTheme);
|
||||
return updateUserTheme({ theme: newTheme }).catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Failed to save user theme settings!",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// useEffect only runs on the client, so now we can safely show the UI
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{THEME_OPTIONS.map((theme) => (
|
||||
<Command.Item
|
||||
key={theme.value}
|
||||
onSelect={() => {
|
||||
updateTheme(theme.value);
|
||||
closePalette();
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Settings className="h-4 w-4 text-custom-text-200" />
|
||||
{t(theme.i18n_label)}
|
||||
</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
// hooks
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SettingIcon } from "@/components/icons";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane wev constants
|
||||
// plane web helpers
|
||||
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) => {
|
||||
const { closePalette } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
// mobx store
|
||||
const { t } = useTranslation();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
|
||||
const redirect = (path: string) => {
|
||||
closePalette();
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{WORKSPACE_SETTINGS_LINKS.map(
|
||||
(setting) =>
|
||||
allowPermissions(setting.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) &&
|
||||
shouldRenderSettingLink(workspaceSlug.toString(), setting.key) && (
|
||||
<Command.Item
|
||||
key={setting.key}
|
||||
onSelect={() => redirect(`/${workspaceSlug}${setting.href}`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<Link href={`/${workspaceSlug}${setting.href}`}>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
||||
{t(setting.i18n_label)}
|
||||
</div>
|
||||
</Link>
|
||||
</Command.Item>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
492
apps/web/core/components/command-palette/command-modal.tsx
Normal file
492
apps/web/core/components/command-palette/command-modal.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { CommandIcon, FolderPlus, Search, Settings, X } from "lucide-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import {
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
PROJECT_TRACKER_ELEMENTS,
|
||||
WORK_ITEM_TRACKER_ELEMENTS,
|
||||
WORKSPACE_DEFAULT_SEARCH_RESULT,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { WorkItemsIcon } from "@plane/propel/icons";
|
||||
import type { IWorkspaceSearchResults } from "@plane/types";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
import { cn, getTabIndex } from "@plane/utils";
|
||||
// components
|
||||
import {
|
||||
ChangeIssueAssignee,
|
||||
ChangeIssuePriority,
|
||||
ChangeIssueState,
|
||||
CommandPaletteHelpActions,
|
||||
CommandPaletteIssueActions,
|
||||
CommandPaletteProjectActions,
|
||||
CommandPaletteSearchResults,
|
||||
CommandPaletteThemeActions,
|
||||
CommandPaletteWorkspaceSettingsActions,
|
||||
} from "@/components/command-palette";
|
||||
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
|
||||
// helpers
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import useDebounce from "@/hooks/use-debounce";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
export const CommandModal: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId: routerProjectId, workItem } = useParams();
|
||||
// states
|
||||
const [placeholder, setPlaceholder] = useState("Type a command or search...");
|
||||
const [resultsCount, setResultsCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [results, setResults] = useState<IWorkspaceSearchResults>(WORKSPACE_DEFAULT_SEARCH_RESULT);
|
||||
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
const [searchInIssue, setSearchInIssue] = useState(false);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
fetchIssueWithIdentifier,
|
||||
} = useIssueDetail();
|
||||
const { workspaceProjectIds } = useProject();
|
||||
const { platform, isMobile } = usePlatformOS();
|
||||
const { canPerformAnyCreateAction } = useUser();
|
||||
const { isCommandPaletteOpen, toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal } =
|
||||
useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const projectIdentifier = workItem?.toString().split("-")[0];
|
||||
const sequence_id = workItem?.toString().split("-")[1];
|
||||
// fetch work item details using identifier
|
||||
const { data: workItemDetailsSWR } = useSWR(
|
||||
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
|
||||
workspaceSlug && workItem
|
||||
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
|
||||
: null
|
||||
);
|
||||
|
||||
// derived values
|
||||
const issueDetails = workItemDetailsSWR ? getIssueById(workItemDetailsSWR?.id) : null;
|
||||
const issueId = issueDetails?.id;
|
||||
const projectId = issueDetails?.project_id ?? routerProjectId;
|
||||
const page = pages[pages.length - 1];
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
const { baseTabIndex } = getTabIndex(undefined, isMobile);
|
||||
const canPerformWorkspaceActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" });
|
||||
|
||||
useEffect(() => {
|
||||
if (issueDetails && isCommandPaletteOpen) {
|
||||
setSearchInIssue(true);
|
||||
}
|
||||
}, [issueDetails, isCommandPaletteOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId && !isWorkspaceLevel) {
|
||||
setIsWorkspaceLevel(true);
|
||||
} else {
|
||||
setIsWorkspaceLevel(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const closePalette = () => {
|
||||
toggleCommandPaletteModal(false);
|
||||
};
|
||||
|
||||
const createNewWorkspace = () => {
|
||||
closePalette();
|
||||
router.push("/create-workspace");
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
setIsSearching(true);
|
||||
workspaceService
|
||||
.searchWorkspace(workspaceSlug.toString(), {
|
||||
...(projectId ? { project_id: projectId.toString() } : {}),
|
||||
search: debouncedSearchTerm,
|
||||
workspace_search: !projectId ? true : isWorkspaceLevel,
|
||||
})
|
||||
.then((results) => {
|
||||
setResults(results);
|
||||
const count = Object.keys(results.results).reduce(
|
||||
(accumulator, key) => (results.results as any)[key].length + accumulator,
|
||||
0
|
||||
);
|
||||
setResultsCount(count);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
});
|
||||
} else {
|
||||
setResults(WORKSPACE_DEFAULT_SEARCH_RESULT);
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
},
|
||||
[debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes
|
||||
);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isCommandPaletteOpen} afterLeave={() => setSearchTerm("")} as={React.Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-30"
|
||||
onClose={() => {
|
||||
closePalette();
|
||||
if (searchInIssue) {
|
||||
setSearchInIssue(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||
<div className="flex items-center justify-center p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex w-full max-w-2xl transform flex-col items-center justify-center divide-y divide-custom-border-200 divide-opacity-10 rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all">
|
||||
<div className="w-full max-w-2xl">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
shouldFilter={searchTerm.length > 0}
|
||||
onKeyDown={(e: any) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const commandList = document.querySelector("[cmdk-list]");
|
||||
const items = commandList?.querySelectorAll("[cmdk-item]") || [];
|
||||
const selectedItem = commandList?.querySelector('[aria-selected="true"]');
|
||||
if (items.length === 0) return;
|
||||
|
||||
const currentIndex = Array.from(items).indexOf(selectedItem as Element);
|
||||
let nextIndex;
|
||||
|
||||
if (e.shiftKey) {
|
||||
nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
||||
} else {
|
||||
nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
||||
}
|
||||
|
||||
const nextItem = items[nextIndex] as HTMLElement;
|
||||
if (nextItem) {
|
||||
nextItem.setAttribute("aria-selected", "true");
|
||||
selectedItem?.setAttribute("aria-selected", "false");
|
||||
nextItem.focus();
|
||||
nextItem.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Escape" && searchTerm) {
|
||||
e.preventDefault();
|
||||
setSearchTerm("");
|
||||
}
|
||||
|
||||
if (e.key === "Escape" && !page && !searchTerm) {
|
||||
e.preventDefault();
|
||||
closePalette();
|
||||
}
|
||||
|
||||
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
|
||||
e.preventDefault();
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
setPlaceholder("Type a command or search...");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative flex items-center px-4 border-0 border-b border-custom-border-200">
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Search
|
||||
className="h-4 w-4 text-custom-text-200 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{searchInIssue && issueDetails && (
|
||||
<>
|
||||
<span className="flex items-center text-sm">Update in:</span>
|
||||
<span className="flex items-center gap-1 rounded px-1.5 py-1 text-sm bg-custom-primary-100/10 ">
|
||||
{issueDetails.project_id && (
|
||||
<IssueIdentifier
|
||||
issueId={issueDetails.id}
|
||||
projectId={issueDetails.project_id}
|
||||
textContainerClassName="text-sm text-custom-primary-200"
|
||||
/>
|
||||
)}
|
||||
<X
|
||||
size={12}
|
||||
strokeWidth={2}
|
||||
className="flex-shrink-0 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSearchInIssue(false);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Command.Input
|
||||
className={cn(
|
||||
"w-full bg-transparent p-4 text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onValueChange={(e) => setSearchTerm(e)}
|
||||
autoFocus
|
||||
tabIndex={baseTabIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Command.List className="vertical-scrollbar scrollbar-sm max-h-96 overflow-scroll p-2">
|
||||
{searchTerm !== "" && (
|
||||
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
|
||||
Search results for{" "}
|
||||
<span className="font-medium">
|
||||
{'"'}
|
||||
{searchTerm}
|
||||
{'"'}
|
||||
</span>{" "}
|
||||
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
|
||||
</h5>
|
||||
)}
|
||||
|
||||
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
|
||||
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
|
||||
<SimpleEmptyState title={t("command_k.empty_state.search.title")} assetPath={resolvedPath} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isLoading || isSearching) && (
|
||||
<Command.Loading>
|
||||
<Loader className="space-y-3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
</Command.Loading>
|
||||
)}
|
||||
|
||||
{debouncedSearchTerm !== "" && (
|
||||
<CommandPaletteSearchResults closePalette={closePalette} results={results} />
|
||||
)}
|
||||
|
||||
{!page && (
|
||||
<>
|
||||
{/* issue actions */}
|
||||
{issueId && issueDetails && searchInIssue && (
|
||||
<CommandPaletteIssueActions
|
||||
closePalette={closePalette}
|
||||
issueDetails={issueDetails}
|
||||
pages={pages}
|
||||
setPages={(newPages) => setPages(newPages)}
|
||||
setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)}
|
||||
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
|
||||
/>
|
||||
)}
|
||||
{workspaceSlug &&
|
||||
workspaceProjectIds &&
|
||||
workspaceProjectIds.length > 0 &&
|
||||
canPerformAnyCreateAction && (
|
||||
<Command.Group heading="Work item">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
captureClick({
|
||||
elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON,
|
||||
});
|
||||
toggleCreateIssueModal(true);
|
||||
}}
|
||||
className="focus:bg-custom-background-80"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<WorkItemsIcon className="h-3.5 w-3.5" />
|
||||
Create new work item
|
||||
</div>
|
||||
<kbd>C</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
{workspaceSlug && canPerformWorkspaceActions && (
|
||||
<Command.Group heading="Project">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON });
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
Create new project
|
||||
</div>
|
||||
<kbd>P</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{/* project actions */}
|
||||
{projectId && canPerformAnyCreateAction && (
|
||||
<CommandPaletteProjectActions closePalette={closePalette} />
|
||||
)}
|
||||
{canPerformWorkspaceActions && (
|
||||
<Command.Group heading="Workspace Settings">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Search workspace settings...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "settings"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Search settings...
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
<Command.Group heading="Account">
|
||||
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
Create new workspace
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Change interface theme...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-interface-theme"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Change interface theme...
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
{/* help options */}
|
||||
<CommandPaletteHelpActions closePalette={closePalette} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* workspace settings actions */}
|
||||
{page === "settings" && workspaceSlug && (
|
||||
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
|
||||
)}
|
||||
|
||||
{/* issue details page actions */}
|
||||
{page === "change-issue-state" && issueDetails && (
|
||||
<ChangeIssueState closePalette={closePalette} issue={issueDetails} />
|
||||
)}
|
||||
{page === "change-issue-priority" && issueDetails && (
|
||||
<ChangeIssuePriority closePalette={closePalette} issue={issueDetails} />
|
||||
)}
|
||||
{page === "change-issue-assignee" && issueDetails && (
|
||||
<ChangeIssueAssignee closePalette={closePalette} issue={issueDetails} />
|
||||
)}
|
||||
|
||||
{/* theme actions */}
|
||||
{page === "change-interface-theme" && (
|
||||
<CommandPaletteThemeActions
|
||||
closePalette={() => {
|
||||
closePalette();
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</div>
|
||||
{/* Bottom overlay */}
|
||||
<div className="w-full flex items-center justify-between px-4 py-2 border-t border-custom-border-200 bg-custom-background-90/80 rounded-b-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-custom-text-300">Actions</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="grid h-6 min-w-[1.5rem] place-items-center rounded bg-custom-background-80 border-[0.5px] border-custom-border-200 px-1.5 text-[10px] text-custom-text-200">
|
||||
{platform === "MacOS" ? <CommandIcon className="h-2.5 w-2.5 text-custom-text-200" /> : "Ctrl"}
|
||||
</div>
|
||||
<kbd className="grid h-6 min-w-[1.5rem] place-items-center rounded bg-custom-background-80 border-[0.5px] border-custom-border-200 px-1.5 text-[10px] text-custom-text-200">
|
||||
K
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-custom-text-300">Workspace Level</span>
|
||||
<ToggleSwitch
|
||||
value={isWorkspaceLevel}
|
||||
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
disabled={!projectId}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
||||
270
apps/web/core/components/command-palette/command-palette.tsx
Normal file
270
apps/web/core/components/command-palette/command-palette.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { COMMAND_PALETTE_TRACKER_ELEMENTS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
// components
|
||||
import { copyTextToClipboard } from "@plane/utils";
|
||||
import { CommandModal, ShortcutsModal } from "@/components/command-palette";
|
||||
// helpers
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import {
|
||||
IssueLevelModals,
|
||||
ProjectLevelModals,
|
||||
WorkspaceLevelModals,
|
||||
} from "@/plane-web/components/command-palette/modals";
|
||||
// plane web constants
|
||||
// plane web helpers
|
||||
import {
|
||||
getGlobalShortcutsList,
|
||||
getProjectShortcutsList,
|
||||
getWorkspaceShortcutsList,
|
||||
handleAdditionalKeyDownEvents,
|
||||
} from "@/plane-web/helpers/command-palette";
|
||||
|
||||
export const CommandPalette: FC = observer(() => {
|
||||
// router params
|
||||
const { workspaceSlug, projectId: paramsProjectId, workItem } = useParams();
|
||||
// store hooks
|
||||
const { fetchIssueWithIdentifier } = useIssueDetail();
|
||||
const { toggleSidebar, toggleExtendedSidebar } = useAppTheme();
|
||||
const { platform } = usePlatformOS();
|
||||
const { data: currentUser, canPerformAnyCreateAction } = useUser();
|
||||
const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen } = useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
// derived values
|
||||
const projectIdentifier = workItem?.toString().split("-")[0];
|
||||
const sequence_id = workItem?.toString().split("-")[1];
|
||||
|
||||
const { data: issueDetails } = useSWR(
|
||||
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
|
||||
workspaceSlug && workItem
|
||||
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
|
||||
: null
|
||||
);
|
||||
|
||||
const issueId = issueDetails?.id;
|
||||
const projectId = paramsProjectId?.toString() ?? issueDetails?.project_id;
|
||||
|
||||
const canPerformWorkspaceMemberActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
const canPerformProjectMemberActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug?.toString(),
|
||||
projectId
|
||||
);
|
||||
const canPerformProjectAdminActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug?.toString(),
|
||||
projectId
|
||||
);
|
||||
|
||||
const copyIssueUrlToClipboard = useCallback(() => {
|
||||
if (!workItem) return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
}, [workItem]);
|
||||
|
||||
// auth
|
||||
const performProjectCreateActions = useCallback(
|
||||
(showToast: boolean = true) => {
|
||||
if (!canPerformProjectMemberActions && showToast)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "You don't have permission to perform this action.",
|
||||
});
|
||||
|
||||
return canPerformProjectMemberActions;
|
||||
},
|
||||
[canPerformProjectMemberActions]
|
||||
);
|
||||
|
||||
const performProjectBulkDeleteActions = useCallback(
|
||||
(showToast: boolean = true) => {
|
||||
if (!canPerformProjectAdminActions && projectId && showToast)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "You don't have permission to perform this action.",
|
||||
});
|
||||
|
||||
return canPerformProjectAdminActions;
|
||||
},
|
||||
[canPerformProjectAdminActions, projectId]
|
||||
);
|
||||
|
||||
const performWorkspaceCreateActions = useCallback(
|
||||
(showToast: boolean = true) => {
|
||||
if (!canPerformWorkspaceMemberActions && showToast)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "You don't have permission to perform this action.",
|
||||
});
|
||||
return canPerformWorkspaceMemberActions;
|
||||
},
|
||||
[canPerformWorkspaceMemberActions]
|
||||
);
|
||||
|
||||
const performAnyProjectCreateActions = useCallback(
|
||||
(showToast: boolean = true) => {
|
||||
if (!canPerformAnyCreateAction && showToast)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "You don't have permission to perform this action.",
|
||||
});
|
||||
return canPerformAnyCreateAction;
|
||||
},
|
||||
[canPerformAnyCreateAction]
|
||||
);
|
||||
|
||||
const shortcutsList: {
|
||||
global: Record<string, { title: string; description: string; action: () => void }>;
|
||||
workspace: Record<string, { title: string; description: string; action: () => void }>;
|
||||
project: Record<string, { title: string; description: string; action: () => void }>;
|
||||
} = useMemo(
|
||||
() => ({
|
||||
global: getGlobalShortcutsList(),
|
||||
workspace: getWorkspaceShortcutsList(),
|
||||
project: getProjectShortcutsList(),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
|
||||
if (!key) return;
|
||||
|
||||
const keyPressed = key.toLowerCase();
|
||||
const cmdClicked = ctrlKey || metaKey;
|
||||
const shiftClicked = shiftKey;
|
||||
const deleteKey = keyPressed === "backspace" || keyPressed === "delete";
|
||||
|
||||
if (cmdClicked && keyPressed === "k" && !isAnyModalOpen) {
|
||||
e.preventDefault();
|
||||
toggleCommandPaletteModal(true);
|
||||
}
|
||||
|
||||
// if on input, textarea or editor, don't do anything
|
||||
if (
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
(e.target as Element)?.classList?.contains("ProseMirror")
|
||||
)
|
||||
return;
|
||||
|
||||
if (shiftClicked && (keyPressed === "?" || keyPressed === "/") && !isAnyModalOpen) {
|
||||
e.preventDefault();
|
||||
toggleShortcutModal(true);
|
||||
}
|
||||
|
||||
if (deleteKey) {
|
||||
if (performProjectBulkDeleteActions()) {
|
||||
shortcutsList.project.delete.action();
|
||||
}
|
||||
} else if (cmdClicked) {
|
||||
if (keyPressed === "c" && ((platform === "MacOS" && ctrlKey) || altKey)) {
|
||||
e.preventDefault();
|
||||
copyIssueUrlToClipboard();
|
||||
} else if (keyPressed === "b") {
|
||||
e.preventDefault();
|
||||
toggleSidebar();
|
||||
toggleExtendedSidebar(false);
|
||||
}
|
||||
} else if (!isAnyModalOpen) {
|
||||
captureClick({ elementName: COMMAND_PALETTE_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_KEY });
|
||||
if (
|
||||
Object.keys(shortcutsList.global).includes(keyPressed) &&
|
||||
((!projectId && performAnyProjectCreateActions()) || performProjectCreateActions())
|
||||
) {
|
||||
shortcutsList.global[keyPressed].action();
|
||||
}
|
||||
// workspace authorized actions
|
||||
else if (
|
||||
Object.keys(shortcutsList.workspace).includes(keyPressed) &&
|
||||
workspaceSlug &&
|
||||
performWorkspaceCreateActions()
|
||||
) {
|
||||
e.preventDefault();
|
||||
shortcutsList.workspace[keyPressed].action();
|
||||
}
|
||||
// project authorized actions
|
||||
else if (
|
||||
Object.keys(shortcutsList.project).includes(keyPressed) &&
|
||||
projectId &&
|
||||
performProjectCreateActions()
|
||||
) {
|
||||
e.preventDefault();
|
||||
// actions that can be performed only inside a project
|
||||
shortcutsList.project[keyPressed].action();
|
||||
}
|
||||
}
|
||||
// Additional keydown events
|
||||
handleAdditionalKeyDownEvents(e);
|
||||
},
|
||||
[
|
||||
copyIssueUrlToClipboard,
|
||||
isAnyModalOpen,
|
||||
platform,
|
||||
performAnyProjectCreateActions,
|
||||
performProjectBulkDeleteActions,
|
||||
performProjectCreateActions,
|
||||
performWorkspaceCreateActions,
|
||||
projectId,
|
||||
shortcutsList,
|
||||
toggleCommandPaletteModal,
|
||||
toggleShortcutModal,
|
||||
toggleSidebar,
|
||||
toggleExtendedSidebar,
|
||||
workspaceSlug,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShortcutsModal isOpen={isShortcutModalOpen} onClose={() => toggleShortcutModal(false)} />
|
||||
{workspaceSlug && <WorkspaceLevelModals workspaceSlug={workspaceSlug.toString()} />}
|
||||
{workspaceSlug && projectId && (
|
||||
<ProjectLevelModals workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
)}
|
||||
<IssueLevelModals projectId={projectId} issueId={issueId} />
|
||||
<CommandModal />
|
||||
</>
|
||||
);
|
||||
});
|
||||
4
apps/web/core/components/command-palette/index.ts
Normal file
4
apps/web/core/components/command-palette/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./actions";
|
||||
export * from "./shortcuts-modal";
|
||||
export * from "./command-modal";
|
||||
export * from "./command-palette";
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Command } from "lucide-react";
|
||||
// helpers
|
||||
import { substringMatch } from "@plane/utils";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web helpers
|
||||
import {
|
||||
getAdditionalShortcutsList,
|
||||
getCommonShortcutsList,
|
||||
getNavigationShortcutsList,
|
||||
} from "@/plane-web/helpers/command-palette";
|
||||
|
||||
type Props = {
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export const ShortcutCommandsList: React.FC<Props> = (props) => {
|
||||
const { searchQuery } = props;
|
||||
const { platform } = usePlatformOS();
|
||||
|
||||
const KEYBOARD_SHORTCUTS = [
|
||||
{
|
||||
key: "navigation",
|
||||
title: "Navigation",
|
||||
shortcuts: getNavigationShortcutsList(),
|
||||
},
|
||||
{
|
||||
key: "common",
|
||||
title: "Common",
|
||||
shortcuts: getCommonShortcutsList(platform),
|
||||
},
|
||||
...getAdditionalShortcutsList(),
|
||||
];
|
||||
|
||||
const filteredShortcuts = KEYBOARD_SHORTCUTS.map((category) => {
|
||||
const newCategory = { ...category };
|
||||
|
||||
newCategory.shortcuts = newCategory.shortcuts.filter((shortcut) =>
|
||||
substringMatch(shortcut.description, searchQuery)
|
||||
);
|
||||
|
||||
return newCategory;
|
||||
});
|
||||
|
||||
const isShortcutsEmpty = filteredShortcuts.every((category) => category.shortcuts.length === 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3 overflow-y-auto">
|
||||
{!isShortcutsEmpty ? (
|
||||
filteredShortcuts.map((category) => {
|
||||
if (category.shortcuts.length === 0) return;
|
||||
|
||||
return (
|
||||
<div key={category.key}>
|
||||
<h5 className="text-left text-sm font-medium">{category.title}</h5>
|
||||
<div className="space-y-3 px-1">
|
||||
{category.shortcuts.map((shortcut) => (
|
||||
<div key={shortcut.keys} className="mt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs text-custom-text-200 text-left">{shortcut.description}</h4>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{shortcut.keys.split(",").map((key) => (
|
||||
<div key={key} className="flex items-center gap-1">
|
||||
{key === "Ctrl" ? (
|
||||
<div className="grid h-6 min-w-[1.5rem] place-items-center rounded-sm border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 text-[10px] text-custom-text-200">
|
||||
{platform === "MacOS" ? (
|
||||
<Command className="h-2.5 w-2.5 text-custom-text-200" />
|
||||
) : (
|
||||
"Ctrl"
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<kbd className="grid h-6 min-w-[1.5rem] place-items-center rounded-sm border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 text-[10px] text-custom-text-200">
|
||||
{key}
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="flex justify-center text-center text-sm text-custom-text-200">
|
||||
No shortcuts found for{" "}
|
||||
<span className="font-semibold italic">
|
||||
{`"`}
|
||||
{searchQuery}
|
||||
{`"`}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./commands-list";
|
||||
export * from "./modal";
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState, Fragment } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// components
|
||||
import { Input } from "@plane/ui";
|
||||
import { ShortcutCommandsList } from "@/components/command-palette";
|
||||
// ui
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const ShortcutsModal: FC<Props> = (props) => {
|
||||
const { isOpen, onClose } = props;
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-30" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-full items-center justify-center">
|
||||
<div className="flex h-[61vh] w-full flex-col space-y-4 overflow-hidden rounded-lg bg-custom-background-100 p-5 shadow-custom-shadow-md transition-all sm:w-[28rem]">
|
||||
<Dialog.Title as="h3" className="flex justify-between">
|
||||
<span className="text-lg font-medium">Keyboard shortcuts</span>
|
||||
<button type="button" onClick={handleClose}>
|
||||
<X className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" aria-hidden="true" />
|
||||
</button>
|
||||
</Dialog.Title>
|
||||
<div className="flex w-full items-center rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
<Input
|
||||
id="search"
|
||||
name="search"
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search for shortcuts"
|
||||
className="w-full border-none bg-transparent py-1 text-xs text-custom-text-200 outline-none"
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
/>
|
||||
</div>
|
||||
<ShortcutCommandsList searchQuery={query} />
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
87
apps/web/core/components/comments/card/display.tsx
Normal file
87
apps/web/core/components/comments/card/display.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Globe2, Lock } from "lucide-react";
|
||||
// plane imports
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import { useHashScroll } from "@plane/hooks";
|
||||
import { EIssueCommentAccessSpecifier } from "@plane/types";
|
||||
import type { TCommentsOperations, TIssueComment } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { LiteTextEditor } from "@/components/editor/lite-text";
|
||||
// local imports
|
||||
import { CommentReactions } from "../comment-reaction";
|
||||
|
||||
type Props = {
|
||||
activityOperations: TCommentsOperations;
|
||||
comment: TIssueComment;
|
||||
disabled: boolean;
|
||||
projectId?: string;
|
||||
readOnlyEditorRef: React.RefObject<EditorRefApi>;
|
||||
showAccessSpecifier: boolean;
|
||||
workspaceId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const CommentCardDisplay: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
activityOperations,
|
||||
comment,
|
||||
disabled,
|
||||
projectId,
|
||||
readOnlyEditorRef,
|
||||
showAccessSpecifier,
|
||||
workspaceId,
|
||||
workspaceSlug,
|
||||
} = props;
|
||||
// states
|
||||
const [highlightClassName, setHighlightClassName] = useState("");
|
||||
// navigation
|
||||
const pathname = usePathname();
|
||||
// derived values
|
||||
const commentBlockId = `comment-${comment?.id}`;
|
||||
// scroll to comment
|
||||
const { isHashMatch } = useHashScroll({
|
||||
elementId: commentBlockId,
|
||||
pathname,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHashMatch) return;
|
||||
setHighlightClassName("border-custom-primary-100");
|
||||
const timeout = setTimeout(() => {
|
||||
setHighlightClassName("");
|
||||
}, 8000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isHashMatch]);
|
||||
|
||||
return (
|
||||
<div id={commentBlockId} className="relative flex flex-col gap-2">
|
||||
{showAccessSpecifier && (
|
||||
<div className="absolute right-2.5 top-2.5 z-[1] text-custom-text-300">
|
||||
{comment.access === EIssueCommentAccessSpecifier.INTERNAL ? (
|
||||
<Lock className="size-3" />
|
||||
) : (
|
||||
<Globe2 className="size-3" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<LiteTextEditor
|
||||
editable={false}
|
||||
ref={readOnlyEditorRef}
|
||||
id={comment.id}
|
||||
initialValue={comment.comment_html ?? ""}
|
||||
workspaceId={workspaceId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
containerClassName={cn("!py-1 transition-[border-color] duration-500", highlightClassName)}
|
||||
projectId={projectId?.toString()}
|
||||
displayConfig={{
|
||||
fontSize: "small-font",
|
||||
}}
|
||||
/>
|
||||
<CommentReactions comment={comment} disabled={disabled} activityOperations={activityOperations} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
131
apps/web/core/components/comments/card/edit-form.tsx
Normal file
131
apps/web/core/components/comments/card/edit-form.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Check, X } from "lucide-react";
|
||||
// plane imports
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import type { TCommentsOperations, TIssueComment } from "@plane/types";
|
||||
import { isCommentEmpty } from "@plane/utils";
|
||||
// components
|
||||
import { LiteTextEditor } from "@/components/editor/lite-text";
|
||||
|
||||
type Props = {
|
||||
activityOperations: TCommentsOperations;
|
||||
comment: TIssueComment;
|
||||
isEditing: boolean;
|
||||
projectId?: string;
|
||||
readOnlyEditorRef: EditorRefApi | null;
|
||||
setIsEditing: (isEditing: boolean) => void;
|
||||
workspaceId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const CommentCardEditForm: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
activityOperations,
|
||||
comment,
|
||||
isEditing,
|
||||
projectId,
|
||||
readOnlyEditorRef,
|
||||
setIsEditing,
|
||||
workspaceId,
|
||||
workspaceSlug,
|
||||
} = props;
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
// form info
|
||||
const {
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
setFocus,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<Partial<TIssueComment>>({
|
||||
defaultValues: { comment_html: comment?.comment_html },
|
||||
});
|
||||
const commentHTML = watch("comment_html");
|
||||
|
||||
const isEmpty = isCommentEmpty(commentHTML ?? undefined);
|
||||
const isEditorReadyToDiscard = editorRef.current?.isEditorReadyToDiscard();
|
||||
const isSubmitButtonDisabled = isSubmitting || !isEditorReadyToDiscard;
|
||||
const isDisabled = isSubmitting || isEmpty || isSubmitButtonDisabled;
|
||||
|
||||
const onEnter = async (formData: Partial<TIssueComment>) => {
|
||||
if (isSubmitting || !comment) return;
|
||||
|
||||
setIsEditing(false);
|
||||
|
||||
await activityOperations.updateComment(comment.id, formData);
|
||||
|
||||
editorRef.current?.setEditorValue(formData?.comment_html ?? "<p></p>");
|
||||
readOnlyEditorRef?.setEditorValue(formData?.comment_html ?? "<p></p>");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
setFocus("comment_html");
|
||||
}
|
||||
}, [isEditing, setFocus]);
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-2">
|
||||
<div
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty) handleSubmit(onEnter)(e);
|
||||
}}
|
||||
>
|
||||
<LiteTextEditor
|
||||
editable
|
||||
workspaceId={workspaceId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
ref={editorRef}
|
||||
id={comment.id}
|
||||
initialValue={commentHTML ?? ""}
|
||||
value={null}
|
||||
onChange={(_comment_json, comment_html) => setValue("comment_html", comment_html)}
|
||||
onEnterKeyPress={(e) => {
|
||||
if (!isEmpty && !isSubmitting) {
|
||||
handleSubmit(onEnter)(e);
|
||||
}
|
||||
}}
|
||||
showSubmitButton={false}
|
||||
uploadFile={async (blockId, file) => {
|
||||
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id);
|
||||
return asset_id;
|
||||
}}
|
||||
projectId={projectId}
|
||||
parentClassName="p-2"
|
||||
displayConfig={{
|
||||
fontSize: "small-font",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 self-end">
|
||||
{!isEmpty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit(onEnter)}
|
||||
disabled={isDisabled}
|
||||
className={`group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 ${
|
||||
isEmpty ? "cursor-not-allowed bg-gray-200" : "hover:bg-green-500"
|
||||
}`}
|
||||
>
|
||||
<Check
|
||||
className={`h-3 w-3 text-green-500 duration-300 ${isEmpty ? "text-black" : "group-hover:text-white"}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
editorRef.current?.setEditorValue(comment.comment_html ?? "<p></p>");
|
||||
}}
|
||||
>
|
||||
<X className="size-3 text-red-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
88
apps/web/core/components/comments/card/root.tsx
Normal file
88
apps/web/core/components/comments/card/root.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import type { TIssueComment, TCommentsOperations } from "@plane/types";
|
||||
// plane web imports
|
||||
import { CommentBlock } from "@/plane-web/components/comments";
|
||||
// local imports
|
||||
import { CommentQuickActions } from "../quick-actions";
|
||||
import { CommentCardDisplay } from "./display";
|
||||
import { CommentCardEditForm } from "./edit-form";
|
||||
|
||||
type TCommentCard = {
|
||||
workspaceSlug: string;
|
||||
comment: TIssueComment | undefined;
|
||||
activityOperations: TCommentsOperations;
|
||||
ends: "top" | "bottom" | undefined;
|
||||
showAccessSpecifier: boolean;
|
||||
showCopyLinkOption: boolean;
|
||||
disabled?: boolean;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
export const CommentCard: FC<TCommentCard> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
comment,
|
||||
activityOperations,
|
||||
ends,
|
||||
showAccessSpecifier,
|
||||
showCopyLinkOption,
|
||||
disabled = false,
|
||||
projectId,
|
||||
} = props;
|
||||
// states
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
// refs
|
||||
const readOnlyEditorRef = useRef<EditorRefApi>(null);
|
||||
// derived values
|
||||
const workspaceId = comment?.workspace;
|
||||
|
||||
if (!comment || !workspaceId) return null;
|
||||
|
||||
return (
|
||||
<CommentBlock
|
||||
comment={comment}
|
||||
quickActions={
|
||||
!disabled && (
|
||||
<CommentQuickActions
|
||||
activityOperations={activityOperations}
|
||||
comment={comment}
|
||||
setEditMode={() => setIsEditing(true)}
|
||||
showAccessSpecifier={showAccessSpecifier}
|
||||
showCopyLinkOption={showCopyLinkOption}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ends={ends}
|
||||
>
|
||||
{isEditing ? (
|
||||
<CommentCardEditForm
|
||||
activityOperations={activityOperations}
|
||||
comment={comment}
|
||||
isEditing
|
||||
readOnlyEditorRef={readOnlyEditorRef.current}
|
||||
setIsEditing={setIsEditing}
|
||||
projectId={projectId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
) : (
|
||||
<CommentCardDisplay
|
||||
activityOperations={activityOperations}
|
||||
comment={comment}
|
||||
disabled={disabled}
|
||||
projectId={projectId}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
showAccessSpecifier={showAccessSpecifier}
|
||||
workspaceId={workspaceId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
)}
|
||||
</CommentBlock>
|
||||
);
|
||||
});
|
||||
148
apps/web/core/components/comments/comment-create.tsx
Normal file
148
apps/web/core/components/comments/comment-create.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { FC } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
// plane imports
|
||||
import { EIssueCommentAccessSpecifier } from "@plane/constants";
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import type { TIssueComment, TCommentsOperations } from "@plane/types";
|
||||
import { cn, isCommentEmpty } from "@plane/utils";
|
||||
// components
|
||||
import { LiteTextEditor } from "@/components/editor/lite-text";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
|
||||
type TCommentCreate = {
|
||||
entityId: string;
|
||||
workspaceSlug: string;
|
||||
activityOperations: TCommentsOperations;
|
||||
showToolbarInitially?: boolean;
|
||||
projectId?: string;
|
||||
onSubmitCallback?: (elementId: string) => void;
|
||||
};
|
||||
|
||||
// services
|
||||
const fileService = new FileService();
|
||||
|
||||
export const CommentCreate: FC<TCommentCreate> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
entityId,
|
||||
activityOperations,
|
||||
showToolbarInitially = false,
|
||||
projectId,
|
||||
onSubmitCallback,
|
||||
} = props;
|
||||
// states
|
||||
const [uploadedAssetIds, setUploadedAssetIds] = useState<string[]>([]);
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
// store hooks
|
||||
const workspaceStore = useWorkspace();
|
||||
// derived values
|
||||
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string;
|
||||
// form info
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
reset,
|
||||
} = useForm<Partial<TIssueComment>>({
|
||||
defaultValues: {
|
||||
comment_html: "<p></p>",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: Partial<TIssueComment>) => {
|
||||
try {
|
||||
const comment = await activityOperations.createComment(formData);
|
||||
if (comment?.id) onSubmitCallback?.(comment.id);
|
||||
if (uploadedAssetIds.length > 0) {
|
||||
if (projectId) {
|
||||
await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId.toString(), entityId, {
|
||||
asset_ids: uploadedAssetIds,
|
||||
});
|
||||
} else {
|
||||
await fileService.updateBulkWorkspaceAssetsUploadStatus(workspaceSlug, entityId, {
|
||||
asset_ids: uploadedAssetIds,
|
||||
});
|
||||
}
|
||||
setUploadedAssetIds([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
reset({
|
||||
comment_html: "<p></p>",
|
||||
});
|
||||
editorRef.current?.clearEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const commentHTML = watch("comment_html");
|
||||
const isEmpty = isCommentEmpty(commentHTML ?? undefined);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("sticky bottom-0 z-[4] bg-custom-background-100 sm:static")}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!isEmpty &&
|
||||
!isSubmitting &&
|
||||
editorRef.current?.isEditorReadyToDiscard()
|
||||
)
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="access"
|
||||
control={control}
|
||||
render={({ field: { onChange: onAccessChange, value: accessValue } }) => (
|
||||
<Controller
|
||||
name="comment_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<LiteTextEditor
|
||||
editable
|
||||
workspaceId={workspaceId}
|
||||
id={"add_comment_" + entityId}
|
||||
value={"<p></p>"}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
onEnterKeyPress={(e) => {
|
||||
if (!isEmpty && !isSubmitting) {
|
||||
handleSubmit(onSubmit)(e);
|
||||
}
|
||||
}}
|
||||
ref={editorRef}
|
||||
initialValue={value ?? "<p></p>"}
|
||||
containerClassName="min-h-min"
|
||||
onChange={(comment_json, comment_html) => onChange(comment_html)}
|
||||
accessSpecifier={accessValue ?? EIssueCommentAccessSpecifier.INTERNAL}
|
||||
handleAccessChange={onAccessChange}
|
||||
isSubmitting={isSubmitting}
|
||||
uploadFile={async (blockId, file) => {
|
||||
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file);
|
||||
setUploadedAssetIds((prev) => [...prev, asset_id]);
|
||||
return asset_id;
|
||||
}}
|
||||
showToolbarInitially={showToolbarInitially}
|
||||
parentClassName="p-2"
|
||||
displayConfig={{
|
||||
fontSize: "small-font",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
67
apps/web/core/components/comments/comment-reaction.tsx
Normal file
67
apps/web/core/components/comments/comment-reaction.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TCommentsOperations, TIssueComment } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { renderEmoji } from "@/helpers/emoji.helper";
|
||||
// local imports
|
||||
import { ReactionSelector } from "../issues/issue-detail/reactions";
|
||||
|
||||
export type TProps = {
|
||||
comment: TIssueComment;
|
||||
disabled?: boolean;
|
||||
activityOperations: TCommentsOperations;
|
||||
};
|
||||
|
||||
export const CommentReactions: FC<TProps> = observer((props) => {
|
||||
const { comment, activityOperations, disabled = false } = props;
|
||||
|
||||
const userReactions = activityOperations.userReactions(comment.id);
|
||||
const reactionIds = activityOperations.reactionIds(comment.id);
|
||||
|
||||
if (!userReactions) return null;
|
||||
return (
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
{!disabled && (
|
||||
<ReactionSelector
|
||||
size="md"
|
||||
position="top"
|
||||
value={userReactions}
|
||||
onSelect={(reactionEmoji) => activityOperations.react(comment.id, reactionEmoji, userReactions)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{reactionIds &&
|
||||
Object.keys(reactionIds || {}).map(
|
||||
(reaction: string) =>
|
||||
reactionIds[reaction]?.length > 0 && (
|
||||
<>
|
||||
<Tooltip tooltipContent={activityOperations.getReactionUsers(reaction, reactionIds)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && activityOperations.react(comment.id, reaction, userReactions)}
|
||||
key={reaction}
|
||||
className={cn(
|
||||
"flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100",
|
||||
userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80",
|
||||
{
|
||||
"cursor-not-allowed": disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span>{renderEmoji(reaction)}</span>
|
||||
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
|
||||
{(reactionIds || {})[reaction].length}{" "}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
83
apps/web/core/components/comments/comments.tsx
Normal file
83
apps/web/core/components/comments/comments.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import type { E_SORT_ORDER } from "@plane/constants";
|
||||
import type { TCommentsOperations, TIssueComment } from "@plane/types";
|
||||
// local components
|
||||
import { CommentCard } from "./card/root";
|
||||
import { CommentCreate } from "./comment-create";
|
||||
|
||||
type TCommentsWrapper = {
|
||||
projectId?: string;
|
||||
entityId: string;
|
||||
isEditingAllowed?: boolean;
|
||||
activityOperations: TCommentsOperations;
|
||||
comments: TIssueComment[] | string[];
|
||||
sortOrder?: E_SORT_ORDER;
|
||||
getCommentById?: (activityId: string) => TIssueComment | undefined;
|
||||
showAccessSpecifier?: boolean;
|
||||
showCopyLinkOption?: boolean;
|
||||
};
|
||||
|
||||
export const CommentsWrapper: FC<TCommentsWrapper> = observer((props) => {
|
||||
const {
|
||||
entityId,
|
||||
activityOperations,
|
||||
comments,
|
||||
getCommentById,
|
||||
isEditingAllowed = true,
|
||||
projectId,
|
||||
showAccessSpecifier = false,
|
||||
showCopyLinkOption = false,
|
||||
} = props;
|
||||
// router
|
||||
const { workspaceSlug: routerWorkspaceSlug } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug?.toString();
|
||||
const renderCommentCreate = useMemo(
|
||||
() =>
|
||||
isEditingAllowed && (
|
||||
<CommentCreate
|
||||
workspaceSlug={workspaceSlug}
|
||||
entityId={entityId}
|
||||
activityOperations={activityOperations}
|
||||
projectId={projectId}
|
||||
/>
|
||||
),
|
||||
[isEditingAllowed, workspaceSlug, entityId, activityOperations, projectId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-y-2 h-full overflow-hidden">
|
||||
{renderCommentCreate}
|
||||
<div className="flex-grow py-4 overflow-y-auto">
|
||||
{comments?.map((data, index) => {
|
||||
let comment;
|
||||
if (typeof data === "string") {
|
||||
comment = getCommentById?.(data);
|
||||
} else {
|
||||
comment = data;
|
||||
}
|
||||
|
||||
if (!comment) return null;
|
||||
return (
|
||||
<CommentCard
|
||||
key={comment.id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
comment={comment as TIssueComment}
|
||||
activityOperations={activityOperations}
|
||||
disabled={!isEditingAllowed}
|
||||
ends={index === 0 ? "top" : index === comments.length - 1 ? "bottom" : undefined}
|
||||
projectId={projectId}
|
||||
showAccessSpecifier={showAccessSpecifier}
|
||||
showCopyLinkOption={showCopyLinkOption}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
1
apps/web/core/components/comments/index.ts
Normal file
1
apps/web/core/components/comments/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./comments";
|
||||
115
apps/web/core/components/comments/quick-actions.tsx
Normal file
115
apps/web/core/components/comments/quick-actions.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Globe2, Link, Lock, Pencil, Trash2 } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIssueCommentAccessSpecifier } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssueComment, TCommentsOperations } from "@plane/types";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
type TCommentCard = {
|
||||
activityOperations: TCommentsOperations;
|
||||
comment: TIssueComment;
|
||||
setEditMode: () => void;
|
||||
showAccessSpecifier: boolean;
|
||||
showCopyLinkOption: boolean;
|
||||
};
|
||||
|
||||
export const CommentQuickActions: FC<TCommentCard> = observer((props) => {
|
||||
const { activityOperations, comment, setEditMode, showAccessSpecifier, showCopyLinkOption } = props;
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
// derived values
|
||||
const isAuthor = currentUser?.id === comment.actor;
|
||||
const canEdit = isAuthor;
|
||||
const canDelete = isAuthor;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "edit",
|
||||
action: setEditMode,
|
||||
title: t("common.actions.edit"),
|
||||
icon: Pencil,
|
||||
shouldRender: canEdit,
|
||||
},
|
||||
{
|
||||
key: "copy_link",
|
||||
action: () => activityOperations.copyCommentLink(comment.id),
|
||||
title: t("common.actions.copy_link"),
|
||||
icon: Link,
|
||||
shouldRender: showCopyLinkOption,
|
||||
},
|
||||
{
|
||||
key: "access_specifier",
|
||||
action: () =>
|
||||
activityOperations.updateComment(comment.id, {
|
||||
access:
|
||||
comment.access === EIssueCommentAccessSpecifier.INTERNAL
|
||||
? EIssueCommentAccessSpecifier.EXTERNAL
|
||||
: EIssueCommentAccessSpecifier.INTERNAL,
|
||||
}),
|
||||
title:
|
||||
comment.access === EIssueCommentAccessSpecifier.INTERNAL
|
||||
? t("issue.comments.switch.public")
|
||||
: t("issue.comments.switch.private"),
|
||||
icon: comment.access === EIssueCommentAccessSpecifier.INTERNAL ? Globe2 : Lock,
|
||||
shouldRender: showAccessSpecifier,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: () => activityOperations.removeComment(comment.id),
|
||||
title: t("common.actions.delete"),
|
||||
icon: Trash2,
|
||||
shouldRender: canDelete,
|
||||
},
|
||||
],
|
||||
[activityOperations, canDelete, canEdit, comment, setEditMode, showAccessSpecifier, showCopyLinkOption]
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomMenu ellipsis closeOnSelect>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={() => item.action()}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className={cn("shrink-0 size-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
);
|
||||
});
|
||||
53
apps/web/core/components/common/access-field.tsx
Normal file
53
apps/web/core/components/common/access-field.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
// plane ui
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
onChange: (value: number) => void;
|
||||
value: number;
|
||||
accessSpecifiers: {
|
||||
key: number;
|
||||
i18n_label?: string;
|
||||
label?: string;
|
||||
icon: LucideIcon;
|
||||
}[];
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
// TODO: Remove label once i18n is done
|
||||
export const AccessField = (props: Props) => {
|
||||
const { onChange, value, accessSpecifiers, isMobile = false } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-shrink-0 items-stretch gap-0.5 rounded border-[1px] border-custom-border-200 p-1">
|
||||
{accessSpecifiers.map((access, index) => {
|
||||
const label = access.i18n_label ? t(access.i18n_label) : access.label;
|
||||
return (
|
||||
<Tooltip key={access.key} tooltipContent={label} isMobile={isMobile}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(access.key)}
|
||||
className={cn(
|
||||
"flex-shrink-0 relative flex justify-center items-center w-5 h-5 rounded-sm p-1 transition-all",
|
||||
value === access.key ? "bg-custom-background-80" : "hover:bg-custom-background-80"
|
||||
)}
|
||||
tabIndex={2 + index}
|
||||
>
|
||||
<access.icon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-all",
|
||||
value === access.key ? "text-custom-text-100" : "text-custom-text-400"
|
||||
)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
56
apps/web/core/components/common/activity/activity-block.tsx
Normal file
56
apps/web/core/components/common/activity/activity-block.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Network } from "lucide-react";
|
||||
// types
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TWorkspaceBaseActivity } from "@plane/types";
|
||||
// ui
|
||||
// helpers
|
||||
import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@plane/utils";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local components
|
||||
import { User } from "./user";
|
||||
|
||||
type TActivityBlockComponent = {
|
||||
icon?: ReactNode;
|
||||
activity: TWorkspaceBaseActivity;
|
||||
ends: "top" | "bottom" | undefined;
|
||||
children: ReactNode;
|
||||
customUserName?: string;
|
||||
};
|
||||
|
||||
export const ActivityBlockComponent: FC<TActivityBlockComponent> = (props) => {
|
||||
const { icon, activity, ends, children, customUserName } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<div
|
||||
className={`relative flex items-start gap-2 text-xs ${
|
||||
ends === "top" ? `pb-3` : ends === "bottom" ? `pt-3` : `py-3`
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 ring-6 w-7 h-7 rounded-full overflow-hidden flex justify-center items-start mt-0.5 z-[4] text-custom-text-200">
|
||||
{icon ? icon : <Network className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
<div className="w-full text-custom-text-200">
|
||||
<div className="line-clamp-2">
|
||||
<User activity={activity} customUserName={customUserName} /> {children}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`}
|
||||
>
|
||||
<span className="whitespace-nowrap text-custom-text-350 font-medium cursor-help">
|
||||
{calculateTimeAgo(activity.created_at)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
apps/web/core/components/common/activity/activity-item.tsx
Normal file
32
apps/web/core/components/common/activity/activity-item.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
import type { TProjectActivity } from "@/plane-web/types";
|
||||
import { ActivityBlockComponent } from "./activity-block";
|
||||
import { iconsMap, messages } from "./helper";
|
||||
|
||||
type TActivityItem = {
|
||||
activity: TProjectActivity;
|
||||
showProject?: boolean;
|
||||
ends?: "top" | "bottom" | undefined;
|
||||
};
|
||||
|
||||
export const ActivityItem: FC<TActivityItem> = observer((props) => {
|
||||
const { activity, showProject = true, ends } = props;
|
||||
|
||||
if (!activity) return null;
|
||||
|
||||
const activityType = activity.field;
|
||||
if (!activityType) return null;
|
||||
|
||||
const { message, customUserName } = messages(activity);
|
||||
const icon = iconsMap[activityType] || iconsMap.default;
|
||||
|
||||
return (
|
||||
<ActivityBlockComponent icon={icon} activity={activity} ends={ends} customUserName={customUserName}>
|
||||
<>{message}</>
|
||||
</ActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
281
apps/web/core/components/common/activity/helper.tsx
Normal file
281
apps/web/core/components/common/activity/helper.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Signal,
|
||||
RotateCcw,
|
||||
Network,
|
||||
Link as LinkIcon,
|
||||
Calendar,
|
||||
Tag,
|
||||
Inbox,
|
||||
AlignLeft,
|
||||
Users,
|
||||
Paperclip,
|
||||
Type,
|
||||
Triangle,
|
||||
FileText,
|
||||
Globe,
|
||||
Hash,
|
||||
Clock,
|
||||
Bell,
|
||||
LayoutGrid,
|
||||
GitBranch,
|
||||
Timer,
|
||||
ListTodo,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
|
||||
// components
|
||||
import { ArchiveIcon, CycleIcon, DoubleCircleIcon, IntakeIcon, ModuleIcon } from "@plane/propel/icons";
|
||||
import { store } from "@/lib/store-context";
|
||||
import type { TProjectActivity } from "@/plane-web/types";
|
||||
|
||||
type ActivityIconMap = {
|
||||
[key: string]: ReactNode;
|
||||
};
|
||||
export const iconsMap: ActivityIconMap = {
|
||||
priority: <Signal size={14} className="text-custom-text-200" />,
|
||||
archived_at: <ArchiveIcon className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
restored: <RotateCcw className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
link: <LinkIcon className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
start_date: <Calendar className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
target_date: <Calendar className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
label: <Tag className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
inbox: <Inbox className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
description: <AlignLeft className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
assignee: <Users className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
attachment: <Paperclip className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
name: <Type className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
state: <DoubleCircleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />,
|
||||
estimate: <Triangle size={14} className="text-custom-text-200" />,
|
||||
cycle: <CycleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />,
|
||||
module: <ModuleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />,
|
||||
page: <FileText className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
network: <Globe className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
identifier: <Hash className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
timezone: <Clock className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
is_project_updates_enabled: <Bell className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
is_epic_enabled: <LayoutGrid className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
is_workflow_enabled: <GitBranch className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
is_time_tracking_enabled: <Timer className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
is_issue_type_enabled: <ListTodo className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
default: <Network className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
module_view: <ModuleIcon className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
cycle_view: <CycleIcon className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
issue_views_view: <Layers className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
page_view: <FileText className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
intake_view: <IntakeIcon className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
};
|
||||
|
||||
export const messages = (activity: TProjectActivity): { message: string | ReactNode; customUserName?: string } => {
|
||||
const activityType = activity.field;
|
||||
const newValue = activity.new_value;
|
||||
const oldValue = activity.old_value;
|
||||
const verb = activity.verb;
|
||||
const workspaceDetail = store.workspaceRoot.getWorkspaceById(activity.workspace);
|
||||
|
||||
const getBooleanActionText = (value: string | undefined) => {
|
||||
if (value === "true") return "enabled";
|
||||
if (value === "false") return "disabled";
|
||||
return verb;
|
||||
};
|
||||
|
||||
switch (activityType) {
|
||||
case "priority":
|
||||
return {
|
||||
message: (
|
||||
<>
|
||||
set the priority to <span className="font-medium text-custom-text-100">{newValue || "none"}</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
case "archived_at":
|
||||
return {
|
||||
message: newValue === "restore" ? "restored the project" : "archived the project",
|
||||
customUserName: newValue === "archive" ? "Plane" : undefined,
|
||||
};
|
||||
case "name":
|
||||
return {
|
||||
message: (
|
||||
<>
|
||||
renamed the project to <span className="font-medium text-custom-text-100">{newValue}</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
case "description":
|
||||
return {
|
||||
message: newValue ? "updated the project description" : "removed the project description",
|
||||
};
|
||||
case "start_date":
|
||||
return {
|
||||
message: (
|
||||
<>
|
||||
{newValue ? (
|
||||
<>
|
||||
set the start date to <span className="font-medium text-custom-text-100">{newValue}</span>
|
||||
</>
|
||||
) : (
|
||||
"removed the start date"
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
case "target_date":
|
||||
return {
|
||||
message: (
|
||||
<>
|
||||
{newValue ? (
|
||||
<>
|
||||
set the target date to <span className="font-medium text-custom-text-100">{newValue}</span>
|
||||
</>
|
||||
) : (
|
||||
"removed the target date"
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
case "state":
|
||||
return {
|
||||
message: (
|
||||
<>
|
||||
set the state to <span className="font-medium text-custom-text-100">{newValue || "none"}</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
case "estimate":
|
||||
return {
|
||||
message: (
|
||||
<>
|
||||
{newValue ? (
|
||||
<>
|
||||
set the estimate point to <span className="font-medium text-custom-text-100">{newValue}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
removed the estimate point
|
||||
{oldValue && (
|
||||
<>
|
||||
{" "}
|
||||
<span className="font-medium text-custom-text-100">{oldValue}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
case "cycles":
|
||||
return {
|
||||
message: (
|
||||
<>
|
||||
<span>
|
||||
{verb} this project {verb === "removed" ? "from" : "to"} the cycle{" "}
|
||||
</span>
|
||||
{verb !== "removed" ? (
|
||||
<a
|
||||
href={`/${workspaceDetail?.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex font-medium text-custom-text-100"
|
||||
>
|
||||
{activity.new_value}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value || "Unknown cycle"}</span>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
case "modules":
|
||||
return {
|
||||
message: (
|
||||
<>
|
||||
<span>
|
||||
{verb} this project {verb === "removed" ? "from" : "to"} the module{" "}
|
||||
</span>
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{verb === "removed" ? oldValue : newValue || "Unknown module"}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
case "labels":
|
||||
return {
|
||||
message: (
|
||||
<>
|
||||
{verb} the label{" "}
|
||||
<span className="font-medium text-custom-text-100">{newValue || oldValue || "Untitled label"}</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
case "inbox":
|
||||
return {
|
||||
message: <>{newValue ? "enabled" : "disabled"} inbox</>,
|
||||
};
|
||||
case "page":
|
||||
return {
|
||||
message: (
|
||||
<>
|
||||
{newValue ? "created" : "removed"} the project page{" "}
|
||||
<span className="font-medium text-custom-text-100">{newValue || oldValue || "Untitled page"}</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
case "network":
|
||||
return {
|
||||
message: <>{newValue ? "enabled" : "disabled"} network access</>,
|
||||
};
|
||||
case "identifier":
|
||||
return {
|
||||
message: (
|
||||
<>
|
||||
updated project identifier to <span className="font-medium text-custom-text-100">{newValue || "none"}</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
case "timezone":
|
||||
return {
|
||||
message: (
|
||||
<>
|
||||
changed project timezone to{" "}
|
||||
<span className="font-medium text-custom-text-100">{newValue || "default"}</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
case "module_view":
|
||||
case "cycle_view":
|
||||
case "issue_views_view":
|
||||
case "page_view":
|
||||
case "intake_view":
|
||||
return {
|
||||
message: (
|
||||
<>
|
||||
{getBooleanActionText(newValue)} {activityType.replace(/_view$/, "").replace(/_/g, " ")} view
|
||||
</>
|
||||
),
|
||||
};
|
||||
case "is_project_updates_enabled":
|
||||
return {
|
||||
message: <>{getBooleanActionText(newValue)} project updates</>,
|
||||
};
|
||||
case "is_epic_enabled":
|
||||
return {
|
||||
message: <>{getBooleanActionText(newValue)} epics</>,
|
||||
};
|
||||
case "is_workflow_enabled":
|
||||
return {
|
||||
message: <>{getBooleanActionText(newValue)} custom workflow</>,
|
||||
};
|
||||
case "is_time_tracking_enabled":
|
||||
return {
|
||||
message: <>{getBooleanActionText(newValue)} time tracking</>,
|
||||
};
|
||||
case "is_issue_type_enabled":
|
||||
return {
|
||||
message: <>{getBooleanActionText(newValue)} work item types</>,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
message: `${verb} ${activityType?.replace(/_/g, " ")} `,
|
||||
};
|
||||
}
|
||||
};
|
||||
38
apps/web/core/components/common/activity/user.tsx
Normal file
38
apps/web/core/components/common/activity/user.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
// types
|
||||
import type { TWorkspaceBaseActivity } from "@plane/types";
|
||||
// store hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
type TUser = {
|
||||
activity: TWorkspaceBaseActivity;
|
||||
customUserName?: string;
|
||||
};
|
||||
|
||||
export const User: FC<TUser> = observer((props) => {
|
||||
const { activity, customUserName } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const { getWorkspaceById } = useWorkspace();
|
||||
// derived values
|
||||
const actorDetail = getUserDetails(activity.actor);
|
||||
const workspaceDetail = getWorkspaceById(activity.workspace);
|
||||
|
||||
return (
|
||||
<>
|
||||
{customUserName || actorDetail?.display_name.includes("-intake") ? (
|
||||
<span className="text-custom-text-100 font-medium">{customUserName || "Plane"}</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`/${workspaceDetail?.slug}/profile/${actorDetail?.id}`}
|
||||
className="hover:underline text-custom-text-100 font-medium"
|
||||
>
|
||||
{actorDetail?.display_name}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
54
apps/web/core/components/common/applied-filters/date.tsx
Normal file
54
apps/web/core/components/common/applied-filters/date.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { X } from "lucide-react";
|
||||
// plane constants
|
||||
import { DATE_BEFORE_FILTER_OPTIONS } from "@plane/constants";
|
||||
import { renderFormattedDate, capitalizeFirstLetter } from "@plane/utils";
|
||||
// helpers
|
||||
type Props = {
|
||||
editable: boolean | undefined;
|
||||
handleRemove: (val: string) => void;
|
||||
values: string[];
|
||||
};
|
||||
|
||||
export const AppliedDateFilters: React.FC<Props> = observer((props) => {
|
||||
const { editable, handleRemove, values } = props;
|
||||
|
||||
const getDateLabel = (value: string): string => {
|
||||
let dateLabel = "";
|
||||
|
||||
const dateDetails = DATE_BEFORE_FILTER_OPTIONS.find((d) => d.value === value);
|
||||
|
||||
if (dateDetails) dateLabel = dateDetails.name;
|
||||
else {
|
||||
const dateParts = value.split(";");
|
||||
|
||||
if (dateParts.length === 2) {
|
||||
const [date, time] = dateParts;
|
||||
|
||||
dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return dateLabel;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{values.map((date) => (
|
||||
<div key={date} className="flex items-center gap-1 rounded bg-custom-background-80 py-1 px-1.5 text-xs">
|
||||
<span className="normal-case">{getDateLabel(date)}</span>
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
onClick={() => handleRemove(date)}
|
||||
>
|
||||
<X size={10} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user