This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Info } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { CloseIcon } from "@plane/propel/icons";
// helpers
import type React from "react";
type TAuthBanner = {
message: React.ReactNode;
handleBannerData?: (bannerData: undefined) => void;
};
export function AuthBanner(props: TAuthBanner) {
const { message, handleBannerData } = props;
// translation
const { t } = useTranslation();
if (!message) return <></>;
return (
<div
role="alert"
className="relative flex items-center gap-2 rounded-md border border-accent-strong/50 bg-accent-primary/10 p-2"
>
<div className="grid size-4 flex-shrink-0 place-items-center">
<Info size={16} className="text-accent-primary" />
</div>
<p className="w-full text-13 font-medium text-accent-primary">{message}</p>
<button
type="button"
className="relative ml-auto grid size-6 place-items-center rounded-xs text-accent-primary/80 transition-all hover:bg-accent-primary/20"
onClick={() => handleBannerData?.(undefined)}
aria-label={t("aria_labels.auth_forms.close_alert")}
>
<CloseIcon className="size-4" />
</button>
</div>
);
}
@@ -0,0 +1,91 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";
// services
import { WorkspaceService } from "@/services/workspace.service";
type TAuthHeader = {
workspaceSlug: string | undefined;
invitationId: string | undefined;
invitationEmail: string | undefined;
authMode: EAuthModes;
currentAuthStep: EAuthSteps;
};
const workSpaceService = new WorkspaceService();
export const AuthHeader = observer(function AuthHeader(props: TAuthHeader) {
const { workspaceSlug, invitationId, invitationEmail, authMode } = 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 = (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
? t("auth_header.invitation_sign_up_subheader")
: t("auth_header.invitation_sign_in_subheader"),
};
}
return {
header: t("auth_header.hero_title"),
subHeader: mode === EAuthModes.SIGN_UP ? t("auth_header.sign_up_subheader") : t("auth_header.sign_in_subheader"),
};
};
const { header, subHeader } = getHeaderSubHeader(authMode, invitation || undefined, invitationEmail);
if (isLoading)
return (
<div className="flex h-full w-full items-center justify-center">
<LogoSpinner />
</div>
);
return <AuthHeaderBase subHeader={subHeader} header={header} />;
});
type TAuthHeaderBase = {
header: React.ReactNode;
subHeader: string;
};
export function AuthHeaderBase(props: TAuthHeaderBase) {
return (
<div className="flex flex-col gap-1">
<span className="text-h4-semibold text-primary">{props.header}</span>
<span className="text-h4-semibold text-placeholder">{props.subHeader}</span>
</div>
);
}
@@ -0,0 +1,164 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// plane imports
import { useTranslation } from "@plane/i18n";
import { OAuthOptions } from "@plane/ui";
// helpers
import type { TAuthErrorInfo } from "@/helpers/authentication.helper";
import {
EAuthModes,
EAuthSteps,
EAuthenticationErrorCodes,
EErrorAlertType,
authErrorHandler,
} from "@/helpers/authentication.helper";
// hooks
import { useOAuthConfig } from "@/hooks/oauth";
import { useInstance } from "@/hooks/store/use-instance";
// local imports
import { AuthBanner } from "./auth-banner";
import { AuthHeader, AuthHeaderBase } from "./auth-header";
import { AuthFormRoot } from "./form-root";
type TAuthRoot = {
authMode: EAuthModes;
};
export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
//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");
// 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);
// store hooks
const { config } = useInstance();
// i18n
const { t } = useTranslation();
// derived values
const oAuthActionText = t(authMode === EAuthModes.SIGN_UP ? "auth_actions.sign_up" : "auth_actions.sign_in");
const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText);
const isEmailBasedAuthEnabled = config?.is_email_password_enabled || config?.is_magic_login_enabled;
const noAuthMethodsAvailable = !isOAuthEnabled && !isEmailBasedAuthEnabled;
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);
}
if ([EAuthenticationErrorCodes.PASSWORD_TOO_WEAK].includes(errorhandler.code)) {
setAuthMode(EAuthModes.SIGN_UP);
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 <></>;
if (noAuthMethodsAvailable) {
return (
<AuthContainer>
<AuthHeaderBase
header={t("auth_actions.no_methods_title")}
subHeader={t("auth_actions.no_methods_description")}
/>
</AuthContainer>
);
}
return (
<AuthContainer>
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
<AuthBanner message={errorInfo.message} handleBannerData={(value) => setErrorInfo(value)} />
)}
<AuthHeader
workspaceSlug={workspaceSlug?.toString() || undefined}
invitationId={invitation_id?.toString() || undefined}
invitationEmail={email || undefined}
authMode={authMode}
currentAuthStep={authStep}
/>
{isOAuthEnabled && (
<OAuthOptions
options={oAuthOptions}
compact={authStep === EAuthSteps.PASSWORD}
showDivider={isEmailBasedAuthEnabled}
dividerLabel={t("auth_actions.or")}
/>
)}
{isEmailBasedAuthEnabled && (
<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}
/>
)}
</AuthContainer>
);
});
function AuthContainer({ children }: { children: React.ReactNode }) {
return (
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6">{children}</div>
</div>
);
}
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export function FormContainer({ children }: { children: React.ReactNode }) {
return (
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6">{children}</div>
</div>
);
}
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export function AuthFormHeader({ title, description }: { title: string; description: string }) {
return (
<div className="flex flex-col gap-1">
<span className="text-20 font-semibold text-primary">{title}</span>
<span className="text-20 font-semibold text-placeholder">{description}</span>
</div>
);
}
@@ -0,0 +1,108 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { 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 = observer(function AuthEmailForm(props: TAuthEmailForm) {
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-13 font-medium text-tertiary">
{t("auth.common.email.label")}
</label>
<div
className={cn(
`relative flex items-center rounded-md border bg-surface-1`,
!isFocused && Boolean(emailError?.email) ? `border-danger-strong` : `border-strong`
)}
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={`h-10 w-full border-0 disable-autofill-style placeholder:text-placeholder autofill:bg-danger-primary focus:bg-none active:bg-transparent`}
autoComplete="off"
autoFocus
ref={inputRef}
/>
{email.length > 0 && (
<button
type="button"
onClick={() => {
setEmail("");
inputRef.current?.focus();
}}
className="absolute right-3 grid size-5 place-items-center"
aria-label={t("aria_labels.auth_forms.clear_email")}
tabIndex={-1}
>
<XCircle className="size-5 stroke-placeholder" />
</button>
)}
</div>
{emailError?.email && !isFocused && (
<p className="flex items-center gap-1 px-0.5 text-11 text-danger-primary">
<CircleAlert height={12} width={12} />
{t(emailError.email)}
</p>
)}
</div>
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : t("common.continue")}
</Button>
</form>
);
});
@@ -0,0 +1,67 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 function 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-11 font-medium text-accent-primary outline-none"
>
{t("auth.common.forgot_password")}
</button>
</Popover.Button>
<Popover.Panel className="fixed z-10">
{({ close }) => (
<div
className="z-10 ml-3 flex w-64 items-start gap-3 rounded-sm border border-strong bg-surface-1 px-2 py-1 text-left break-words"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<span className="flex-shrink-0">🤥</span>
<p className="text-11">{t("auth.forgot_password.errors.smtp_not_enabled")}</p>
<button
type="button"
className="grid size-3 flex-shrink-0 place-items-center"
onClick={() => close()}
aria-label={t("aria_labels.auth_forms.close_popover")}
>
<CloseIcon className="size-3 text-secondary" />
</button>
</div>
)}
</Popover.Panel>
</Popover>
);
}
@@ -0,0 +1,137 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 { 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";
// 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(function ForgotPasswordForm() {
// 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(() => {
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) => {
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={t("auth.forgot_password.title")} description={t("auth.forgot_password.description")} />
<form onSubmit={handleSubmit(handleForgotPassword)} className="space-y-4">
<div className="space-y-1">
<label className="text-13 font-medium text-tertiary" 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-strong !bg-surface-1 pr-12 placeholder:text-placeholder"
autoComplete="off"
disabled={resendTimerCode > 0}
/>
)}
/>
{resendTimerCode > 0 && (
<p className="flex w-full items-start gap-1 px-1 text-11 font-medium text-success-primary">
<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="xl"
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", "lg"))}>
{t("auth.common.back_to_sign_in")}
</Link>
</form>
</FormContainer>
);
});
@@ -0,0 +1,138 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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(function AuthFormRoot(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 <></>;
});
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./auth-root";
@@ -0,0 +1,314 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { 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_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";
// 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 = observer(function AuthPasswordForm(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-11 font-medium text-accent-primary"
>
{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 gap-2 rounded-md border border-danger-strong/50 bg-danger-subtle p-2">
<div className="relative flex h-4 w-4 shrink-0 items-center justify-center">
<Info size={16} className="text-danger-primary" />
</div>
<div className="w-full text-13 font-medium text-danger-primary">
{t("auth.sign_up.errors.password.strength")}
</div>
<button
type="button"
className="relative ml-auto flex h-6 w-6 cursor-pointer items-center justify-center rounded-xs text-accent-primary/80 transition-all hover:bg-danger-subtle-hover"
onClick={() => setBannerMessage(false)}
>
<CloseIcon className="h-4 w-4 shrink-0 text-danger-primary" />
</button>
</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);
if (formRef.current) formRef.current.submit(); // Manually submit the form if the condition is met
} else {
setBannerMessage(true);
}
}}
onError={() => {
setIsSubmitting(false);
}}
>
<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-13 font-medium text-tertiary">
{t("auth.common.email.label")}
</label>
<div className={`relative flex items-center rounded-md border border-strong bg-surface-1`}>
<Input
id="email"
name="email"
type="email"
value={passwordFormData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
placeholder={t("auth.common.email.placeholder")}
className={`h-10 w-full border-0 disable-autofill-style placeholder:text-placeholder`}
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-placeholder" />
</button>
)}
</div>
</div>
<div className="space-y-1">
<label htmlFor="password" className="text-13 font-medium text-tertiary">
{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-surface-1">
<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="h-10 w-full border border-strong !bg-surface-1 pr-12 disable-autofill-style placeholder:text-placeholder"
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="off"
autoFocus
/>
<button
type="button"
onClick={() => handleShowPassword("password")}
className="absolute right-3 grid size-5 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-placeholder" />
) : (
<Eye className="size-5 stroke-placeholder" />
)}
</button>
</div>
{passwordSupport}
</div>
{mode === EAuthModes.SIGN_UP && (
<div className="space-y-1">
<label htmlFor="confirm-password" className="text-13 font-medium text-tertiary">
{t("auth.common.password.confirm_password.label")}
</label>
<div className="relative flex items-center rounded-md bg-surface-1">
<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="h-10 w-full border border-strong !bg-surface-1 pr-12 disable-autofill-style placeholder:text-placeholder"
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
autoComplete="off"
/>
<button
type="button"
className="absolute right-3 grid size-5 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-placeholder" />
) : (
<Eye className="size-5 stroke-placeholder" />
)}
</button>
</div>
{!!passwordFormData.confirm_password &&
passwordFormData.password !== passwordFormData.confirm_password &&
renderPasswordMatchError && (
<span className="text-13 text-danger-primary">{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="xl" 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="secondary"
className="w-full"
size="xl"
>
{t("auth.common.sign_in_with_unique_code")}
</Button>
)}
</>
) : (
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : t("auth_actions.sign_up")}
</Button>
)}
</div>
</form>
</>
);
});
@@ -0,0 +1,204 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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(function ResetPasswordForm() {
// 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={t("auth.reset_password.title")} description={t("auth.reset_password.description")} />
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
<AuthBanner message={errorInfo.message} 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-13 font-medium text-tertiary" htmlFor="email">
{t("auth.common.email.label")}
</label>
<div className="relative flex items-center rounded-md bg-surface-1">
<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 cursor-not-allowed border border-strong !bg-surface-1 pr-12 text-placeholder"
autoComplete="off"
disabled
/>
</div>
</div>
<div className="space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="password">
{t("auth.common.password.label")}
</label>
<div className="relative flex items-center rounded-md bg-surface-1">
<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-strong !bg-surface-1 pr-12 placeholder:text-placeholder"
minLength={8}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="new-password"
autoFocus
/>
{showPassword.password ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
)}
</div>
<PasswordStrengthIndicator password={resetFormData.password} isFocused={isPasswordInputFocused} />
</div>
<div className="space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="confirm_password">
{t("auth.common.password.confirm_password.label")}
</label>
<div className="relative flex items-center rounded-md bg-surface-1">
<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-strong !bg-surface-1 pr-12 placeholder:text-placeholder"
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
autoComplete="new-password"
/>
{showPassword.retypePassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("retypePassword")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("retypePassword")}
/>
)}
</div>
{!!resetFormData.confirm_password &&
resetFormData.password !== resetFormData.confirm_password &&
renderPasswordMatchError && (
<span className="text-13 text-danger-primary">{t("auth.common.password.errors.match")}</span>
)}
</div>
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={isButtonDisabled}>
{t("auth.common.password.submit")}
</Button>
</form>
</FormContainer>
);
});
@@ -0,0 +1,207 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 { 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";
// 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(function SetPasswordForm() {
// 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(() => {
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 });
router.push("/");
} catch (error: unknown) {
let message = undefined;
if (error instanceof Error) {
const err = error as Error & { error?: string };
message = err.error;
}
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={t("auth.set_password.title")} description={t("auth.set_password.description")} />
<form className="space-y-4" onSubmit={(e) => handleSubmit(e)}>
<div className="space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="email">
{t("auth.common.email.label")}
</label>
<div className="relative flex items-center rounded-md bg-surface-1">
<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 cursor-not-allowed border border-strong !bg-surface-1 pr-12 text-placeholder"
autoComplete="off"
disabled
/>
</div>
</div>
<div className="space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="password">
{t("auth.common.password.label")}
</label>
<div className="relative flex items-center rounded-md bg-surface-1">
<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-strong !bg-surface-1 pr-12 placeholder:text-placeholder"
minLength={8}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="new-password"
autoFocus
/>
{showPassword.password ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
)}
</div>
<PasswordStrengthIndicator password={passwordFormData.password} isFocused={isPasswordInputFocused} />
</div>
<div className="space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="confirm_password">
{t("auth.common.password.confirm_password.label")}
</label>
<div className="relative flex items-center rounded-md bg-surface-1">
<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-strong !bg-surface-1 pr-12 placeholder:text-placeholder"
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
autoComplete="new-password"
/>
{showPassword.retypePassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("retypePassword")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("retypePassword")}
/>
)}
</div>
{!!passwordFormData.confirm_password &&
passwordFormData.password !== passwordFormData.confirm_password &&
renderPasswordMatchError && (
<span className="text-13 text-danger-primary">{t("auth.common.password.errors.match")}</span>
)}
</div>
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={isButtonDisabled}>
{t("common.continue")}
</Button>
</form>
</FormContainer>
);
});
@@ -0,0 +1,177 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { CircleCheck, XCircle } from "lucide-react";
import { API_BASE_URL } 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 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 function AuthUniqueCodeForm(props: TAuthUniqueCodeForm) {
const { mode, email, handleEmailClear, generateEmailUniqueCode, 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);
} catch {
setResendCodeTimer(0);
console.error("Error while requesting new code");
setIsRequestingNewCode(false);
}
};
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);
}}
onError={() => {
setIsSubmitting(false);
}}
>
<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-13 font-medium text-tertiary">
{t("auth.common.email.label")}
</label>
<div className={`relative flex items-center rounded-md border border-strong bg-surface-1`}>
<Input
id="email"
name="email"
type="email"
value={uniqueCodeFormData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
placeholder={t("auth.common.email.placeholder")}
className="h-10 w-full border-0 disable-autofill-style placeholder:text-placeholder"
autoComplete="off"
disabled
/>
{uniqueCodeFormData.email.length > 0 && (
<button
type="button"
className="absolute right-3 grid size-5 place-items-center"
aria-label={t("aria_labels.auth_forms.clear_email")}
onClick={handleEmailClear}
>
<XCircle className="size-5 stroke-placeholder" />
</button>
)}
</div>
</div>
<div className="space-y-1">
<label htmlFor="unique-code" className="text-13 font-medium text-tertiary">
{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="h-10 w-full border border-strong !bg-surface-1 pr-12 disable-autofill-style placeholder:text-placeholder"
autoComplete="off"
autoFocus
/>
<div className="flex w-full items-center justify-between px-1 pt-1 text-11">
<p className="flex items-center gap-1 font-medium text-success-primary">
<CircleCheck height={12} width={12} />
{t("auth.common.unique_code.paste_code")}
</p>
<button
type="button"
onClick={() => generateNewCode(uniqueCodeFormData.email)}
className={
isRequestNewCodeDisabled
? "text-placeholder"
: "font-medium text-accent-secondary hover:text-accent-secondary"
}
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="xl" disabled={isButtonDisabled}>
{isRequestingNewCode ? (
t("auth.common.unique_code.sending_code")
) : isSubmitting ? (
<Spinner height="20px" width="20px" />
) : (
t("common.continue")
)}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,93 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { useTranslation } from "@plane/i18n";
// ui
import { Button } from "@plane/propel/button";
import { TrashIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
type Props = {
isOpen: boolean;
onClose: () => void;
};
export function DeactivateAccountModal(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(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Account deactivated successfully.",
});
signOut();
router.push("/");
handleClose();
return;
})
.catch((err: any) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.error,
});
})
.finally(() => setIsDeactivating(false));
};
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<div className="px-4 pt-5 pb-4 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-danger-subtle p-2 sm:mt-3 sm:p-2 md:mt-0 md:p-4 lg:mt-0 lg:p-4">
<TrashIcon
className="h-4 w-4 text-danger-primary sm:h-4 sm:w-4 md:h-6 md:w-6 lg:h-6 lg:w-6"
aria-hidden="true"
/>
</div>
<div>
<h3 className="my-4 text-20 leading-6 font-medium text-primary">{t("deactivate_your_account")}</h3>
<p className="mt-6 list-disc pr-4 text-14 font-regular text-secondary">
{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="secondary" size="lg" onClick={handleClose}>
{t("cancel")}
</Button>
<Button variant="error-fill" size="lg" onClick={handleDeleteAccount}>
{isDeactivating ? t("deactivating") : t("confirm")}
</Button>
</div>
</ModalCore>
);
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import Link from "next/link";
import { EAuthModes } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
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;
// Reusable link component to reduce duplication
function LegalLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Link href={href} className="text-secondary" target="_blank" rel="noopener noreferrer">
<span className="text-13 font-medium underline hover:cursor-pointer">{children}</span>
</Link>
);
}
export function TermsAndConditions({ authType = EAuthModes.SIGN_IN }: TermsAndConditionsProps) {
const { t } = useTranslation();
const messageKey =
authType === EAuthModes.SIGN_UP ? "auth_terms.sign_up_prefix" : "auth_terms.sign_in_prefix";
return (
<div className="flex items-center justify-center">
<p className="text-center text-13 whitespace-pre-line text-tertiary">
{t(messageKey)} <LegalLink href={LEGAL_LINKS.termsOfService}>{t("auth_terms.terms_of_service")}</LegalLink>{" "}
{t("auth_terms.and")} <LegalLink href={LEGAL_LINKS.privacyPolicy}>{t("auth_terms.privacy_policy")}</LegalLink>.
</p>
</div>
);
}
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// 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 { ProjectSelect } from "./select/project";
const AnalyticsFilterActions = observer(function AnalyticsFilterActions() {
const { selectedProjects, updateSelectedProjects } = useAnalytics();
const { joinedProjectIds } = useProject();
return (
<div className="flex items-center justify-end gap-2">
<ProjectSelect
value={selectedProjects}
onChange={(val) => {
updateSelectedProjects(val ?? []);
}}
projectIds={joinedProjectIds}
/>
{/* <DurationDropdown
buttonVariant="border-with-text"
value={selectedDuration}
onChange={(val) => {
updateSelectedDuration(val);
}}
dropdownArrow
/> */}
</div>
);
});
export default AnalyticsFilterActions;
@@ -0,0 +1,36 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { cn } from "@plane/utils";
type Props = {
title?: string;
children: React.ReactNode;
className?: string;
subtitle?: string | null;
actions?: React.ReactNode;
headerClassName?: string;
};
function AnalyticsSectionWrapper(props: Props) {
const { title, children, className, 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-16 font-medium"}>{title}</h1>
{/* {subtitle && <p className="text-16 text-tertiary"> • {subtitle}</p>} */}
</div>
)}
{actions}
</div>
{children}
</div>
);
}
export default AnalyticsSectionWrapper;
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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;
};
function AnalyticsWrapper(props: Props) {
const { i18nTitle, children, className } = props;
const { t } = useTranslation();
return (
<div className={cn("px-6 py-4", className)}>
<h1 className={"mb-4 text-20 font-bold md:mb-6"}>{t(i18nTitle)}</h1>
{children}
</div>
);
}
export default AnalyticsWrapper;
@@ -0,0 +1,51 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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;
};
function 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-subtle 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">
<img src={assetPath} alt={title} className="z-10 h-2/3 w-2/3 object-contain" />
<div className="absolute inset-0">
<img src={backgroundReolvedPath} alt={title} className="h-full w-full object-contain" />
</div>
</div>
)}
<div className="flex flex-shrink flex-col items-center gap-1.5 text-center">
<h3 className={cn("text-18 font-semibold")}>{title}</h3>
{description && <p className="max-w-[350px] text-13 text-tertiary">{description}</p>}
</div>
</div>
</div>
);
}
export default AnalyticsEmptyState;
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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);
};
@@ -0,0 +1,36 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// 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;
};
function 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-13 text-tertiary">{label}</div>
{!isLoading ? (
<div className="flex flex-col gap-1">
<div className="text-20 font-bold text-primary">{count}</div>
</div>
) : (
<Loader.Item height="50px" width="100%" />
)}
</div>
);
}
export default InsightCard;
@@ -0,0 +1,175 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 { useTranslation } from "@plane/i18n";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { SearchIcon, 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 text-13 whitespace-nowrap text-placeholder">
{searchPlaceholder}
</div>
)}
{!isSearchOpen && (
<button
type="button"
className="-mr-5 grid place-items-center rounded-sm p-2 text-placeholder hover:bg-layer-1"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<SearchIcon 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-surface-1 text-placeholder opacity-0 transition-[width] ease-linear",
{
"w-64 border-subtle px-2.5 py-1.5 opacity-100": isSearchOpen,
}
)}
>
<SearchIcon className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-13 text-primary placeholder:text-placeholder 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-subtle 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>
);
}
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 function TableLoader({ columns, rows }: TableSkeletonProps) {
return (
<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>
);
}
@@ -0,0 +1,51 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 function 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="secondary"
prependIcon={<Download className="h-3.5 w-3.5" />}
onClick={() => onExport?.(table.getFilteredRowModel().rows)}
>
<div>{t("exporter.csv.short_description")}</div>
</Button>
)}
/>
</div>
);
}
@@ -0,0 +1,33 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Loader } from "@plane/ui";
export function ProjectInsightsLoader() {
return (
<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 function ChartLoader() {
return (
<Loader className="h-[350px] w-full">
<Loader.Item height="100%" width="100%" />
</Loader>
);
}
@@ -0,0 +1,68 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane package imports
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ProjectIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
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;
};
function CompletionPercentage({ percentage }: { percentage: number }) {
const percentageColor =
percentage > 50 ? "bg-success-subtle text-success-primary" : "bg-danger-subtle text-danger-primary";
return (
<div className={cn("flex items-center gap-2 rounded-sm p-1 text-11", percentageColor)}>
<span>{percentage}%</span>
</div>
);
}
function 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 w-full items-center justify-between gap-2">
<div className="flex flex-1 items-center gap-2 overflow-hidden">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-layer-1">
<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>
<Tooltip tooltipContent={projectDetails?.name} position="top-start">
<p className="truncate text-13 font-medium">{projectDetails?.name}</p>
</Tooltip>
</div>
<CompletionPercentage
percentage={completed_issues && total_issues ? Math.round((completed_issues / total_issues) * 100) : 0}
/>
</div>
);
}
export default ActiveProjectItem;
@@ -0,0 +1,51 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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(function ActiveProjects() {
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 h-[350px] flex-col gap-4 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;
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";
@@ -0,0 +1,123 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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(function RadarChart() {
return import("@plane/propel/charts/radar-chart").then((mod) => ({
default: mod.RadarChart,
}));
});
const analyticsService = new AnalyticsService();
const ProjectInsights = observer(function ProjectInsights() {
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-subtle 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 text-accent-primary lg:w-3/5"
data={projectInsightsData}
dataKey="key"
radars={[
{
key: "count",
name: "Count",
fill: "var(--text-color-accent-primary)",
stroke: "var(--text-color-accent-primary)",
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-13 text-tertiary">{t("workspace_analytics.summary_of_projects")}</div>
<div className="mb-3 border-b border-subtle py-2">{t("workspace_analytics.all_projects")}</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between text-13 text-tertiary">
<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-13 text-primary">
<div>{item.name}</div>
<div className="flex items-center gap-1">
{/* <TrendPiece key={item.key} size='xs' /> */}
<div className="text-secondary">{item.count}</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
</AnalyticsSectionWrapper>
);
});
export default ProjectInsights;
@@ -0,0 +1,27 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import AnalyticsWrapper from "../analytics-wrapper";
import TotalInsights from "../total-insights";
import ActiveProjects from "./active-projects";
import ProjectInsights from "./project-insights";
function Overview() {
return (
<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 };
@@ -0,0 +1,109 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo } from "react";
import { observer } from "mobx-react";
import type { Control, UseFormSetValue } from "react-hook-form";
import { Controller } from "react-hook-form";
import { SlidersHorizontal } from "lucide-react";
// plane package imports
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "@plane/constants";
import { CalendarLayoutIcon } from "@plane/propel/icons";
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 = observer(function AnalyticsSelectParams(props: 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">
<CalendarLayoutIcon className="h-3 w-3" />
<span className={cn("text-secondary", value && "text-primary")}>
{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-secondary", value && "text-primary")}>
{groupByOptions.find((v) => v.value === value)?.label || "Add Property"}
</span>
</div>
}
options={groupByOptions}
placeholder="Group By"
allowNoValue
/>
)}
/>
</div>
</div>
);
});
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// 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;
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane package imports
import { getButtonStyling } from "@plane/propel/button";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ChevronDownIcon, ProjectIcon } from "@plane/propel/icons";
import { CustomSearchSelect } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useProject } from "@/hooks/store/use-project";
type Props = {
value: string[] | undefined;
onChange: (val: string[] | null) => void;
projectIds: string[] | undefined;
};
export const ProjectSelect = observer(function ProjectSelect(props: 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}
className="border-none p-0"
customButton={
<div className={cn(getButtonStyling("secondary", "lg"), "gap-2")}>
<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"}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
customButtonClassName="border-none p-0 bg-transparent hover:bg-transparent w-auto h-auto"
multiple
/>
);
});
@@ -0,0 +1,36 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// 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 function SelectXAxis(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>
);
}
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function SelectYAxis({ value, onChange, hiddenOptions, options }: Props) {
// 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>
);
});
@@ -0,0 +1,103 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// 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 = observer(function TotalInsights({
analyticsType,
peekView,
}: {
analyticsType: TAnalyticsTabsBase;
peekView?: boolean;
}) {
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;
@@ -0,0 +1,86 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// 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-11",
icon: "w-3 h-3",
},
sm: {
text: "text-13",
icon: "w-4 h-4",
},
md: {
text: "text-14",
icon: "w-5 h-5",
},
lg: {
text: "text-16",
icon: "w-6 h-6",
},
} as const;
const variants: Record<NonNullable<Props["variant"]>, Record<"ontrack" | "offtrack" | "atrisk", string>> = {
simple: {
ontrack: "text-success-primary",
offtrack: "text-yellow-500",
atrisk: "text-danger-primary",
},
outlined: {
ontrack: "text-success-primary border border-success-strong",
offtrack: "text-yellow-500 border border-yellow-500",
atrisk: "text-danger-primary border border-danger-strong",
},
tinted: {
ontrack: "text-success-primary bg-success-subtle",
offtrack: "text-yellow-500 bg-yellow-500/10",
atrisk: "text-danger-primary bg-danger-subtle",
},
} as const;
function 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 rounded-md p-1",
variants[variant][isOnTrack ? "ontrack" : isOffTrack ? "offtrack" : "atrisk"],
config.text,
className
)}
>
{trendIconVisible &&
(isOnTrack ? (
<TrendingUp className={config.icon} />
) : isOffTrack ? (
<TrendingDown className={config.icon} />
) : (
<TrendingDown className={config.icon} />
))}
{Math.round(Math.abs(percentage))}%
</div>
);
}
export default TrendPiece;
@@ -0,0 +1,139 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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(function CreatedVsResolved() {
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-subtle px-5 py-10 md:py-20 md:px-20"
title={t("workspace_empty_state.analytics_work_items.title")}
/>
)}
</AnalyticsSectionWrapper>
);
});
export default CreatedVsResolved;
@@ -0,0 +1,62 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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(function CustomizedInsights({
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;
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";
@@ -0,0 +1,87 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function WorkItemsModalMainContent(props: Props) {
const { projectDetails, cycleDetails, moduleDetails, fullScreen, isEpic } = props;
const { updateSelectedProjects, updateSelectedCycle, updateSelectedModule, updateIsPeekView } = useAnalytics();
const [isModalConfigured, setIsModalConfigured] = useState(false);
useEffect(() => {
updateIsPeekView(true);
// Handle project selection
if (projectDetails?.id) {
updateSelectedProjects([projectDetails.id]);
}
// Handle cycle selection
if (cycleDetails?.id) {
updateSelectedCycle(cycleDetails.id);
}
// Handle module selection
if (moduleDetails?.id) {
updateSelectedModule(moduleDetails.id);
}
setIsModalConfigured(true);
// Cleanup fields
return () => {
updateSelectedProjects([]);
updateSelectedCycle("");
updateSelectedModule("");
updateIsPeekView(false);
};
}, [
projectDetails?.id,
cycleDetails?.id,
moduleDetails?.id,
updateSelectedProjects,
updateSelectedCycle,
updateSelectedModule,
updateIsPeekView,
]);
if (!isModalConfigured)
return (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
);
return (
<Tab.Group as={React.Fragment}>
<div className="flex flex-col gap-14 overflow-y-auto p-6">
<TotalInsights analyticsType="work-items" peekView={!fullScreen} />
<CreatedVsResolved />
<CustomizedInsights peekView={!fullScreen} isEpic={isEpic} />
<WorkItemsInsightTable />
</div>
</Tab.Group>
);
});
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function WorkItemsModalHeader(props: Props) {
const { fullScreen, handleClose, setFullScreen, title, cycle, module } = props;
return (
<div className="flex items-center justify-between gap-4 bg-surface-1 px-5 py-4 text-13">
<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-secondary hover:text-primary 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-secondary hover:text-primary"
onClick={handleClose}
>
<CloseIcon height={14} width={14} strokeWidth={2} />
</button>
</div>
</div>
);
});
@@ -0,0 +1,71 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function WorkItemsModal(props: 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-subtle bg-surface-1 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>
);
});
@@ -0,0 +1,251 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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(function PriorityChart(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="secondary"
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-subtle px-5 py-10 md:py-20 md:px-20"
title={t("workspace_empty_state.analytics_work_items.title")}
/>
)}
</div>
);
});
export default PriorityChart;
@@ -0,0 +1,27 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";
function WorkItems() {
return (
<AnalyticsWrapper i18nTitle="sidebar.work_items">
<div className="flex flex-col gap-14">
<TotalInsights analyticsType="work-items" />
<CreatedVsResolved />
<CustomizedInsights />
<WorkItemsInsightTable />
</div>
</AnalyticsWrapper>
);
}
export { WorkItems };
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane package imports
import type { ChartYAxisMetric, IState } from "@plane/types";
import { ChartXAxisProperty } from "@plane/types";
interface ParamsProps {
x_axis: ChartXAxisProperty;
y_axis: ChartYAxisMetric;
group_by?: ChartXAxisProperty;
}
export const generateBarColor = (
value: string | null | undefined,
params: ParamsProps,
baseColors: string[],
workspaceStates?: IState[]
): string => {
if (!value) return baseColors[0];
let color = baseColors[0];
// Priority
if (params.x_axis === ChartXAxisProperty.PRIORITY) {
color =
value === "urgent"
? "#ef4444"
: value === "high"
? "#f97316"
: value === "medium"
? "#eab308"
: value === "low"
? "#22c55e"
: "#ced4da";
}
// State
if (params.x_axis === ChartXAxisProperty.STATES) {
if (workspaceStates && workspaceStates.length > 0) {
const state = workspaceStates.find((s) => s.id === value);
if (state) {
color = state.color;
} else {
const index = Math.abs(value.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)) % baseColors.length;
color = baseColors[index];
}
}
}
return color;
};
@@ -0,0 +1,210 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
export: {
key: string;
value: (row: Row<TData>) => string | number;
label?: string;
};
}
}
const WorkItemsInsightTable = observer(function WorkItemsInsightTable() {
// 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 overflow-hidden rounded-full bg-layer-1 capitalize">
{row.original.display_name ? (
row.original.display_name?.[0]
) : (
<UserRound className="text-secondary" size={12} />
)}
</div>
)}
<span className="break-words text-secondary">{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;
@@ -0,0 +1,80 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { mutate } from "swr";
// types
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";
type Props = {
isOpen: boolean;
onClose: () => void;
tokenId: string;
};
const apiTokenService = new APITokenService();
export function DeleteApiTokenModal(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
);
handleClose();
setDeleteLoading(false);
})
.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"),
});
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")} </>}
/>
);
}
@@ -0,0 +1,36 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
// ui
import { Button } from "@plane/propel/button";
// assets
import emptyApiTokens from "@/app/assets/empty-state/api-token.svg?url";
type Props = {
onClick: () => void;
};
export function ApiTokenEmptyState(props: Props) {
const { onClick } = props;
return (
<div
className={`mx-auto flex w-full items-center justify-center rounded-xs border border-subtle bg-surface-2 px-16 py-10 lg:w-3/4`}
>
<div className="flex w-full flex-col items-center text-center">
<img src={emptyApiTokens} className="w-52 object-contain sm:w-60" alt="empty" />
<h6 className="mt-6 mb-3 text-18 font-semibold sm:mt-8">No API tokens</h6>
<p className="mb-7 text-tertiary 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>
);
}
@@ -0,0 +1,98 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { mutate } from "swr";
// plane imports
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";
// local imports
import { CreateApiTokenForm } from "./form";
import { GeneratedTokenDetails } from "./generated-token-details";
type Props = {
isOpen: boolean;
onClose: () => void;
};
// services
const apiTokenService = new APITokenService();
export function CreateApiTokenModal(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
);
})
.catch((err) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err.message || err.detail,
});
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>
);
}
@@ -0,0 +1,260 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 function CreateApiTokenForm(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-18 font-medium text-secondary">
{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-14"
/>
)}
/>
{errors.label && <span className="text-11 text-danger-primary">{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="min-h-24 w-full resize-none text-14"
/>
)}
/>
<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(
"flex h-7 items-center gap-2 rounded-sm border-[0.5px] border-strong px-2 py-0.5",
{
"text-placeholder": 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-11 text-placeholder">
{expiredAt === "custom"
? customDate
? `Expires ${renderFormattedDate(customDateFormatted ?? "")} at ${renderFormattedTime(customDateFormatted ?? "")}`
: null
: expiredAt
? `Expires ${renderFormattedDate(expiryDate ?? "")} at ${renderFormattedTime(expiryDate ?? "")}`
: null}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t-[0.5px] border-subtle px-5 py-4">
<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-11">{t("workspace_settings.settings.api_tokens.never_expires")}</span>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={handleClose}>
{t("cancel")}
</Button>
<Button variant="primary" type="submit" loading={isSubmitting}>
{isSubmitting
? t("workspace_settings.settings.api_tokens.generating")
: t("workspace_settings.settings.api_tokens.generate_token")}
</Button>
</div>
</div>
</form>
);
}
@@ -0,0 +1,67 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { CopyIcon } from "@plane/propel/icons";
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 function GeneratedTokenDetails(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-16 leading-6 font-medium text-primary">{t("workspace_settings.key_created")}</h3>
<p className="text-13 text-placeholder">{t("workspace_settings.copy_key")}</p>
</div>
<button
type="button"
onClick={() => copyApiToken(tokenDetails.token ?? "")}
className="mt-4 flex w-full items-center justify-between truncate rounded-md border-[0.5px] border-subtle px-3 py-2 text-13 font-medium outline-none"
>
<span className="truncate pr-2">{tokenDetails.token}</span>
<Tooltip tooltipContent="Copy secret key" isMobile={isMobile}>
<CopyIcon className="h-4 w-4 flex-shrink-0 text-placeholder" />
</Tooltip>
</button>
<div className="mt-6 flex items-center justify-between">
<p className="text-11 text-placeholder">
{tokenDetails.expired_at
? `Expires ${renderFormattedDate(tokenDetails.expired_at)} at ${renderFormattedTime(tokenDetails.expired_at)}`
: "Never expires"}
</p>
<Button variant="secondary" onClick={handleClose}>
{t("close")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,68 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 function ApiTokenListItem(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-subtle 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-danger-primary" />
</button>
</Tooltip>
<div className="flex w-4/5 items-center">
<h5 className="truncate text-13 font-medium">{token.label}</h5>
<span
className={`${
token.is_active ? "bg-success-subtle text-success-primary" : "bg-layer-1 text-placeholder"
} ml-2 flex h-4 max-h-fit items-center rounded-xs px-2 text-11 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%] text-13 break-words">{token.description}</p>
)}
<p className="mb-1 text-11 leading-6 text-placeholder">
{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>
</>
);
}
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./theme-switcher";
@@ -0,0 +1,76 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
// plane imports
import type { I_THEME_OPTION } from "@plane/constants";
import { THEME_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { setPromiseToast } from "@plane/propel/toast";
// components
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
import { ThemeSwitch } from "@/components/core/theme/theme-switch";
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
import { useUserProfile } from "@/hooks/store/user";
export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
option: {
id: string;
title: string;
description: string;
};
}) {
// store hooks
const { data: userProfile, updateUserTheme } = useUserProfile();
// theme
const { setTheme } = useTheme();
// translation
const { t } = useTranslation();
// derived values
const currentTheme = useMemo(() => {
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
return userThemeOption || null;
}, [userProfile?.theme?.theme]);
const handleThemeChange = useCallback(
(themeOption: I_THEME_OPTION) => {
try {
setTheme(themeOption.value);
const updatePromise = updateUserTheme({ theme: themeOption.value });
setPromiseToast(updatePromise, {
loading: "Updating theme...",
success: {
title: "Success!",
message: () => "Theme updated successfully!",
},
error: {
title: "Error!",
message: () => "Failed to update the theme",
},
});
} catch (error) {
console.error("Error updating theme:", error);
}
},
[updateUserTheme]
);
if (!userProfile) return null;
return (
<>
<SettingsControlItem
title={t(props.option.title)}
description={t(props.option.description)}
control={<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />}
/>
{userProfile.theme?.theme === "custom" && <CustomThemeSelector />}
</>
);
});
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function ArchiveTabsList() {
// 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 border-b-2 px-4 py-4 text-13 font-medium whitespace-nowrap outline-none ${
pathname.includes(tab.key)
? "border-accent-strong text-accent-primary"
: "border-transparent text-tertiary hover:border-subtle hover:text-placeholder"
}`}
>
{tab.label}
</span>
</Link>
)
)}
</>
);
});
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./archive-tabs-list";
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { AuthRoot } from "@/components/account/auth-forms/auth-root";
import type { EAuthModes } from "@/helpers/authentication.helper";
import { AuthHeader } from "./header";
type AuthBaseProps = {
authType: EAuthModes;
};
export function AuthBase({ authType }: AuthBaseProps) {
return (
<div className="relative z-10 flex h-screen w-screen flex-col items-center overflow-hidden overflow-y-auto px-8 pt-6 pb-10">
<AuthHeader type={authType} />
<AuthRoot authMode={authType} />
</div>
);
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { useTranslation } from "@plane/i18n";
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-on-color" />,
},
{
id: "dolby",
icon: <DolbyLogo className="h-7 w-16 dark:text-on-color" />,
},
{
id: "accenture",
icon: <AccentureLogo className="h-7 w-24 dark:text-on-color" />,
},
];
export function AuthFooter() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center gap-6">
<span className="text-13 whitespace-nowrap text-tertiary">{t("auth_footer.social_proof")}</span>
<div className="flex w-full flex-wrap items-center justify-center gap-x-10 gap-y-4">
{BRAND_LOGOS.map((brand) => (
<div className="flex h-7 flex-1 items-center justify-center" key={brand.id}>
{brand.icon}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,82 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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: "auth_actions.sign_up",
text: "auth.common.new_to_plane",
linkText: "auth_actions.sign_up",
linkHref: "/sign-up",
},
[EAuthModes.SIGN_UP]: {
pageTitle: "auth_actions.sign_in",
text: "auth.common.already_have_an_account",
linkText: "auth_actions.sign_in",
linkHref: "/sign-in",
},
};
type AuthHeaderProps = {
type: EAuthModes;
};
export const AuthHeader = observer(function AuthHeader({ type }: AuthHeaderProps) {
const { t } = useTranslation();
// store
const { config } = useInstance();
// derived values
const enableSignUpConfig = config?.enable_signup ?? false;
return (
<AuthHeaderBase
pageTitle={t(authContentMap[type].pageTitle)}
additionalAction={
enableSignUpConfig && (
<div className="flex flex-col items-end text-center text-13 font-medium text-tertiary sm:flex-row sm:items-center sm:gap-2">
<span className="text-body-sm-regular text-tertiary">{t(authContentMap[type].text)}</span>
<Link
data-ph-element={AUTH_TRACKER_ELEMENTS.NAVIGATE_TO_SIGN_UP}
href={authContentMap[type].linkHref}
className="text-body-sm-semibold text-accent-primary hover:underline"
>
{t(authContentMap[type].linkText)}
</Link>
</div>
)
}
/>
);
});
type TAuthHeaderBase = {
pageTitle: string;
additionalAction?: React.ReactNode;
};
export function AuthHeaderBase(props: TAuthHeaderBase) {
const { pageTitle, additionalAction } = props;
return (
<>
<PageHead title={pageTitle + " - NODE.DC"} />
<div className="sticky top-0 flex w-full flex-shrink-0 items-center justify-between gap-6">
<Link href="/">
<PlaneLockup height={20} width={95} className="text-primary" />
</Link>
{additionalAction}
</div>
</>
);
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// assets
import { cn } from "@plane/utils";
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 = observer(function NotAuthorizedView(props: Props) {
const { actionButton, section = "general", isProjectView = false, className } = props;
const { t } = useTranslation();
// assets
const settingAsset = isProjectView ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg;
const asset = section === "settings" ? settingAsset : Unauthorized;
return (
<DefaultLayout className={cn("bg-surface-1", className)}>
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
<div className="h-44 w-72">
<img src={asset} className="h-[176px] w-[288px] object-contain" alt="ProjectSettingImg" />
</div>
<h1 className="text-18 font-medium text-primary">{t("you_do_not_have_the_permission_to_access_this_page")}</h1>
{actionButton}
</div>
</DefaultLayout>
);
});
@@ -0,0 +1,76 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
type TProps = {
isWorkspaceAdmin: boolean;
handleJoinProject: () => void;
isJoinButtonDisabled: boolean;
errorStatusCode: number | undefined;
};
export const ProjectAccessRestriction = observer(function ProjectAccessRestriction(props: TProps) {
const { isWorkspaceAdmin, handleJoinProject, isJoinButtonDisabled, errorStatusCode } = props;
// plane hooks
const { t } = useTranslation();
// Show join project screen if:
// - User lacks project membership (409 Conflict)
// - User lacks permission to access the private project (403 Forbidden) but is a workspace admin (can join any project)
if (errorStatusCode === 409 || (errorStatusCode === 403 && isWorkspaceAdmin))
return (
<div className="grid h-full w-full place-items-center bg-surface-1">
<EmptyStateDetailed
title={t("project_empty_state.no_access.title")}
description={t("project_empty_state.no_access.join_description")}
assetKey="no-access"
assetClassName="size-40"
actions={[
{
label: isJoinButtonDisabled
? t("project_empty_state.no_access.cta_loading")
: t("project_empty_state.no_access.cta_primary"),
onClick: handleJoinProject,
disabled: isJoinButtonDisabled,
},
]}
/>
</div>
);
// Show no access screen if:
// - User lacks permission to access the private project (403 Forbidden)
if (errorStatusCode === 403) {
return (
<div className="grid h-full w-full place-items-center bg-surface-1">
<EmptyStateDetailed
title={t("project_empty_state.no_access.title")}
description={t("project_empty_state.no_access.restricted_description")}
assetKey="no-access"
assetClassName="size-40"
/>
</div>
);
}
// Show empty state screen if:
// - Project not found (404 Not Found)
// - Any other error status code
return (
<div className="grid h-full w-full place-items-center bg-surface-1">
<EmptyStateDetailed
title={t("project_empty_state.invalid_project.title")}
description={t("project_empty_state.invalid_project.description")}
assetKey="project"
assetClassName="size-40"
/>
</div>
);
});
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import Link from "next/link";
// ui
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
// layouts
import DefaultLayout from "@/layouts/default-layout";
export function NotAWorkspaceMember() {
const { t } = useTranslation();
return (
<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-16 font-semibold">{t("workspace_access.not_authorized_title")}</h3>
<p className="mx-auto w-1/2 text-13 text-secondary">
{t("workspace_access.not_authorized_description")}
</p>
</div>
<div className="flex items-center justify-center gap-2">
<Link href="/invitations">
<span>
<Button variant="secondary">{t("workspace_access.pending_invites_button")}</Button>
</span>
</Link>
<Link href="/create-workspace">
<span>
<Button variant="primary">{t("create_workspace")}</Button>
</span>
</Link>
</div>
</div>
</div>
</DefaultLayout>
);
}
@@ -0,0 +1,128 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ArchiveRestore } from "lucide-react";
// plane imports
import { PROJECT_AUTOMATION_MONTHS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { IProject } from "@plane/types";
import { CustomSelect, Loader, ToggleSwitch } from "@plane/ui";
// component
import { SelectMonthModal } from "@/components/automation";
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
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 = observer(function AutoArchiveAutomation(props: 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]);
const handleToggleArchive = async () => {
if (currentProjectDetails?.archive_in === 0) {
await handleChange({ archive_in: 1 });
} else {
await handleChange({ archive_in: 0 });
}
};
return (
<>
<SelectMonthModal
type="auto-archive"
initialValues={initialValues}
isOpen={monthModal}
handleClose={() => setmonthModal(false)}
handleChange={handleChange}
/>
<div className="flex flex-col gap-4 border-b border-subtle py-2">
<div className="flex items-center gap-3">
<div className="grid size-10 shrink-0 place-items-center rounded-sm bg-layer-2">
<ArchiveRestore className="size-4 shrink-0 text-primary" />
</div>
<SettingsControlItem
title={t("project_settings.automations.auto-archive.title")}
description={t("project_settings.automations.auto-archive.description")}
control={
<ToggleSwitch value={autoArchiveStatus} onChange={handleToggleArchive} size="sm" disabled={!isAdmin} />
}
/>
</div>
{currentProjectDetails ? (
autoArchiveStatus && (
<div className="ml-13">
<div className="flex w-full items-center justify-between gap-2 rounded-sm border border-subtle bg-surface-2 px-5 py-4">
<div className="w-1/2 text-13 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) => void handleChange({ archive_in: val })}
input
disabled={!isAdmin}
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.i18n_label} value={month.value}>
<span className="text-13">{t(month.i18n_label, { months: month.value })}</span>
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full items-center rounded-sm px-1 py-1.5 text-13 text-secondary select-none hover:bg-layer-1"
onClick={() => setmonthModal(true)}
>
{t("common.customize_time_range")}
</button>
</>
</CustomSelect>
</div>
</div>
</div>
)
) : (
<Loader className="ml-13">
<Loader.Item height="50px" />
</Loader>
)}
</div>
</>
);
});
@@ -0,0 +1,196 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ArchiveX } from "lucide-react";
// plane imports
import { PROJECT_AUTOMATION_MONTHS, EUserPermissions, EUserPermissionsLevel, EIconSize } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { StateGroupIcon, StatePropertyIcon } from "@plane/propel/icons";
import type { IProject } from "@plane/types";
import { CustomSelect, CustomSearchSelect, ToggleSwitch, Loader } from "@plane/ui";
import { SelectMonthModal } from "@/components/automation";
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
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 = observer(function AutoCloseAutomation(props: 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-2">
<div className="flex items-center gap-3">
<div className="grid size-10 shrink-0 place-items-center rounded-sm bg-layer-2">
<ArchiveX className="size-4 shrink-0 text-danger-primary" />
</div>
<SettingsControlItem
title={t("project_settings.automations.auto-close.title")}
description={t("project_settings.automations.auto-close.description")}
control={
<ToggleSwitch
value={autoCloseStatus}
onChange={() => {
if (currentProjectDetails?.close_in === 0) {
void handleChange({ close_in: 1, default_state: defaultState });
} else {
void handleChange({ close_in: 0, default_state: null });
}
}}
size="sm"
disabled={!isAdmin}
/>
}
/>
</div>
{currentProjectDetails ? (
autoCloseStatus && (
<div className="ml-13">
<div className="flex flex-col rounded-sm border border-subtle bg-surface-2">
<div className="flex w-full items-center justify-between gap-2 px-5 py-4">
<div className="w-1/2 text-13 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) => void 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 items-center rounded-sm px-1 py-1.5 text-secondary select-none hover:bg-layer-1"
onClick={() => setmonthModal(true)}
>
{t("common.customize_time_range")}
</button>
</>
</CustomSelect>
</div>
</div>
<div className="ppy flex w-full items-center justify-between gap-2 px-5 py-4 sm:py-10">
<div className="w-1/2 text-13 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-secondary" />
)}
{selectedOption?.name
? selectedOption.name
: (currentDefaultState?.name ?? <span className="text-secondary">{t("state")}</span>)}
</div>
}
onChange={(val: string) => void handleChange({ default_state: val })}
options={options}
disabled={!multipleOptions}
input
/>
</div>
</div>
</div>
</div>
)
) : (
<Loader className="ml-13">
<Loader.Item height="50px" />
</Loader>
)}
</div>
</>
);
});
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./auto-close-automation";
export * from "./auto-archive-automation";
export * from "./select-month-modal";
@@ -0,0 +1,136 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useParams } from "next/navigation";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
import { Button } from "@plane/propel/button";
import type { IProject } from "@plane/types";
// ui
import { Input, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// types
type Props = {
isOpen: boolean;
type: "auto-close" | "auto-archive";
initialValues: Partial<IProject>;
handleClose: () => void;
handleChange: (formData: Partial<IProject>) => Promise<void>;
};
export function SelectMonthModal({ type, initialValues, isOpen, handleClose, handleChange }: Props) {
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 (
<ModalCore isOpen={isOpen} handleClose={onClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<h3 className="text-16 leading-6 font-medium text-primary">Customize time range</h3>
<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-subtle"
min={1}
max={12}
/>
<span className="absolute top-2.5 right-8 text-13 text-secondary">Months</span>
</div>
)}
/>
{errors.close_in && (
<span className="px-1 text-13 text-danger-primary">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-subtle"
min={1}
max={12}
/>
<span className="absolute top-2.5 right-8 text-13 text-secondary">Months</span>
</div>
)}
/>
{errors.archive_in && (
<span className="px-1 text-13 text-danger-primary">Select a month between 1 and 12.</span>
)}
</>
)}
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button variant="secondary" size="lg" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" size="lg" type="submit" loading={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit"}
</Button>
</div>
</form>
</ModalCore>
);
}
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { BoardLayoutIcon, ListLayoutIcon, TimelineLayoutIcon } 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",
},
{
key: "gantt",
icon: TimelineLayoutIcon,
label: "Gantt Layout",
},
];
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export { BaseGanttLayout } from "./layout";
export { BaseGanttSidebar } from "./sidebar";
@@ -0,0 +1,146 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
import { GANTT_TIMELINE_TYPE } from "@plane/types";
import type {
IBaseLayoutsGanttItem,
IBaseLayoutsGanttProps,
TGanttBlockUpdateData,
IBlockUpdateDependencyData,
} from "@plane/types";
import { cn } from "@plane/utils";
import { TimeLineTypeContext } from "@/components/gantt-chart/contexts";
import { GanttChartRoot } from "@/components/gantt-chart/root";
import { BaseGanttSidebar } from "./sidebar";
export const BaseGanttLayout = observer(function BaseGanttLayout<T extends IBaseLayoutsGanttItem>(
props: IBaseLayoutsGanttProps<T>
) {
const {
items,
groupedItemIds,
groups,
renderBlock,
renderSidebar,
onBlockUpdate,
onDateUpdate,
enableBlockLeftResize = false,
enableBlockRightResize = false,
enableBlockMove = false,
enableReorder = false,
enableAddBlock = false,
enableSelection = false,
enableDependency = false,
showAllBlocks = false,
showToday = true,
border = false,
title = "Items",
loaderTitle = "items",
quickAdd,
loadMoreItems,
isLoading: _isLoading,
className,
timelineType: timelineTypeKey = GANTT_TIMELINE_TYPE.ISSUE,
} = props;
// Flatten all grouped item IDs into a single array for gantt
// Gantt doesn't typically show groups, it shows all items on a timeline
const blockIds = useMemo(() => {
const allIds: string[] = [];
groups.forEach((group) => {
const itemIds = groupedItemIds[group.id] || [];
allIds.push(...itemIds);
});
return allIds;
}, [groups, groupedItemIds]);
// Block update handler - transforms base layout item updates to gantt block updates
const handleBlockUpdate = useCallback(
(block: T, payload: TGanttBlockUpdateData) => {
if (onBlockUpdate) {
onBlockUpdate(block, payload);
}
},
[onBlockUpdate]
);
// Block renderer - wraps the user's render function
const blockToRender = useCallback((item: T) => renderBlock(item), [renderBlock]);
// Sidebar renderer - uses custom or default
const sidebarToRender = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(sidebarProps: any) => {
if (renderSidebar) {
// If custom sidebar renderer provided, use it
return (
<BaseGanttSidebar {...sidebarProps} items={items} renderItem={renderSidebar} loadMoreItems={loadMoreItems} />
);
}
// Otherwise use default sidebar
return (
<BaseGanttSidebar {...sidebarProps} items={items} renderItem={renderBlock} loadMoreItems={loadMoreItems} />
);
},
[renderSidebar, renderBlock, items, loadMoreItems]
);
const timelineType = GANTT_TIMELINE_TYPE[timelineTypeKey];
// Date update handler - transforms IBlockUpdateDependencyData to TGanttDateUpdate
const handleDateUpdate = useCallback(
async (updates: IBlockUpdateDependencyData[]) => {
if (onDateUpdate) {
// Transform IBlockUpdateDependencyData[] to TGanttDateUpdate[]
const transformedUpdates = updates.map((update) => ({
id: update.id,
start_date: update.start_date,
target_date: update.target_date,
}));
await onDateUpdate(transformedUpdates);
}
},
[onDateUpdate]
);
// Load more handler - wraps loadMoreItems to match expected signature
const handleLoadMore = useCallback(() => {
if (loadMoreItems) {
loadMoreItems(""); // Pass empty string as default group ID
}
}, [loadMoreItems]);
return (
<TimeLineTypeContext.Provider value={timelineType}>
<div className={cn("h-full w-full", className)}>
<GanttChartRoot
border={border}
title={title}
loaderTitle={loaderTitle}
blockIds={blockIds}
blockUpdateHandler={handleBlockUpdate}
blockToRender={blockToRender}
sidebarToRender={sidebarToRender}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
enableReorder={enableReorder}
enableAddBlock={enableAddBlock}
enableSelection={enableSelection}
enableDependency={enableDependency}
showAllBlocks={showAllBlocks}
showToday={showToday}
quickAdd={quickAdd}
updateBlockDates={onDateUpdate ? handleDateUpdate : undefined}
loadMoreBlocks={loadMoreItems ? handleLoadMore : undefined}
canLoadMoreBlocks={!!loadMoreItems} // Enable pagination if loadMoreItems is provided
/>
</div>
</TimeLineTypeContext.Provider>
);
});
@@ -0,0 +1,155 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { RefObject } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import type { IBaseLayoutsBaseItem, IBlockUpdateData } from "@plane/types";
import { Loader, Row } from "@plane/ui";
import { cn } from "@plane/utils";
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants";
import { GanttDnDHOC } from "@/components/gantt-chart/sidebar/gantt-dnd-HOC";
import { handleOrderChange } from "@/components/gantt-chart/sidebar/utils";
import { GanttLayoutListItemLoader } from "@/components/ui/loader/layouts/gantt-layout-loader";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
type Props<T extends IBaseLayoutsBaseItem> = {
blockUpdateHandler: (block: T, payload: IBlockUpdateData) => void;
canLoadMoreBlocks?: boolean;
loadMoreItems?: (groupId: string) => void;
ganttContainerRef: RefObject<HTMLDivElement>;
blockIds: string[];
enableReorder: boolean;
showAllBlocks?: boolean;
items: Record<string, T>;
renderItem: (item: T) => React.ReactNode;
};
export const BaseGanttSidebar = observer(function BaseGanttSidebar<T extends IBaseLayoutsBaseItem>(props: Props<T>) {
const {
blockUpdateHandler,
blockIds,
enableReorder,
loadMoreItems,
canLoadMoreBlocks,
ganttContainerRef,
showAllBlocks = false,
items,
renderItem,
} = props;
const { getBlockById, updateActiveBlockId, isBlockActive, getNumberOfDaysFromPosition } = useTimeLineChartStore();
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
const isPaginating = false; // TODO: Add proper pagination state
useIntersectionObserver(
ganttContainerRef,
isPaginating ? null : intersectionElement,
loadMoreItems ? () => loadMoreItems("") : undefined,
"100% 0% 100% 0%"
);
const handleOnDrop = (
draggingBlockId: string | undefined,
droppedBlockId: string | undefined,
dropAtEndOfList: boolean
) => {
handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blockIds, getBlockById, blockUpdateHandler);
};
return (
<div>
{blockIds ? (
<>
{blockIds.map((blockId, index) => {
const block = getBlockById(blockId);
const item = items[blockId];
const isBlockVisibleOnSidebar = block?.start_date && block?.target_date;
// hide the block if it doesn't have start and target dates and showAllBlocks is false
if (!block || (!showAllBlocks && !isBlockVisibleOnSidebar)) return null;
if (!item) return null;
return (
<RenderIfVisible
key={blockId}
root={ganttContainerRef}
horizontalOffset={100}
verticalOffset={200}
shouldRecordHeights={false}
placeholderChildren={<GanttLayoutListItemLoader />}
>
<GanttDnDHOC
id={blockId}
isLastChild={index === blockIds.length - 1}
isDragEnabled={enableReorder}
onDrop={handleOnDrop}
>
{(isDragging: boolean) => {
const block = getBlockById(blockId);
const isBlockComplete = !!block?.start_date && !!block?.target_date;
const duration = isBlockComplete ? getNumberOfDaysFromPosition(block?.position?.width) : undefined;
const isBlockHoveredOn = isBlockActive(blockId);
return (
<div
className={cn("group/list-block", {
"rounded-sm bg-layer-1": isDragging,
})}
onMouseEnter={() => updateActiveBlockId(blockId)}
onMouseLeave={() => updateActiveBlockId(null)}
>
<Row
className={cn(
"group flex w-full items-center gap-2 bg-layer-transparent pr-4 hover:bg-layer-transparent-hover",
{
"bg-layer-transparent-hover": isBlockHoveredOn,
}
)}
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
<div className="flex-grow truncate">{renderItem(item)}</div>
{duration && (
<div className="flex-shrink-0 text-13 text-secondary">
<span>
{duration} day{duration > 1 ? "s" : ""}
</span>
</div>
)}
</div>
</Row>
</div>
);
}}
</GanttDnDHOC>
</RenderIfVisible>
);
})}
{canLoadMoreBlocks && (
<div ref={setIntersectionElement} className="p-2">
<div className="flex h-10 w-full animate-pulse items-center justify-between gap-1.5 rounded-sm bg-layer-1 px-4 py-1.5 md:h-8 md:px-1" />
</div>
)}
</>
) : (
<Loader className="space-y-3 pr-2">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
</div>
);
});
@@ -0,0 +1,61 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 };
};
@@ -0,0 +1,64 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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,
};
};
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { IGroupHeaderProps } from "@plane/types";
export function GroupHeader({ group, itemCount, onToggleGroup }: IGroupHeaderProps) {
return (
<button
onClick={() => onToggleGroup(group.id)}
className="flex w-full items-center gap-2 text-13 font-medium text-secondary"
>
<div className="flex items-center gap-2">
{group.icon}
<span>{group.name}</span>
</div>
<span className="text-11 text-tertiary">{itemCount}</span>
</button>
);
}
@@ -0,0 +1,104 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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(function BaseKanbanGroup<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 max-h-full w-[350px] flex-shrink-0 flex-col overflow-y-auto rounded-md border-[1px] border-transparent bg-layer-1 p-2 pt-0",
{
"bg-layer-1": isDraggingOver,
},
groupClassName
)}
>
{/* Group Header */}
<div className="sticky top-0 z-[2] w-full flex-shrink-0 cursor-pointer px-1 py-2">
{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-13 text-tertiary">
{t("common.no_items_in_this_group")}
</div>
)}
</div>
)}
{isDraggingOver && enableDragDrop && (
<div className="absolute top-0 left-0 z-[2] flex h-full w-full items-center justify-center rounded-sm border-[1px] border-strong bg-layer-1/85 text-13 font-medium text-tertiary">
<div className="my-8 flex flex-col items-center rounded-sm p-3 text-secondary">
{t("common.drop_here_to_move")}
</div>
</div>
)}
</div>
);
});
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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(function BaseKanbanItem<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>
);
});
@@ -0,0 +1,74 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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(function BaseKanbanLayout<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 flex h-full w-full gap-2 overflow-x-auto p-3", 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>
);
});
@@ -0,0 +1,62 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
import type { TBaseLayoutType } from "@plane/types";
import { cn } from "@plane/utils";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { BASE_LAYOUTS } from "./constants";
type Props = {
layouts?: TBaseLayoutType[];
onChange: (layout: TBaseLayoutType) => void;
selectedLayout: TBaseLayoutType;
};
export function LayoutSwitcher(props: Props) {
const { layouts, onChange, selectedLayout } = props;
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const handleOnChange = (layoutKey: TBaseLayoutType) => {
if (selectedLayout !== layoutKey) {
onChange(layoutKey);
}
};
return (
<div className="flex items-center gap-1 rounded-md bg-layer-3 p-1">
{BASE_LAYOUTS.filter((l) => (layouts ? layouts.includes(l.key) : true)).map((layout) => {
const Icon = layout.icon;
return (
<Tooltip key={layout.key} tooltipContent={t(layout.label)} isMobile={isMobile}>
<button
type="button"
className={cn(
"group grid h-5.5 w-7 place-items-center overflow-hidden rounded-sm transition-all hover:bg-layer-transparent-hover",
{
"bg-layer-transparent-active hover:bg-layer-transparent-active": selectedLayout === layout.key,
}
)}
onClick={() => handleOnChange(layout.key)}
>
<Icon
width={14}
height={14}
strokeWidth={2}
className={cn("size-3.5", {
"text-primary": selectedLayout === layout.key,
"text-secondary": selectedLayout !== layout.key,
})}
/>
</button>
</Tooltip>
);
})}
</div>
);
}
@@ -0,0 +1,20 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { IGroupHeaderProps } from "@plane/types";
export function GroupHeader({ group, itemCount, onToggleGroup }: IGroupHeaderProps) {
return (
<button
onClick={() => onToggleGroup(group.id)}
className="flex w-full items-center gap-2 py-2 text-13 font-medium text-secondary"
>
{group.icon}
<span>{group.name}</span>
<span className="text-11 text-tertiary">{itemCount}</span>
</button>
);
}
@@ -0,0 +1,93 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { IBaseLayoutsListItem, IBaseLayoutsListGroupProps } from "@plane/types";
import { cn } from "@plane/ui";
import { useGroupDropTarget } from "../hooks/use-group-drop-target";
import { GroupHeader } from "./group-header";
import { BaseListItem } from "./item";
export const BaseListGroup = observer(function BaseListGroup<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", {
"bg-layer-1": isDraggingOver,
})}
>
{/* Group Header */}
<div className="sticky top-0 z-10 w-full shrink-0 cursor-pointer border-b border-subtle bg-layer-1 px-6 py-1 hover:bg-layer-1-hover">
{renderGroupHeader ? (
renderGroupHeader({ group, itemCount: itemIds.length, isCollapsed, onToggleGroup })
) : (
<GroupHeader
group={group}
itemCount={itemIds.length}
isCollapsed={isCollapsed}
onToggleGroup={onToggleGroup}
/>
)}
</div>
{/* 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 z-[2] flex h-full w-full items-center justify-center rounded-sm border-[1px] border-strong bg-layer-1/85 text-13 font-medium text-tertiary">
<div className="my-8 flex flex-col items-center rounded-sm p-3 text-secondary">
{t("common.drop_here_to_move")}
</div>
</div>
)}
</div>
);
});
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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(function BaseListItem<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>
);
});
@@ -0,0 +1,74 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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(function BaseListLayout<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-surface-1", className)}>
<div className="relative flex size-full 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>
);
});
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 function 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;
}
}
@@ -0,0 +1,178 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { getWeekOfMonth, isValid } from "date-fns";
import { CHART_X_AXIS_DATE_PROPERTIES, ChartXAxisDateGrouping, TO_CAPITALIZE_PROPERTIES } from "@plane/constants";
import type { ChartXAxisProperty, TChart, TChartDatum } from "@plane/types";
import {
capitalizeFirstLetter,
hexToHsl,
hslToHex,
renderFormattedDate,
renderFormattedDateWithoutYear,
} from "@plane/utils";
//
const getDateGroupingName = (date: string, dateGrouping: ChartXAxisDateGrouping): string => {
if (!date || ["none", "null"].includes(date.toLowerCase())) return "None";
const formattedData = new Date(date);
const isValidDate = isValid(formattedData);
if (!isValidDate) return date;
const year = formattedData.getFullYear();
const currentYear = new Date().getFullYear();
const isCurrentYear = year === currentYear;
let parsedName: string | undefined;
switch (dateGrouping) {
case ChartXAxisDateGrouping.DAY:
if (isCurrentYear) parsedName = renderFormattedDateWithoutYear(formattedData);
else parsedName = renderFormattedDate(formattedData);
break;
case ChartXAxisDateGrouping.WEEK: {
const month = renderFormattedDate(formattedData, "MMM");
parsedName = `${month}, Week ${getWeekOfMonth(formattedData)}`;
break;
}
case ChartXAxisDateGrouping.MONTH:
if (isCurrentYear) parsedName = renderFormattedDate(formattedData, "MMM");
else parsedName = renderFormattedDate(formattedData, "MMM, yyyy");
break;
case ChartXAxisDateGrouping.YEAR:
parsedName = `${year}`;
break;
default:
parsedName = date;
}
return parsedName ?? date;
};
export const parseChartData = (
data: TChart | null | undefined,
xAxisProperty: ChartXAxisProperty | null | undefined,
groupByProperty: ChartXAxisProperty | null | undefined,
xAxisDateGrouping: ChartXAxisDateGrouping | null | undefined
): TChart => {
if (!data) {
return {
data: [],
schema: {},
};
}
const widgetData = structuredClone(data.data);
const schema = structuredClone(data.schema);
const allKeys = Object.keys(schema);
const updatedWidgetData: TChartDatum[] = widgetData.map((datum) => {
const keys = Object.keys(datum);
const missingKeys = allKeys.filter((key) => !keys.includes(key));
const missingValues: Record<string, number> = Object.fromEntries(missingKeys.map((key) => [key, 0]));
if (xAxisProperty) {
// capitalize first letter if xAxisProperty is in TO_CAPITALIZE_PROPERTIES and no groupByProperty is set
if (TO_CAPITALIZE_PROPERTIES.includes(xAxisProperty)) {
datum.name = capitalizeFirstLetter(datum.name);
}
// parse timestamp to visual date if xAxisProperty is in WIDGET_X_AXIS_DATE_PROPERTIES
if (CHART_X_AXIS_DATE_PROPERTIES.includes(xAxisProperty)) {
datum.name = getDateGroupingName(datum.name, xAxisDateGrouping ?? ChartXAxisDateGrouping.DAY);
}
}
return {
...datum,
...missingValues,
};
});
// capitalize first letter if groupByProperty is in TO_CAPITALIZE_PROPERTIES
const updatedSchema = schema;
if (groupByProperty) {
if (TO_CAPITALIZE_PROPERTIES.includes(groupByProperty)) {
Object.keys(updatedSchema).forEach((key) => {
updatedSchema[key] = capitalizeFirstLetter(updatedSchema[key]);
});
}
if (CHART_X_AXIS_DATE_PROPERTIES.includes(groupByProperty)) {
Object.keys(updatedSchema).forEach((key) => {
updatedSchema[key] = getDateGroupingName(updatedSchema[key], xAxisDateGrouping ?? ChartXAxisDateGrouping.DAY);
});
}
}
return {
data: updatedWidgetData,
schema: updatedSchema,
};
};
export const generateExtendedColors = (baseColorSet: string[], targetCount: number) => {
const colors = [...baseColorSet];
const baseCount = baseColorSet.length;
if (targetCount <= baseCount) {
return colors.slice(0, targetCount);
}
// Convert base colors to HSL
const baseHSL = baseColorSet.map(hexToHsl);
// Calculate average saturation and lightness from base colors
const avgSat = baseHSL.reduce((sum, hsl) => sum + hsl.s, 0) / baseHSL.length;
const avgLight = baseHSL.reduce((sum, hsl) => sum + hsl.l, 0) / baseHSL.length;
// Sort base colors by hue for better distribution
const sortedBaseHSL = [...baseHSL].sort((a, b) => a.h - b.h);
// Generate additional colors for each base color
const colorsNeeded = targetCount - baseCount;
const colorsPerBase = Math.ceil(colorsNeeded / baseCount);
for (let i = 0; i < baseCount; i++) {
const baseColor = sortedBaseHSL[i];
const nextBaseColor = sortedBaseHSL[(i + 1) % baseCount];
// Calculate hue distance to next base color
const hueDistance = (nextBaseColor.h - baseColor.h + 360) % 360;
const hueParts = colorsPerBase + 1;
// Narrower ranges for more consistency
const satRange = [Math.max(40, avgSat - 5), Math.min(60, avgSat + 5)];
const lightRange = [Math.max(40, avgLight - 5), Math.min(60, avgLight + 5)];
for (let j = 1; j <= colorsPerBase; j++) {
if (colors.length >= targetCount) break;
// Create evenly spaced hue variations between base colors
const hueStep = (hueDistance / hueParts) * j;
const newHue = (baseColor.h + hueStep) % 360;
// Keep saturation and lightness closer to base color
const newSat = baseColor.s * 0.8 + avgSat * 0.2;
const newLight = baseColor.l * 0.8 + avgLight * 0.2;
// Ensure values stay within desired ranges
const finalSat = Math.max(satRange[0], Math.min(satRange[1], newSat));
const finalLight = Math.max(lightRange[0], Math.min(lightRange[1], newLight));
colors.push(
hslToHex({
h: newHue,
s: finalSat,
l: finalLight,
})
);
}
}
return colors.slice(0, targetCount);
};
@@ -0,0 +1,189 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { useHashScroll } from "@plane/hooks";
import { GlobeIcon, LockIcon } from "@plane/propel/icons";
import { EIssueCommentAccessSpecifier } from "@plane/types";
import type { TCommentsOperations, TIssueComment } from "@plane/types";
import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor/lite-text";
// local imports
import { CommentReactions } from "../comment-reaction";
import { CommentCardEditForm } from "./edit-form";
import { EmojiReactionButton, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import { Avatar, Tooltip } from "@plane/ui";
import { useMember } from "@/hooks/store/use-member";
export type TCommentCardDisplayProps = {
activityOperations: TCommentsOperations;
comment: TIssueComment;
disabled: boolean;
entityId: string;
projectId?: string;
readOnlyEditorRef: React.RefObject<EditorRefApi>;
showAccessSpecifier: boolean;
workspaceId: string;
workspaceSlug: string;
isEditing?: boolean;
setIsEditing?: (isEditing: boolean) => void;
renderFooter?: (ReactionsComponent: ReactNode | null) => ReactNode;
renderQuickActions?: () => ReactNode;
};
export const CommentCardDisplay = observer(function CommentCardDisplay(props: TCommentCardDisplayProps) {
const {
activityOperations,
comment,
disabled,
projectId,
readOnlyEditorRef,
showAccessSpecifier,
workspaceId,
workspaceSlug,
isEditing = false,
setIsEditing,
renderFooter,
renderQuickActions,
} = props;
// states
const [highlightClassName, setHighlightClassName] = useState("");
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
// store hooks
const { getUserDetails } = useMember();
// derived values
const userDetails = getUserDetails(comment?.actor);
const displayName = comment?.actor_detail?.is_bot
? comment?.actor_detail?.first_name + `Bot`
: (userDetails?.display_name ?? comment?.actor_detail?.display_name);
const avatarUrl = userDetails?.avatar_url ?? comment?.actor_detail?.avatar_url;
const userReactions = activityOperations.userReactions(comment.id);
// navigation
const pathname = usePathname();
// derived values
const commentBlockId = `comment-${comment?.id}`;
// Check if there are any reactions to determine if we should render the footer
const reactionIds = activityOperations.reactionIds(comment.id);
const hasReactions = reactionIds && Object.keys(reactionIds).some((key) => reactionIds[key]?.length > 0);
// scroll to comment
const { isHashMatch } = useHashScroll({
elementId: commentBlockId,
pathname,
});
useEffect(() => {
if (!isHashMatch) return;
setHighlightClassName("border-accent-strong");
const timeout = setTimeout(() => {
setHighlightClassName("");
}, 8000);
return () => clearTimeout(timeout);
}, [isHashMatch]);
const handleEmojiSelect = useCallback(
(emoji: string) => {
if (!userReactions) return;
// emoji is already in decimal string format from EmojiReactionPicker
void activityOperations.react(comment.id, emoji, userReactions);
},
[activityOperations, comment.id, userReactions]
);
const shouldRenderReactions = hasReactions && !disabled;
return (
<div id={commentBlockId} className="relative flex flex-col gap-2">
{showAccessSpecifier && (
<div className="absolute top-2.5 right-2.5 z-[1] text-tertiary">
{comment.access === EIssueCommentAccessSpecifier.INTERNAL ? (
<LockIcon className="size-3" />
) : (
<GlobeIcon className="size-3" />
)}
</div>
)}
<div className="relative mb-3 flex w-full items-center gap-2">
<Avatar size="sm" name={displayName} src={getFileURL(avatarUrl)} className="shrink-0" />
<div className="flex flex-1 flex-wrap items-center gap-1">
<div className="text-caption-sm-medium">{displayName}</div>
<div className="text-caption-sm-regular text-tertiary">
commented{" "}
<Tooltip
tooltipContent={`${renderFormattedDate(comment.created_at)} at ${renderFormattedTime(comment.created_at)}`}
position="bottom"
>
<span className="text-tertiary">
{calculateTimeAgo(comment.created_at)}
{comment.edited_at && " (edited)"}
</span>
</Tooltip>
</div>
</div>
{!disabled && (
<div className="flex shrink-0 items-center gap-1">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={disabled}
label={<EmojiReactionButton onAddReaction={() => setIsPickerOpen(true)} />}
placement="bottom-start"
/>
{renderQuickActions ? renderQuickActions() : null}
</div>
)}
</div>
{isEditing && setIsEditing ? (
<CommentCardEditForm
activityOperations={activityOperations}
comment={comment}
isEditing={isEditing}
readOnlyEditorRef={readOnlyEditorRef.current}
setIsEditing={setIsEditing}
projectId={projectId}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
/>
) : (
<>
<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",
}}
parentClassName="border-none"
/>
{shouldRenderReactions &&
(renderFooter ? (
renderFooter(
<CommentReactions comment={comment} disabled={disabled} activityOperations={activityOperations} />
)
) : (
<CommentReactions comment={comment} disabled={disabled} activityOperations={activityOperations} />
))}
</>
)}
</div>
);
});
@@ -0,0 +1,144 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { useForm } from "react-hook-form";
import type { EditorRefApi } from "@plane/editor";
import { CheckIcon, CloseIcon } from "@plane/propel/icons";
// plane imports
import type { TCommentsOperations, TIssueComment } from "@plane/types";
import { cn, 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 = observer(function CommentCardEditForm(props: 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);
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;
}}
duplicateFile={async (assetId: string) => {
const { asset_id } = await activityOperations.duplicateCommentAsset(assetId, comment.id);
return asset_id;
}}
projectId={projectId}
parentClassName="p-2 bg-surface-1"
displayConfig={{
fontSize: "small-font",
}}
/>
</div>
<div className="flex gap-2 self-end">
{!isEmpty && (
<button
type="button"
onClick={handleSubmit(onEnter)}
disabled={isDisabled}
className={cn(
"group grid size-7 place-items-center rounded-lg border border-success-subtle bg-success-subtle shadow-raised-100 duration-300",
isDisabled ? "" : "hover:bg-success-subtle-1"
)}
>
<CheckIcon className="size-4 text-success-primary" />
</button>
)}
<button
type="button"
disabled={isSubmitting}
className={cn(
"group grid size-7 place-items-center rounded-lg border border-danger-subtle bg-danger-subtle shadow-raised-100 duration-300",
isSubmitting ? "" : "hover:bg-danger-subtle-hover"
)}
onClick={() => {
setIsEditing(false);
editorRef.current?.setEditorValue(comment.comment_html ?? "<p></p>");
}}
>
<CloseIcon className="size-4 text-danger-primary" />
</button>
</div>
</form>
);
});
@@ -0,0 +1,77 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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, CommentCardDisplay } from "@/plane-web/components/comments";
// local imports
import { CommentQuickActions } from "../quick-actions";
type TCommentCard = {
workspaceSlug: string;
entityId: string;
comment: TIssueComment | undefined;
activityOperations: TCommentsOperations;
ends: "top" | "bottom" | undefined;
showAccessSpecifier: boolean;
showCopyLinkOption: boolean;
enableReplies: boolean;
disabled?: boolean;
projectId?: string;
};
export const CommentCard = observer(function CommentCard(props: TCommentCard) {
const {
workspaceSlug,
entityId,
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} ends={ends}>
<CommentCardDisplay
activityOperations={activityOperations}
entityId={entityId}
comment={comment}
disabled={disabled}
projectId={projectId}
readOnlyEditorRef={readOnlyEditorRef}
showAccessSpecifier={showAccessSpecifier}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
isEditing={isEditing}
setIsEditing={setIsEditing}
renderQuickActions={() => (
<CommentQuickActions
activityOperations={activityOperations}
comment={comment}
setEditMode={() => setIsEditing(true)}
showAccessSpecifier={showAccessSpecifier}
showCopyLinkOption={showCopyLinkOption}
/>
)}
/>
</CommentBlock>
);
});
@@ -0,0 +1,158 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function CommentCreate(props: TCommentCreate) {
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)?.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-surface-1 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;
}}
duplicateFile={async (assetId: string) => {
const { asset_id } = await activityOperations.duplicateCommentAsset(assetId);
setUploadedAssetIds((prev) => [...prev, asset_id]);
return asset_id;
}}
showToolbarInitially={showToolbarInitially}
parentClassName="p-2"
displayConfig={{
fontSize: "small-font",
}}
/>
)}
/>
)}
/>
</div>
);
});
@@ -0,0 +1,93 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";
// helpers
// local imports
export type TProps = {
comment: TIssueComment;
disabled?: boolean;
activityOperations: TCommentsOperations;
};
export const CommentReactions = observer(function CommentReactions(props: TProps) {
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;
// Don't render anything if there are no reactions and it's disabled
if (reactions.length === 0 && disabled) return null;
// Don't show the add button if there are no reactions
const showAddButton = !disabled && reactions.length > 0;
return (
<div className="relative">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={disabled}
label={
<EmojiReactionGroup
reactions={reactions}
onReactionClick={handleReactionClick}
showAddButton={showAddButton}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});
@@ -0,0 +1,90 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { 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;
enableReplies?: boolean;
};
export const CommentsWrapper = observer(function CommentsWrapper(props: TCommentsWrapper) {
const {
entityId,
activityOperations,
comments,
getCommentById,
isEditingAllowed = true,
projectId,
showAccessSpecifier = false,
showCopyLinkOption = false,
enableReplies = 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 h-full flex-col gap-y-2 overflow-hidden">
{renderCommentCreate}
<div className="flex-grow overflow-y-auto py-4">
{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}
entityId={entityId}
comment={comment}
activityOperations={activityOperations}
disabled={!isEditingAllowed}
ends={index === 0 ? "top" : index === comments.length - 1 ? "bottom" : undefined}
projectId={projectId}
showAccessSpecifier={showAccessSpecifier}
showCopyLinkOption={showCopyLinkOption}
enableReplies={enableReplies}
/>
);
})}
</div>
</div>
);
});
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./comments";
@@ -0,0 +1,122 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo } from "react";
import { observer } from "mobx-react";
import { MoreHorizontal } from "lucide-react";
// plane imports
import { EIssueCommentAccessSpecifier } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IconButton } from "@plane/propel/icon-button";
import { LinkIcon, GlobeIcon, LockIcon, EditIcon, TrashIcon } from "@plane/propel/icons";
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 = observer(function CommentQuickActions(props: TCommentCard) {
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 = useMemo(
function MENU_ITEMS(): TContextMenuItem[] {
return [
{
key: "edit",
action: setEditMode,
title: t("common.actions.edit"),
icon: EditIcon,
shouldRender: canEdit,
},
{
key: "copy_link",
action: () => activityOperations.copyCommentLink(comment.id),
title: t("common.actions.copy_link"),
icon: LinkIcon,
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 ? GlobeIcon : LockIcon,
shouldRender: showAccessSpecifier,
},
{
key: "delete",
action: () => activityOperations.removeComment(comment.id),
title: t("common.actions.delete"),
icon: TrashIcon,
shouldRender: canDelete,
},
];
},
[t, setEditMode, canEdit, showCopyLinkOption, activityOperations, comment, showAccessSpecifier, canDelete]
);
return (
<CustomMenu customButton={<IconButton icon={MoreHorizontal} variant="ghost" size="sm" />} 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-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("size-3 shrink-0", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
);
});
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { LucideIcon } from "lucide-react";
// plane ui
import { useTranslation } from "@plane/i18n";
import type { ISvgIcons } from "@plane/propel/icons";
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 | React.FC<ISvgIcons>;
}[];
isMobile?: boolean;
};
// TODO: Remove label once i18n is done
export function 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-sm border-[1px] border-subtle 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(
"relative flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-xs p-1 transition-all",
value === access.key ? "bg-layer-1" : "hover:bg-layer-1"
)}
tabIndex={2 + index}
>
<access.icon
className={cn("h-3.5 w-3.5 transition-all", value === access.key ? "text-primary" : "text-placeholder")}
strokeWidth={2}
/>
</button>
</Tooltip>
);
})}
</div>
);
}
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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?: FC<{ className?: string }>;
activity: TWorkspaceBaseActivity;
ends: "top" | "bottom" | undefined;
children: ReactNode;
customUserName?: string;
};
export function ActivityBlockComponent(props: TActivityBlockComponent) {
const { icon: Icon, activity, ends, children, customUserName } = props;
// hooks
const { isMobile } = usePlatformOS();
if (!activity) return <></>;
return (
<div
className={`relative flex items-start gap-2 text-caption-sm-regular ${
ends === "top" ? `pb-3` : ends === "bottom" ? `pt-3` : `py-3`
}`}
>
<div className="z-[4] mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-subtle text-secondary shadow-raised-100">
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" /> : <Network className="h-3.5 w-3.5 shrink-0" />}
</div>
<div className="w-full text-secondary">
<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="cursor-help font-medium whitespace-nowrap text-tertiary">
{calculateTimeAgo(activity.created_at)}
</span>
</Tooltip>
</div>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function ActivityItem(props: TActivityItem) {
const { activity, 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>
);
});
@@ -0,0 +1,293 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { FC, ReactNode } from "react";
import {
RotateCcw,
Network,
Inbox,
AlignLeft,
Paperclip,
Type,
FileText,
Hash,
Clock,
Bell,
GitBranch,
Timer,
ListTodo,
Layers,
} from "lucide-react";
// components
import {
LinkIcon,
ArchiveIcon,
CycleIcon,
GlobeIcon,
DueDatePropertyIcon,
EstimatePropertyIcon,
GridLayoutIcon,
IntakeIcon,
LabelPropertyIcon,
MembersPropertyIcon,
ModuleIcon,
PriorityPropertyIcon,
StartDatePropertyIcon,
StatePropertyIcon,
} from "@plane/propel/icons";
import { store } from "@/lib/store-context";
import type { TProjectActivity } from "@/plane-web/types";
type ActivityIconMap = {
[key: string]: FC<{ className?: string }>;
};
export const iconsMap: ActivityIconMap = {
priority: PriorityPropertyIcon,
archived_at: ArchiveIcon,
restored: RotateCcw,
link: LinkIcon,
start_date: StartDatePropertyIcon,
target_date: DueDatePropertyIcon,
label: LabelPropertyIcon,
inbox: Inbox,
description: AlignLeft,
assignee: MembersPropertyIcon,
attachment: Paperclip,
name: Type,
state: StatePropertyIcon,
estimate: EstimatePropertyIcon,
cycle: CycleIcon,
module: ModuleIcon,
page: FileText,
network: GlobeIcon,
identifier: Hash,
timezone: Clock,
is_project_updates_enabled: Bell,
is_epic_enabled: GridLayoutIcon,
is_workflow_enabled: GitBranch,
is_time_tracking_enabled: Timer,
is_issue_type_enabled: ListTodo,
default: Network,
module_view: ModuleIcon,
cycle_view: CycleIcon,
issue_views_view: Layers,
page_view: FileText,
intake_view: IntakeIcon,
};
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-primary">{newValue || "none"}</span>
</>
),
};
case "archived_at":
return {
message: newValue === "restore" ? "restored the project" : "archived the project",
customUserName: newValue === "archive" ? "NODE.DC" : undefined,
};
case "name":
return {
message: (
<>
renamed the project to <span className="font-medium text-primary">{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-primary">{newValue}</span>
</>
) : (
"removed the start date"
)}
</>
),
};
case "target_date":
return {
message: (
<>
{newValue ? (
<>
set the target date to <span className="font-medium text-primary">{newValue}</span>
</>
) : (
"removed the target date"
)}
</>
),
};
case "state":
return {
message: (
<>
set the state to <span className="font-medium text-primary">{newValue || "none"}</span>
</>
),
};
case "estimate":
return {
message: (
<>
{newValue ? (
<>
set the estimate point to <span className="font-medium text-primary">{newValue}</span>
</>
) : (
<>
removed the estimate point
{oldValue && (
<>
{" "}
<span className="font-medium text-primary">{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-primary"
>
{activity.new_value}
</a>
) : (
<span className="font-medium text-primary">{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-primary">
{verb === "removed" ? oldValue : newValue || "Unknown module"}
</span>
</>
),
};
case "labels":
return {
message: (
<>
{verb} the label{" "}
<span className="font-medium text-primary">{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-primary">{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-primary">{newValue || "none"}</span>
</>
),
};
case "timezone":
return {
message: (
<>
changed project timezone to <span className="font-medium text-primary">{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, " ")} `,
};
}
};
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function User(props: TUser) {
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="font-medium text-primary">{customUserName || "NODE.DC"}</span>
) : (
<Link
href={`/${workspaceDetail?.slug}/profile/${actorDetail?.id}`}
className="font-medium text-primary hover:underline"
>
{actorDetail?.display_name}
</Link>
)}
</>
);
});
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function AppliedDateFilters(props: 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-sm bg-layer-1 px-1.5 py-1 text-11">
<span className="normal-case">{getDateLabel(date)}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-tertiary hover:text-secondary"
onClick={() => handleRemove(date)}
>
<CloseIcon height={10} width={10} strokeWidth={2} />
</button>
)}
</div>
))}
</>
);
});

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