Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
import type { FC } from "react";
import { Info } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { CloseIcon } from "@plane/propel/icons";
// plane imports
// 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")}
>
<CloseIcon className="size-4" />
</button>
</div>
);
};

View 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>
);
});

View 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 "@/app/assets/logos/github-black.png?url";
import GithubDarkLogo from "@/app/assets/logos/github-dark.svg?url";
import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url";
import GoogleLogo from "@/app/assets/logos/google-logo.svg?url";
// 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>
);
});

View File

@@ -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>
);

View File

@@ -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>
);

View 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>
);
});

View File

@@ -0,0 +1,61 @@
import { Fragment, useState } from "react";
import { usePopper } from "react-popper";
import { Popover } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { CloseIcon } from "@plane/propel/icons";
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")}
>
<CloseIcon className="size-3 text-custom-text-200" />
</button>
</div>
)}
</Popover.Panel>
</Popover>
);
};

View 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>
);
});

View 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 <></>;
});

View File

@@ -0,0 +1 @@
export * from "./auth-root";

View File

@@ -0,0 +1,328 @@
"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, 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 { CloseIcon } from "@plane/propel/icons";
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)}
>
<CloseIcon 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>
</>
);
});

View 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>
);
});

View 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>
);
});

View 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>
);
};

View 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>
);
};

View 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>
);

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -0,0 +1,53 @@
import React from "react";
import Image from "next/image";
import { useTheme } from "next-themes";
// plane package imports
import { cn } from "@plane/utils";
// assets
import darkBackgroundAsset from "@/app/assets/empty-state/analytics/empty-grid-background-dark.webp?url";
import lightBackgroundAsset from "@/app/assets/empty-state/analytics/empty-grid-background-light.webp?url";
type Props = {
title: string;
description?: string;
assetPath?: string;
className?: string;
};
const AnalyticsEmptyState = ({ title, description, assetPath, className }: Props) => {
// theme hook
const { resolvedTheme } = useTheme();
const backgroundReolvedPath = resolvedTheme === "light" ? lightBackgroundAsset : darkBackgroundAsset;
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;

View 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);
};

View 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;

View File

@@ -0,0 +1,171 @@
"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,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Search } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { CloseIcon } from "@plane/propel/icons";
// plane package imports
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table";
import { cn } from "@plane/utils";
// plane web components
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 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);
}}
>
<CloseIcon 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">
<EmptyStateCompact
assetKey="unknown"
assetClassName="size-20"
rootClassName="border border-custom-border-100 px-5 py-10 md:py-20 md:px-20"
title={t("workspace_empty_state.analytics_work_items.title")}
/>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export * from "./root";

View 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>
);

View 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>
);
};

View 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>
);

View File

@@ -0,0 +1,57 @@
// plane package imports
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ProjectIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils";
// 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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,117 @@
import { lazy, Suspense } 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 { EmptyStateCompact } from "@plane/propel/empty-state";
import type { TChartData } from "@plane/types";
// hooks
import { useAnalytics } from "@/hooks/store/use-analytics";
// services
import { AnalyticsService } from "@/services/analytics.service";
// plane web components
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
import { ProjectInsightsLoader } from "../loaders";
const RadarChart = lazy(() =>
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 { 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 ? (
<EmptyStateCompact
assetKey="unknown"
assetClassName="size-20"
rootClassName="border border-custom-border-100 px-5 py-10 md:py-20 md:px-20"
title={t("workspace_empty_state.analytics_work_items.title")}
/>
) : (
<div className="gap-8 lg:flex">
{projectInsightsData && (
<Suspense fallback={<ProjectInsightsLoader />}>
<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",
}}
/>
</Suspense>
)}
<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;

View 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 };

View 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>
);
});

View 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;

View File

@@ -0,0 +1,61 @@
"use client";
import { observer } from "mobx-react";
// plane package imports
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ProjectIcon } from "@plane/propel/icons";
import { CustomSearchSelect } from "@plane/ui";
// 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
/>
);
});

View 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>
);
};

View 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>
);
});

View 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;

View 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;

View File

@@ -0,0 +1,133 @@
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 { EmptyStateCompact } from "@plane/propel/empty-state";
import type { IChartResponse, TChartData } from "@plane/types";
import { renderFormattedDate } from "@plane/utils";
// hooks
import { useAnalytics } from "@/hooks/store/use-analytics";
// services
import { AnalyticsService } from "@/services/analytics.service";
// plane web components
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
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 { 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",
},
}}
/>
) : (
<EmptyStateCompact
assetKey="unknown"
assetClassName="size-20"
rootClassName="border border-custom-border-100 px-5 py-10 md:py-20 md:px-20"
title={t("workspace_empty_state.analytics_work_items.title")}
/>
)}
</AnalyticsSectionWrapper>
);
});
export default CreatedVsResolved;

View File

@@ -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;

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -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>
);
});

View File

@@ -0,0 +1,43 @@
import { observer } from "mobx-react";
// plane package imports
import { Expand, Shrink } from "lucide-react";
import { CloseIcon } from "@plane/propel/icons";
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}
>
<CloseIcon height={14} width={14} strokeWidth={2} />
</button>
</div>
</div>
);
});

View File

@@ -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>
);
});

View File

@@ -0,0 +1,244 @@
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 { EmptyStateCompact } from "@plane/propel/empty-state";
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 { AnalyticsService } from "@/services/analytics.service";
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();
// 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>
)}
/>
</>
) : (
<EmptyStateCompact
assetKey="unknown"
assetClassName="size-20"
rootClassName="border border-custom-border-100 px-5 py-10 md:py-20 md:px-20"
title={t("workspace_empty_state.analytics_work_items.title")}
/>
)}
</div>
);
});
export default PriorityChart;

View 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 };

View 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;
};

View File

@@ -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 { Logo } from "@plane/propel/emoji-icon-picker";
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";
// 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;

View 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")} </>}
/>
);
};

View 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 "@/app/assets/empty-state/api-token.svg?url";
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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View 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>
</>
);
};

View 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>
)
)}
</>
);
});

View File

@@ -0,0 +1 @@
export * from "./archive-tabs-list";

View 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>
);

View 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>
);

View 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>
</>
);
});

View File

@@ -0,0 +1,36 @@
import React from "react";
import { observer } from "mobx-react";
import Image from "next/image";
// assets
import ProjectNotAuthorizedImg from "@/app/assets/auth/project-not-authorized.svg?url";
import Unauthorized from "@/app/assets/auth/unauthorized.svg?url";
import WorkspaceNotAuthorizedImg from "@/app/assets/auth/workspace-not-authorized.svg?url";
// layouts
import DefaultLayout from "@/layouts/default-layout";
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>
);
});

View File

@@ -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";
// assets
import Unauthorized from "@/app/assets/auth/unauthorized.svg?url";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
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>
);
};

View File

@@ -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>
);

View 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>
</>
);
});

View 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, StatePropertyIcon } 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}
/>
) : (
<StatePropertyIcon 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>
</>
);
});

View File

@@ -0,0 +1,3 @@
export * from "./auto-close-automation";
export * from "./auto-archive-automation";
export * from "./select-month-modal";

View 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>
);
};

View File

@@ -0,0 +1,15 @@
import { BoardLayoutIcon, ListLayoutIcon } from "@plane/propel/icons";
import type { IBaseLayoutConfig } from "@plane/types";
export const BASE_LAYOUTS: IBaseLayoutConfig[] = [
{
key: "list",
icon: ListLayoutIcon,
label: "List Layout",
},
{
key: "kanban",
icon: BoardLayoutIcon,
label: "Board Layout",
},
];

View File

@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from "react";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
interface UseGroupDropTargetProps {
groupId: string;
enableDragDrop?: boolean;
onDrop?: (itemId: string, targetId: string | null, sourceGroupId: string, targetGroupId: string) => void;
}
interface DragSourceData {
id: string;
groupId: string;
type: "ITEM" | "GROUP";
}
/**
* A hook that turns an element into a valid drop target for group drag-and-drop.
*
* @returns groupRef (attach to the droppable container) and isDraggingOver (for visual feedback)
*/
export const useGroupDropTarget = ({ groupId, enableDragDrop = false, onDrop }: UseGroupDropTargetProps) => {
const groupRef = useRef<HTMLDivElement | null>(null);
const [isDraggingOver, setIsDraggingOver] = useState(false);
useEffect(() => {
const element = groupRef.current;
if (!element || !enableDragDrop || !onDrop) return;
const cleanup = dropTargetForElements({
element,
getData: () => ({ groupId, type: "GROUP" }),
canDrop: ({ source }) => {
const data = (source?.data || {}) as Partial<DragSourceData>;
return data.type === "ITEM" && !!data.groupId && data.groupId !== groupId;
},
onDragEnter: () => setIsDraggingOver(true),
onDragLeave: () => setIsDraggingOver(false),
onDrop: ({ source }) => {
setIsDraggingOver(false);
const data = (source?.data || {}) as Partial<DragSourceData>;
if (data.type !== "ITEM" || !data.id || !data.groupId) return;
if (data.groupId !== groupId) {
onDrop(data.id, null, data.groupId, groupId);
}
},
});
return cleanup;
}, [groupId, enableDragDrop, onDrop]);
return { groupRef, isDraggingOver };
};

View File

@@ -0,0 +1,58 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
type UseLayoutStateProps =
| {
mode: "external";
externalCollapsedGroups: string[];
externalOnToggleGroup: (groupId: string) => void;
enableAutoScroll?: boolean;
}
| {
mode?: "internal";
enableAutoScroll?: boolean;
};
/**
* Hook for managing layout state including:
* - Collapsed/expanded group tracking (internal or external)
* - Auto-scroll setup for drag-and-drop
*/
export const useLayoutState = (props: UseLayoutStateProps = { mode: "internal" }) => {
const containerRef = useRef<HTMLDivElement | null>(null);
// Internal fallback state
const [internalCollapsedGroups, setInternalCollapsedGroups] = useState<string[]>([]);
// Stable internal toggle function
const internalToggleGroup = useCallback((groupId: string) => {
setInternalCollapsedGroups((prev) =>
prev.includes(groupId) ? prev.filter((id) => id !== groupId) : [...prev, groupId]
);
}, []);
const useExternal = props.mode === "external";
const collapsedGroups = useExternal ? props.externalCollapsedGroups : internalCollapsedGroups;
const onToggleGroup = useExternal ? props.externalOnToggleGroup : internalToggleGroup;
// Enable auto-scroll for DnD
useEffect(() => {
const element = containerRef.current;
if (!element || !props.enableAutoScroll) return;
const cleanup = combine(
autoScrollForElements({
element,
})
);
return cleanup;
}, [props.enableAutoScroll]);
return {
containerRef,
collapsedGroups,
onToggleGroup,
};
};

View File

@@ -0,0 +1,14 @@
import type { IGroupHeaderProps } from "@plane/types";
export const GroupHeader = ({ group, itemCount, onToggleGroup }: IGroupHeaderProps) => (
<button
onClick={() => onToggleGroup(group.id)}
className="flex w-full items-center gap-2 text-sm font-medium text-custom-text-200"
>
<div className="flex items-center gap-2">
{group.icon}
<span>{group.name}</span>
</div>
<span className="text-xs text-custom-text-300">{itemCount}</span>
</button>
);

View File

@@ -0,0 +1,96 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { IBaseLayoutsKanbanItem, IBaseLayoutsKanbanGroupProps } from "@plane/types";
import { cn } from "@plane/utils";
import { useGroupDropTarget } from "../hooks/use-group-drop-target";
import { GroupHeader } from "./group-header";
import { BaseKanbanItem } from "./item";
export const BaseKanbanGroup = observer(<T extends IBaseLayoutsKanbanItem>(props: IBaseLayoutsKanbanGroupProps<T>) => {
const {
group,
itemIds,
items,
renderItem,
renderGroupHeader,
isCollapsed,
onToggleGroup,
enableDragDrop = false,
onDrop,
canDrag,
groupClassName,
loadMoreItems: _loadMoreItems,
} = props;
const { t } = useTranslation();
const { groupRef, isDraggingOver } = useGroupDropTarget({
groupId: group.id,
enableDragDrop,
onDrop,
});
return (
<div
ref={groupRef}
className={cn(
"relative flex flex-shrink-0 flex-col w-[350px] border-[1px] border-transparent p-2 pt-0 max-h-full overflow-y-auto bg-custom-background-90 rounded-md",
{
"bg-custom-background-80": isDraggingOver,
},
groupClassName
)}
>
{/* Group Header */}
<div className="sticky top-0 z-[2] w-full flex-shrink-0 bg-custom-background-90 px-1 py-2 cursor-pointer">
{renderGroupHeader ? (
renderGroupHeader({ group, itemCount: itemIds.length, isCollapsed, onToggleGroup })
) : (
<GroupHeader
group={group}
itemCount={itemIds.length}
isCollapsed={isCollapsed}
onToggleGroup={onToggleGroup}
/>
)}
</div>
{/* Group Items */}
{!isCollapsed && (
<div className="flex flex-col gap-2 py-2">
{itemIds.map((itemId, index) => {
const item = items[itemId];
if (!item) return null;
return (
<BaseKanbanItem
key={itemId}
item={item}
index={index}
groupId={group.id}
renderItem={renderItem}
enableDragDrop={enableDragDrop}
canDrag={canDrag}
onDrop={onDrop}
isLast={index === itemIds.length - 1}
/>
);
})}
{itemIds.length === 0 && (
<div className="flex items-center justify-center py-8 text-sm text-custom-text-300">
{t("common.no_items_in_this_group")}
</div>
)}
</div>
)}
{isDraggingOver && enableDragDrop && (
<div className="absolute top-0 left-0 h-full w-full flex items-center justify-center text-sm font-medium text-custom-text-300 rounded bg-custom-background-80/85 border-[1px] border-custom-border-300 z-[2]">
<div className="p-3 my-8 flex flex-col rounded items-center text-custom-text-200">
{t("common.drop_here_to_move")}
</div>
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,40 @@
import { useEffect, useRef } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { observer } from "mobx-react";
import type { IBaseLayoutsKanbanItem, IBaseLayoutsKanbanItemProps } from "@plane/types";
export const BaseKanbanItem = observer(<T extends IBaseLayoutsKanbanItem>(props: IBaseLayoutsKanbanItemProps<T>) => {
const { item, groupId, renderItem, enableDragDrop, canDrag } = props;
const itemRef = useRef<HTMLDivElement | null>(null);
const isDragAllowed = canDrag ? canDrag(item) : true;
// Setup draggable and drop target
useEffect(() => {
const element = itemRef.current;
if (!element || !enableDragDrop) return;
return combine(
draggable({
element,
canDrag: () => isDragAllowed,
getInitialData: () => ({ id: item.id, type: "ITEM", groupId }),
}),
dropTargetForElements({
element,
getData: () => ({ id: item.id, groupId, type: "ITEM" }),
canDrop: ({ source }) => source?.data?.id !== item.id,
})
);
}, [enableDragDrop, isDragAllowed, item.id, groupId]);
const renderedItem = renderItem(item, groupId);
return (
<div ref={itemRef} className="cursor-pointer">
{renderedItem}
</div>
);
});

View File

@@ -0,0 +1,68 @@
"use client";
import { observer } from "mobx-react";
import type { IBaseLayoutsKanbanItem, IBaseLayoutsKanbanProps } from "@plane/types";
import { cn } from "@plane/utils";
import { useLayoutState } from "../hooks/use-layout-state";
import { BaseKanbanGroup } from "./group";
export const BaseKanbanLayout = observer(<T extends IBaseLayoutsKanbanItem>(props: IBaseLayoutsKanbanProps<T>) => {
const {
items,
groups,
groupedItemIds,
renderItem,
renderGroupHeader,
onDrop,
canDrag,
className,
groupClassName,
showEmptyGroups = true,
enableDragDrop = false,
loadMoreItems,
collapsedGroups: externalCollapsedGroups,
onToggleGroup: externalOnToggleGroup,
} = props;
const useExternalMode = externalCollapsedGroups !== undefined && externalOnToggleGroup !== undefined;
const { containerRef, collapsedGroups, onToggleGroup } = useLayoutState(
useExternalMode
? {
mode: "external",
externalCollapsedGroups,
externalOnToggleGroup,
}
: {
mode: "internal",
}
);
return (
<div ref={containerRef} className={cn("relative w-full flex gap-2 p-3 h-full overflow-x-auto", className)}>
{groups.map((group) => {
const itemIds = groupedItemIds[group.id] || [];
const isCollapsed = collapsedGroups.includes(group.id);
if (!showEmptyGroups && itemIds.length === 0) return null;
return (
<BaseKanbanGroup
key={group.id}
group={group}
itemIds={itemIds}
items={items}
renderItem={renderItem}
renderGroupHeader={renderGroupHeader}
isCollapsed={isCollapsed}
onToggleGroup={onToggleGroup}
enableDragDrop={enableDragDrop}
onDrop={onDrop}
canDrag={canDrag}
groupClassName={groupClassName}
loadMoreItems={loadMoreItems}
/>
);
})}
</div>
);
});

View File

@@ -0,0 +1,50 @@
"use client";
import React from "react";
import { Tooltip } from "@plane/propel/tooltip";
import type { TBaseLayoutType } from "@plane/types";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { BASE_LAYOUTS } from "./constants";
type Props = {
layouts?: TBaseLayoutType[];
onChange: (layout: TBaseLayoutType) => void;
selectedLayout: TBaseLayoutType;
};
export const LayoutSwitcher: React.FC<Props> = (props) => {
const { layouts, onChange, selectedLayout } = props;
const { isMobile } = usePlatformOS();
const handleOnChange = (layoutKey: TBaseLayoutType) => {
if (selectedLayout !== layoutKey) {
onChange(layoutKey);
}
};
return (
<div className="flex items-center gap-1 rounded bg-custom-background-80 p-1">
{BASE_LAYOUTS.filter((l) => (layouts ? layouts.includes(l.key) : true)).map((layout) => {
const Icon = layout.icon;
return (
<Tooltip key={layout.key} tooltipContent={layout.label} isMobile={isMobile}>
<button
type="button"
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
selectedLayout === layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
}`}
onClick={() => handleOnChange(layout.key)}
>
<Icon
strokeWidth={2}
className={`h-3.5 w-3.5 ${
selectedLayout === layout.key ? "text-custom-text-100" : "text-custom-text-200"
}`}
/>
</button>
</Tooltip>
);
})}
</div>
);
};

View File

@@ -0,0 +1,12 @@
import type { IGroupHeaderProps } from "@plane/types";
export const GroupHeader = ({ group, itemCount, onToggleGroup }: IGroupHeaderProps) => (
<button
onClick={() => onToggleGroup(group.id)}
className="flex w-full items-center gap-2 py-2 text-sm font-medium text-custom-text-200"
>
{group.icon}
<span>{group.name}</span>
<span className="text-xs text-custom-text-300">{itemCount}</span>
</button>
);

View File

@@ -0,0 +1,85 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { IBaseLayoutsListItem, IBaseLayoutsListGroupProps } from "@plane/types";
import { cn, Row } from "@plane/ui";
import { useGroupDropTarget } from "../hooks/use-group-drop-target";
import { GroupHeader } from "./group-header";
import { BaseListItem } from "./item";
export const BaseListGroup = observer(<T extends IBaseLayoutsListItem>(props: IBaseLayoutsListGroupProps<T>) => {
const {
group,
itemIds,
items,
isCollapsed,
onToggleGroup,
renderItem,
renderGroupHeader,
enableDragDrop = false,
onDrop,
canDrag,
loadMoreItems: _loadMoreItems,
} = props;
const { t } = useTranslation();
const { groupRef, isDraggingOver } = useGroupDropTarget({
groupId: group.id,
enableDragDrop,
onDrop,
});
return (
<div
ref={groupRef}
className={cn("relative flex flex-shrink-0 flex-col border-[1px] border-transparent", {
"bg-custom-background-80": isDraggingOver,
})}
>
{/* Group Header */}
<Row className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 py-1">
{renderGroupHeader ? (
renderGroupHeader({ group, itemCount: itemIds.length, isCollapsed, onToggleGroup })
) : (
<GroupHeader
group={group}
itemCount={itemIds.length}
isCollapsed={isCollapsed}
onToggleGroup={onToggleGroup}
/>
)}
</Row>
{/* Group Items */}
{!isCollapsed && (
<div className="relative">
{itemIds.map((itemId: string, index: number) => {
const item = items[itemId];
if (!item) return null;
return (
<BaseListItem
key={itemId}
item={item}
index={index}
groupId={group.id}
renderItem={renderItem}
enableDragDrop={enableDragDrop}
canDrag={canDrag}
onDrop={onDrop}
isLast={index === itemIds.length - 1}
/>
);
})}
</div>
)}
{isDraggingOver && enableDragDrop && (
<div className="absolute top-0 left-0 h-full w-full flex items-center justify-center text-sm font-medium text-custom-text-300 rounded bg-custom-background-80/85 border-[1px] border-custom-border-300 z-[2]">
<div className="p-3 my-8 flex flex-col rounded items-center text-custom-text-200">
{t("common.drop_here_to_move")}
</div>
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,38 @@
import { useEffect, useRef } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { observer } from "mobx-react";
import type { IBaseLayoutsListItem, IBaseLayoutsListItemProps } from "@plane/types";
export const BaseListItem = observer(<T extends IBaseLayoutsListItem>(props: IBaseLayoutsListItemProps<T>) => {
const { item, groupId, renderItem, enableDragDrop, canDrag, isLast: _isLast, index: _index } = props;
const itemRef = useRef<HTMLDivElement | null>(null);
const isDragAllowed = canDrag ? canDrag(item) : true;
useEffect(() => {
const element = itemRef.current;
if (!element || !enableDragDrop) return;
return combine(
draggable({
element,
canDrag: () => isDragAllowed,
getInitialData: () => ({ id: item.id, type: "ITEM", groupId }),
}),
dropTargetForElements({
element,
getData: () => ({ groupId, type: "ITEM" }),
canDrop: ({ source }) => source?.data?.id !== item.id,
})
);
}, [enableDragDrop, isDragAllowed, item.id, groupId]);
const renderedItem = renderItem(item, groupId);
return (
<div ref={itemRef} className="cursor-pointer">
{renderedItem}
</div>
);
});

View File

@@ -0,0 +1,68 @@
"use client";
import { observer } from "mobx-react";
import type { IBaseLayoutsListItem, IBaseLayoutsListProps } from "@plane/types";
import { cn } from "@plane/ui";
import { useLayoutState } from "../hooks/use-layout-state";
import { BaseListGroup } from "./group";
export const BaseListLayout = observer(<T extends IBaseLayoutsListItem>(props: IBaseLayoutsListProps<T>) => {
const {
items,
groupedItemIds,
groups,
renderItem,
renderGroupHeader,
enableDragDrop = false,
onDrop,
canDrag,
showEmptyGroups = false,
collapsedGroups: externalCollapsedGroups,
onToggleGroup: externalOnToggleGroup,
loadMoreItems,
className,
} = props;
const useExternalMode = externalCollapsedGroups !== undefined && externalOnToggleGroup !== undefined;
const { containerRef, collapsedGroups, onToggleGroup } = useLayoutState(
useExternalMode
? {
mode: "external",
externalCollapsedGroups,
externalOnToggleGroup,
}
: {
mode: "internal",
}
);
return (
<div ref={containerRef} className={cn("relative size-full overflow-auto bg-custom-background-90", className)}>
<div className="relative size-full flex flex-col">
{groups.map((group) => {
const itemIds = groupedItemIds[group.id] || [];
const isCollapsed = collapsedGroups.includes(group.id);
if (!showEmptyGroups && itemIds.length === 0) return null;
return (
<BaseListGroup
key={group.id}
group={group}
itemIds={itemIds}
items={items}
renderItem={renderItem}
renderGroupHeader={renderGroupHeader}
isCollapsed={isCollapsed}
onToggleGroup={onToggleGroup}
enableDragDrop={enableDragDrop}
onDrop={onDrop}
canDrag={canDrag}
loadMoreItems={loadMoreItems}
/>
);
})}
</div>
</div>
);
});

View File

@@ -0,0 +1,24 @@
import type { TBaseLayoutType } from "@plane/types";
import { KanbanLayoutLoader } from "@/components/ui/loader/layouts/kanban-layout-loader";
import { ListLayoutLoader } from "@/components/ui/loader/layouts/list-layout-loader";
interface GenericLayoutLoaderProps {
layout: TBaseLayoutType;
/** Optional custom loaders to override defaults */
customLoaders?: Partial<Record<TBaseLayoutType, React.ComponentType>>;
}
export const GenericLayoutLoader = ({ layout, customLoaders }: GenericLayoutLoaderProps) => {
const CustomLoader = customLoaders?.[layout];
if (CustomLoader) return <CustomLoader />;
switch (layout) {
case "list":
return <ListLayoutLoader />;
case "kanban":
return <KanbanLayoutLoader />;
default:
console.warn(`Unknown layout: ${layout}`);
return null;
}
};

View 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);
};

View 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>
);
});

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { useForm } from "react-hook-form";
import { Check } from "lucide-react";
import type { EditorRefApi } from "@plane/editor";
import { CloseIcon } from "@plane/propel/icons";
// plane imports
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>");
}}
>
<CloseIcon className="size-3 text-red-500 duration-300 group-hover:text-white" />
</button>
</div>
</form>
);
});

View 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>
);
});

View 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>
);
});

View File

@@ -0,0 +1,85 @@
"use client";
import type { FC } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
import type { TCommentsOperations, TIssueComment } from "@plane/types";
import { cn } from "@plane/utils";
// helpers
// local imports
export type TProps = {
comment: TIssueComment;
disabled?: boolean;
activityOperations: TCommentsOperations;
};
export const CommentReactions: FC<TProps> = observer((props) => {
const { comment, activityOperations, disabled = false } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
const userReactions = activityOperations.userReactions(comment.id);
const reactionIds = activityOperations.reactionIds(comment.id);
// Transform reactions data to Propel EmojiReactionType format
const reactions: EmojiReactionType[] = useMemo(() => {
if (!reactionIds) return [];
return Object.keys(reactionIds)
.filter((reaction) => reactionIds[reaction]?.length > 0)
.map((reaction) => {
// Get user names for this reaction
const tooltipContent = activityOperations.getReactionUsers(reaction, reactionIds);
// Parse the tooltip content string to extract user names
const users = tooltipContent ? tooltipContent.split(", ") : [];
return {
emoji: stringToEmoji(reaction),
count: reactionIds[reaction].length,
reacted: userReactions?.includes(reaction) || false,
users: users,
};
});
}, [reactionIds, userReactions, activityOperations]);
const handleReactionClick = (emoji: string) => {
if (disabled || !userReactions) return;
// Convert emoji back to decimal string format for the API
const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0));
const reactionString = emojiCodePoints.join("-");
activityOperations.react(comment.id, reactionString, userReactions);
};
const handleEmojiSelect = (emoji: string) => {
if (!userReactions) return;
// emoji is already in decimal string format from EmojiReactionPicker
activityOperations.react(comment.id, emoji, userReactions);
};
if (!userReactions) return null;
return (
<div className="relative">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={disabled}
label={
<EmojiReactionGroup
reactions={reactions}
onReactionClick={handleReactionClick}
showAddButton={!disabled}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});

View 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>
);
});

View File

@@ -0,0 +1 @@
export * from "./comments";

View 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>
);
});

View 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>
);
};

View 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>
);
};

View 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>
);
});

View File

@@ -0,0 +1,289 @@
import type { ReactNode } from "react";
import {
RotateCcw,
Network,
Link as LinkIcon,
Calendar,
Inbox,
AlignLeft,
Paperclip,
Type,
FileText,
Globe,
Hash,
Clock,
Bell,
LayoutGrid,
GitBranch,
Timer,
ListTodo,
Layers,
} from "lucide-react";
// components
import {
ArchiveIcon,
CycleIcon,
StatePropertyIcon,
IntakeIcon,
ModuleIcon,
PriorityPropertyIcon,
StartDatePropertyIcon,
DueDatePropertyIcon,
LabelPropertyIcon,
MembersPropertyIcon,
EstimatePropertyIcon,
} 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: <PriorityPropertyIcon className="h-3.5 w-3.5 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: <StartDatePropertyIcon className="h-3.5 w-3.5 text-custom-text-200" />,
target_date: <DueDatePropertyIcon className="h-3.5 w-3.5 text-custom-text-200" />,
label: <LabelPropertyIcon 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: <MembersPropertyIcon 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: <StatePropertyIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />,
estimate: <EstimatePropertyIcon className="h-3.5 w-3.5 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, " ")} `,
};
}
};

View 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>
)}
</>
);
});

View File

@@ -0,0 +1,54 @@
import { observer } from "mobx-react";
// icons
import { DATE_BEFORE_FILTER_OPTIONS } from "@plane/constants";
import { CloseIcon } from "@plane/propel/icons";
// 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)}
>
<CloseIcon height={10} width={10} strokeWidth={2} />
</button>
)}
</div>
))}
</>
);
});

View File

@@ -0,0 +1,55 @@
"use client";
import { observer } from "mobx-react";
import { CloseIcon } from "@plane/propel/icons";
// plane ui
import { Avatar } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
// types
import { useMember } from "@/hooks/store/use-member";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedMembersFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
// store hooks
const {
workspace: { getWorkspaceMemberDetails },
} = useMember();
return (
<>
{values.map((memberId) => {
const memberDetails = getWorkspaceMemberDetails(memberId)?.member;
if (!memberDetails) return null;
return (
<div key={memberId} className="flex items-center gap-1 rounded bg-custom-background-80 py-1 px-1.5 text-xs">
<Avatar
name={memberDetails.display_name}
src={getFileURL(memberDetails.avatar_url)}
showTooltip={false}
size={"sm"}
/>
<span className="normal-case">{memberDetails.display_name}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(memberId)}
>
<CloseIcon height={10} width={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,76 @@
"use client";
import type { ReactNode, FC } from "react";
import React, { useMemo } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { Breadcrumbs } from "@plane/ui";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
label?: string;
href?: string;
icon?: React.ReactNode;
disableTooltip?: boolean;
isLast?: boolean;
};
const IconWrapper = React.memo(({ icon }: { icon: React.ReactNode }) => (
<div className="flex size-4 items-center justify-center overflow-hidden !text-[1rem]">{icon}</div>
));
IconWrapper.displayName = "IconWrapper";
const LabelWrapper = React.memo(({ label }: { label: ReactNode }) => (
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
));
LabelWrapper.displayName = "LabelWrapper";
const BreadcrumbContent = React.memo(({ icon, label }: { icon?: React.ReactNode; label?: ReactNode }) => {
if (!icon && !label) return null;
return (
<>
{icon && <IconWrapper icon={icon} />}
{label && <LabelWrapper label={label} />}
</>
);
});
BreadcrumbContent.displayName = "BreadcrumbContent";
const ItemWrapper = React.memo(({ children, ...props }: React.ComponentProps<typeof Breadcrumbs.ItemWrapper>) => (
<Breadcrumbs.ItemWrapper {...props}>{children}</Breadcrumbs.ItemWrapper>
));
ItemWrapper.displayName = "ItemWrapper";
export const BreadcrumbLink: FC<Props> = observer((props) => {
const { href, label, icon, disableTooltip = false, isLast = false } = props;
const { isMobile } = usePlatformOS();
const itemWrapperProps = useMemo(
() => ({
label: label?.toString(),
disableTooltip: isMobile || disableTooltip,
type: (href && href !== "" ? "link" : "text") as "link" | "text",
isLast,
}),
[href, label, isMobile, disableTooltip, isLast]
);
const content = useMemo(() => <BreadcrumbContent icon={icon} label={label} />, [icon, label]);
if (href) {
return (
<Link href={href}>
<ItemWrapper {...itemWrapperProps}>{content}</ItemWrapper>
</Link>
);
}
return <ItemWrapper {...itemWrapperProps}>{content}</ItemWrapper>;
});
BreadcrumbLink.displayName = "BreadcrumbLink";

View File

@@ -0,0 +1,25 @@
"use client";
import type { FC } from "react";
//
import { cn } from "@plane/utils";
type TCountChip = {
count: string | number;
className?: string;
};
export const CountChip: FC<TCountChip> = (props) => {
const { count, className = "" } = props;
return (
<div
className={cn(
"relative flex justify-center items-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl",
className
)}
>
{count}
</div>
);
};

View File

@@ -0,0 +1,50 @@
"use client";
import React from "react";
import Image from "next/image";
// ui
import { Button } from "@plane/propel/button";
type Props = {
title: string;
description?: React.ReactNode;
image: any;
primaryButton?: {
icon?: any;
text: string;
onClick: () => void;
};
secondaryButton?: React.ReactNode;
disabled?: boolean;
};
export const EmptyState: React.FC<Props> = ({
title,
description,
image,
primaryButton,
secondaryButton,
disabled = false,
}) => (
<div className={`flex h-full w-full items-center justify-center`}>
<div className="flex w-full flex-col items-center text-center">
<Image src={image} className="w-52 sm:w-60" alt={primaryButton?.text || "button image"} />
<h6 className="mb-3 mt-6 text-xl font-semibold sm:mt-8">{title}</h6>
{description && <p className="mb-7 px-5 text-custom-text-300 sm:mb-8">{description}</p>}
<div className="flex items-center gap-4">
{primaryButton && (
<Button
variant="primary"
prependIcon={primaryButton.icon}
onClick={primaryButton.onClick}
disabled={disabled}
>
{primaryButton.text}
</Button>
)}
{secondaryButton}
</div>
</div>
</div>
);

View File

@@ -0,0 +1,76 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { DATE_BEFORE_FILTER_OPTIONS } from "@plane/constants";
import { isInDateFormat } from "@plane/utils";
// components
import { DateFilterModal } from "@/components/core/filters/date-filter-modal";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterCreatedDate: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = DATE_BEFORE_FILTER_OPTIONS.filter((d) =>
d.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const isCustomDateSelected = () => {
const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || [];
return isValidDateSelected.length > 0 ? true : false;
};
const handleCustomDate = () => {
if (isCustomDateSelected()) {
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
handleUpdate(updateAppliedFilters);
} else setIsDateFilterModalOpen(true);
};
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Created date"
/>
)}
<FilterHeader
title={`Created date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={appliedFilters?.includes(option.value) ? true : false}
onClick={() => handleUpdate(option.value)}
title={option.name}
multiple
/>
))}
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

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