fix: merge conflicts resolved from develop

This commit is contained in:
Aaryan Khandelwal 2024-02-09 16:46:40 +05:30
commit e000e7eedd
86 changed files with 1785 additions and 750 deletions

View File

@ -401,8 +401,8 @@ class EmailCheckEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
event_name="Sign up",
medium="Magic link",
first_time=True,
)
key, token, current_attempt = generate_magic_token(email=email)
@ -438,8 +438,8 @@ class EmailCheckEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
event_name="Sign in",
medium="Magic link",
first_time=False,
)
@ -468,8 +468,8 @@ class EmailCheckEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
event_name="Sign in",
medium="Email",
first_time=False,
)

View File

@ -274,8 +274,8 @@ class SignInEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
event_name="Sign in",
medium="Email",
first_time=False,
)
@ -349,8 +349,8 @@ class MagicSignInEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
event_name="Sign in",
medium="Magic link",
first_time=False,
)

View File

@ -296,7 +296,7 @@ class OauthEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
event_name="Sign in",
medium=medium.upper(),
first_time=False,
)
@ -427,7 +427,7 @@ class OauthEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
event_name="Sign up",
medium=medium.upper(),
first_time=True,
)

View File

@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form";
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// icons
import { Eye, EyeOff } from "lucide-react";
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED } from "constants/event-tracker";
type Props = {
email: string;
@ -34,6 +36,8 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
// states
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
// toast alert
const { setToastAlert } = useToast();
// form info
@ -63,21 +67,34 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
title: "Success!",
message: "Password created successfully.",
});
captureEvent(PASSWORD_CREATE_SELECTED, {
state: "SUCCESS",
first_time: false,
});
await handleSignInRedirection();
})
.catch((err) =>
.catch((err) => {
captureEvent(PASSWORD_CREATE_SELECTED, {
state: "FAILED",
first_time: false,
});
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
});
});
};
const handleGoToWorkspace = async () => {
setIsGoingToWorkspace(true);
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
await handleSignInRedirection().finally(() => {
captureEvent(PASSWORD_CREATE_SKIPPED, {
state: "SUCCESS",
first_time: false,
});
setIsGoingToWorkspace(false);
});
};
return (

View File

@ -7,7 +7,7 @@ import { Eye, EyeOff, XCircle } from "lucide-react";
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import { useApplication } from "hooks/store";
import { useApplication, useEventTracker } from "hooks/store";
// components
import { ESignInSteps, ForgotPasswordPopover } from "components/account";
// ui
@ -16,6 +16,8 @@ import { Button, Input } from "@plane/ui";
import { checkEmailValidity } from "helpers/string.helper";
// types
import { IPasswordSignInData } from "@plane/types";
// constants
import { FORGOT_PASSWORD, SIGN_IN_WITH_PASSWORD } from "constants/event-tracker";
type Props = {
email: string;
@ -46,6 +48,7 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
const {
config: { envConfig },
} = useApplication();
const { captureEvent } = useEventTracker();
// derived values
const isSmtpConfigured = envConfig?.is_smtp_configured;
// form info
@ -72,7 +75,13 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
await authService
.passwordSignIn(payload)
.then(async () => await onSubmit())
.then(async () => {
captureEvent(SIGN_IN_WITH_PASSWORD, {
state: "SUCCESS",
first_time: false,
});
await onSubmit();
})
.catch((err) =>
setToastAlert({
type: "error",
@ -182,9 +191,10 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
</div>
)}
/>
<div className="w-full text-right mt-2 pb-3">
<div className="mt-2 w-full pb-3 text-right">
{isSmtpConfigured ? (
<Link
onClick={() => captureEvent(FORGOT_PASSWORD)}
href={`/accounts/forgot-password?email=${email}`}
className="text-xs font-medium text-primary-text-subtle"
>

View File

@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import Link from "next/link";
import { observer } from "mobx-react-lite";
// hooks
import { useApplication } from "hooks/store";
import { useApplication, useEventTracker } from "hooks/store";
import useSignInRedirection from "hooks/use-sign-in-redirection";
// components
import { LatestFeatureBlock } from "components/common";
@ -13,6 +13,8 @@ import {
OAuthOptions,
SignInOptionalSetPasswordForm,
} from "components/account";
// constants
import { NAVIGATE_TO_SIGNUP } from "constants/event-tracker";
export enum ESignInSteps {
EMAIL = "EMAIL",
@ -32,6 +34,7 @@ export const SignInRoot = observer(() => {
const {
config: { envConfig },
} = useApplication();
const { captureEvent } = useEventTracker();
// derived values
const isSmtpConfigured = envConfig?.is_smtp_configured;
@ -110,7 +113,11 @@ export const SignInRoot = observer(() => {
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_in" />
<p className="text-xs text-onboarding-text-300 text-center mt-6">
Don{"'"}t have an account?{" "}
<Link href="/accounts/sign-up" className="text-primary-text-subtle font-medium underline">
<Link
href="/accounts/sign-up"
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
className="text-primary-text-subtle font-medium underline"
>
Sign up
</Link>
</p>

View File

@ -7,12 +7,15 @@ import { UserService } from "services/user.service";
// hooks
import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
import { useEventTracker } from "hooks/store";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// types
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
// constants
import { CODE_VERIFIED } from "constants/event-tracker";
type Props = {
email: string;
@ -41,6 +44,8 @@ export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
// toast alert
const { setToastAlert } = useToast();
// store hooks
const { captureEvent } = useEventTracker();
// timer
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30);
// form info
@ -69,17 +74,22 @@ export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
await authService
.magicSignIn(payload)
.then(async () => {
captureEvent(CODE_VERIFIED, {
state: "SUCCESS",
});
const currentUser = await userService.currentUser();
await onSubmit(currentUser.is_password_autoset);
})
.catch((err) =>
.catch((err) => {
captureEvent(CODE_VERIFIED, {
state: "FAILED",
});
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
});
});
};
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {

View File

@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form";
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// constants
import { ESignUpSteps } from "components/account";
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED, SETUP_PASSWORD } from "constants/event-tracker";
// icons
import { Eye, EyeOff } from "lucide-react";
@ -37,6 +39,8 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
// states
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
// toast alert
const { setToastAlert } = useToast();
// form info
@ -66,21 +70,34 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
title: "Success!",
message: "Password created successfully.",
});
captureEvent(SETUP_PASSWORD, {
state: "SUCCESS",
first_time: true,
});
await handleSignInRedirection();
})
.catch((err) =>
.catch((err) => {
captureEvent(SETUP_PASSWORD, {
state: "FAILED",
first_time: true,
});
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
});
});
};
const handleGoToWorkspace = async () => {
setIsGoingToWorkspace(true);
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
await handleSignInRedirection().finally(() => {
captureEvent(PASSWORD_CREATE_SKIPPED, {
state: "SUCCESS",
first_time: true,
});
setIsGoingToWorkspace(false);
});
};
return (

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useApplication } from "hooks/store";
import { useApplication, useEventTracker } from "hooks/store";
import useSignInRedirection from "hooks/use-sign-in-redirection";
// components
import {
@ -12,6 +12,8 @@ import {
SignUpUniqueCodeForm,
} from "components/account";
import Link from "next/link";
// constants
import { NAVIGATE_TO_SIGNIN } from "constants/event-tracker";
export enum ESignUpSteps {
EMAIL = "EMAIL",
@ -32,6 +34,7 @@ export const SignUpRoot = observer(() => {
const {
config: { envConfig },
} = useApplication();
const { captureEvent } = useEventTracker();
// step 1 submit handler- email verification
const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE);
@ -86,7 +89,11 @@ export const SignUpRoot = observer(() => {
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_up" />
<p className="text-xs text-onboarding-text-300 text-center mt-6">
Already using Plane?{" "}
<Link href="/" className="text-primary-text-subtle font-medium underline">
<Link
href="/"
onClick={() => captureEvent(NAVIGATE_TO_SIGNIN, {})}
className="text-primary-text-subtle font-medium underline"
>
Sign in
</Link>
</p>

View File

@ -8,12 +8,15 @@ import { UserService } from "services/user.service";
// hooks
import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
import { useEventTracker } from "hooks/store";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// types
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
// constants
import { CODE_VERIFIED } from "constants/event-tracker";
type Props = {
email: string;
@ -39,6 +42,8 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
const { email, handleEmailClear, onSubmit } = props;
// states
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
// toast alert
const { setToastAlert } = useToast();
// timer
@ -69,17 +74,22 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
await authService
.magicSignIn(payload)
.then(async () => {
captureEvent(CODE_VERIFIED, {
state: "SUCCESS",
});
const currentUser = await userService.currentUser();
await onSubmit(currentUser.is_password_autoset);
})
.catch((err) =>
.catch((err) => {
captureEvent(CODE_VERIFIED, {
state: "FAILED",
});
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
});
});
};
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
@ -96,7 +106,6 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
title: "Success!",
message: "A new unique code has been sent to your email.",
});
reset({
email: formData.email,
token: "",

View File

@ -16,6 +16,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
// constants
import { CYCLE_STATUS } from "constants/cycle";
import { EUserWorkspaceRoles } from "constants/workspace";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
//.types
import { TCycleGroups } from "@plane/types";
@ -33,7 +34,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
// router
const router = useRouter();
// store
const { setTrackElement } = useEventTracker();
const { setTrackElement, captureEvent } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
@ -90,39 +91,55 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
.then(() => {
captureEvent(CYCLE_FAVORITED, {
cycle_id: cycleId,
element: "Grid layout",
state: "SUCCESS",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
});
});
});
};
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
.then(() => {
captureEvent(CYCLE_UNFAVORITED, {
cycle_id: cycleId,
element: "Grid layout",
state: "SUCCESS",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
});
});
});
};
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page board layout");
setTrackElement("Cycles page grid layout");
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page board layout");
setTrackElement("Cycles page grid layout");
setDeleteModal(true);
};

View File

@ -18,6 +18,7 @@ import { CYCLE_STATUS } from "constants/cycle";
import { EUserWorkspaceRoles } from "constants/workspace";
// types
import { TCycleGroups } from "@plane/types";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
type TCyclesListItem = {
cycleId: string;
@ -37,7 +38,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
// router
const router = useRouter();
// store hooks
const { setTrackElement } = useEventTracker();
const { setTrackElement, captureEvent } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
@ -63,26 +64,42 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
.then(() => {
captureEvent(CYCLE_FAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
});
});
});
};
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
.then(() => {
captureEvent(CYCLE_UNFAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
});
});
});
};
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
@ -159,9 +176,9 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
projectId={projectId}
/>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
<div className="group flex flex-col md:flex-row w-full items-center justify-between gap-5 border-b border-neutral-border-subtle bg-neutral-component-surface-light px-5 py-6 text-sm hover:bg-neutral-component-surface-medium">
<div className="relative w-full flex items-center justify-between gap-3 overflow-hidden">
<div className="relative w-full flex items-center gap-3 overflow-hidden">
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-neutral-border-subtle bg-neutral-component-surface-light px-5 py-6 text-sm hover:bg-neutral-component-surface-medium md:flex-row">
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative flex w-full items-center gap-3 overflow-hidden">
<div className="flex-shrink-0">
<CircularProgressIndicator size={38} percentage={progress}>
{isCompleted ? (
@ -181,20 +198,20 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
<div className="relative flex items-center gap-2.5 overflow-hidden">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
<Tooltip tooltipContent={cycleDetails.name} position="top">
<span className="truncate line-clamp-1 inline-block overflow-hidden text-base font-medium">
<span className="line-clamp-1 inline-block overflow-hidden truncate text-base font-medium">
{cycleDetails.name}
</span>
</Tooltip>
</div>
<button onClick={openCycleOverview} className="flex-shrink-0 z-10 invisible group-hover:visible">
<button onClick={openCycleOverview} className="invisible z-10 flex-shrink-0 group-hover:visible">
<Info className="h-4 w-4 text-neutral-text-subtle" />
</button>
</div>
{currentCycle && (
<div
className="flex-shrink-0 relative flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
@ -206,12 +223,12 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
</div>
)}
</div>
<div className="flex-shrink-0 relative overflow-hidden flex w-full items-center justify-between md:justify-end gap-2.5 md:w-auto md:flex-shrink-0 ">
<div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 overflow-hidden md:w-auto md:flex-shrink-0 md:justify-end ">
<div className="text-xs text-neutral-text-medium">
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
</div>
<div className="flex-shrink-0 relative flex items-center gap-3">
<div className="relative flex flex-shrink-0 items-center gap-3">
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}>
<div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignees.length > 0 ? (
@ -221,7 +238,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
))}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-neutral-component-surface-dark">
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-neutral-border-strong bg-neutral-component-surface-dark">
<User2 className="h-4 w-4 text-neutral-text-subtle" />
</span>
)}

View File

@ -10,6 +10,8 @@ import useToast from "hooks/use-toast";
import { Button } from "@plane/ui";
// types
import { ICycle } from "@plane/types";
// constants
import { CYCLE_DELETED } from "constants/event-tracker";
interface ICycleDelete {
cycle: ICycle;
@ -45,13 +47,13 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
message: "Cycle deleted successfully.",
});
captureCycleEvent({
eventName: "Cycle deleted",
eventName: CYCLE_DELETED,
payload: { ...cycle, state: "SUCCESS" },
});
})
.catch(() => {
captureCycleEvent({
eventName: "Cycle deleted",
eventName: CYCLE_DELETED,
payload: { ...cycle, state: "FAILED" },
});
});

View File

@ -10,7 +10,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { ICycle } from "@plane/types";
type Props = {
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
handleFormSubmit: (values: Partial<ICycle>, dirtyFields: any) => Promise<void>;
handleClose: () => void;
status: boolean;
projectId: string;
@ -29,7 +29,7 @@ export const CycleForm: React.FC<Props> = (props) => {
const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props;
// form data
const {
formState: { errors, isSubmitting },
formState: { errors, isSubmitting, dirtyFields },
handleSubmit,
control,
watch,
@ -61,7 +61,7 @@ export const CycleForm: React.FC<Props> = (props) => {
maxDate?.setDate(maxDate.getDate() - 1);
return (
<form onSubmit={handleSubmit(handleFormSubmit)}>
<form onSubmit={handleSubmit((formData)=>handleFormSubmit(formData,dirtyFields))}>
<div className="space-y-5">
<div className="flex items-center gap-x-3">
{!status && (

View File

@ -10,6 +10,8 @@ import useLocalStorage from "hooks/use-local-storage";
import { CycleForm } from "components/cycles";
// types
import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types";
// constants
import { CYCLE_CREATED, CYCLE_UPDATED } from "constants/event-tracker";
type CycleModalProps = {
isOpen: boolean;
@ -47,7 +49,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
message: "Cycle created successfully.",
});
captureCycleEvent({
eventName: "Cycle created",
eventName: CYCLE_CREATED,
payload: { ...res, state: "SUCCESS" },
});
})
@ -58,18 +60,23 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
message: err.detail ?? "Error in creating cycle. Please try again.",
});
captureCycleEvent({
eventName: "Cycle created",
eventName: CYCLE_CREATED,
payload: { ...payload, state: "FAILED" },
});
});
};
const handleUpdateCycle = async (cycleId: string, payload: Partial<ICycle>) => {
const handleUpdateCycle = async (cycleId: string, payload: Partial<ICycle>, dirtyFields: any) => {
if (!workspaceSlug || !projectId) return;
const selectedProjectId = payload.project ?? projectId.toString();
await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload)
.then(() => {
.then((res) => {
const changed_properties = Object.keys(dirtyFields);
captureCycleEvent({
eventName: CYCLE_UPDATED,
payload: { ...res, changed_properties: changed_properties, state: "SUCCESS" },
});
setToastAlert({
type: "success",
title: "Success!",
@ -77,6 +84,10 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
});
})
.catch((err) => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
payload: { ...payload, state: "FAILED" },
});
setToastAlert({
type: "error",
title: "Error!",
@ -95,7 +106,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
return status;
};
const handleFormSubmit = async (formData: Partial<ICycle>) => {
const handleFormSubmit = async (formData: Partial<ICycle>, dirtyFields: any) => {
if (!workspaceSlug || !projectId) return;
const payload: Partial<ICycle> = {
@ -119,7 +130,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
}
if (isDateValid) {
if (data) await handleUpdateCycle(data.id, payload);
if (data) await handleUpdateCycle(data.id, payload, dirtyFields);
else {
await handleCreateCycle(payload).then(() => {
setCycleTab("all");

View File

@ -39,6 +39,7 @@ import {
import { ICycle } from "@plane/types";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
import { CYCLE_UPDATED } from "constants/event-tracker";
// fetch-keys
import { CYCLE_STATUS } from "constants/cycle";
@ -67,7 +68,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query;
// store hooks
const { setTrackElement } = useEventTracker();
const { setTrackElement, captureCycleEvent } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
@ -83,10 +84,32 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
defaultValues,
});
const submitChanges = (data: Partial<ICycle>) => {
const submitChanges = (data: Partial<ICycle>, changedProperty: string) => {
if (!workspaceSlug || !projectId || !cycleId) return;
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data);
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data)
.then((res) => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
payload: {
...res,
changed_properties: [changedProperty],
element: "Right side-peek",
state: "SUCCESS",
},
});
})
.catch((_) => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
payload: {
...data,
element: "Right side-peek",
state: "FAILED",
},
});
});
};
const handleCopyText = () => {
@ -146,10 +169,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
});
if (isDateValidForExistingCycle) {
submitChanges({
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
});
submitChanges(
{
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"start_date"
);
setToastAlert({
type: "success",
title: "Success!",
@ -174,10 +200,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
});
if (isDateValid) {
submitChanges({
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
});
submitChanges(
{
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"start_date"
);
setToastAlert({
type: "success",
title: "Success!",
@ -219,10 +248,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
});
if (isDateValidForExistingCycle) {
submitChanges({
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
});
submitChanges(
{
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"end_date"
);
setToastAlert({
type: "success",
title: "Success!",
@ -246,10 +278,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
});
if (isDateValid) {
submitChanges({
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
});
submitChanges(
{
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"end_date"
);
setToastAlert({
type: "success",
title: "Success!",

View File

@ -2,7 +2,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { FileText, Plus } from "lucide-react";
// hooks
import { useApplication, useProject, useUser } from "hooks/store";
import { useApplication, useEventTracker, useProject, useUser } from "hooks/store";
// ui
import { Breadcrumbs, Button } from "@plane/ui";
// helpers
@ -25,6 +25,7 @@ export const PagesHeader = observer(() => {
membership: { currentProjectRole },
} = useUser();
const { currentProjectDetails } = useProject();
const { setTrackElement } = useEventTracker();
const canUserCreatePage =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
@ -64,7 +65,15 @@ export const PagesHeader = observer(() => {
</div>
{canUserCreatePage && (
<div className="flex items-center gap-2">
<Button variant="primary" prependIcon={<Plus />} size="sm" onClick={() => toggleCreatePageModal(true)}>
<Button
variant="primary"
prependIcon={<Plus />}
size="sm"
onClick={() => {
setTrackElement("Project pages page");
toggleCreatePageModal(true);
}}
>
Create Page
</Button>
</div>

View File

@ -4,18 +4,23 @@ import { useTheme } from "next-themes";
// images
import githubBlackImage from "/public/logos/github-black.png";
import githubWhiteImage from "/public/logos/github-white.png";
// hooks
import { useEventTracker } from "hooks/store";
// components
import { BreadcrumbLink } from "components/common";
import { Breadcrumbs } from "@plane/ui";
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
// constants
import { CHANGELOG_REDIRECTED, GITHUB_REDIRECTED } from "constants/event-tracker";
export const WorkspaceDashboardHeader = () => {
// hooks
const { captureEvent } = useEventTracker();
const { resolvedTheme } = useTheme();
return (
<>
<div className="relative z-[15] flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-neutral-border-medium bg-custom-sidebar-background-100 p-4">
<div className="relative z-[15] flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-neutral-border-medium bg-sidebar-neutral-component-surface-light p-4">
<div className="flex items-center gap-2 overflow-ellipsis whitespace-nowrap">
<SidebarHamburgerToggle />
<div>
@ -34,16 +39,26 @@ export const WorkspaceDashboardHeader = () => {
</div>
<div className="flex items-center gap-3 px-3">
<a
onClick={() =>
captureEvent(CHANGELOG_REDIRECTED, {
element: "navbar",
})
}
href="https://plane.so/changelog"
target="_blank"
rel="noopener noreferrer"
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-neutral-component-surface-dark px-3 py-1.5"
>
<Zap size={14} strokeWidth={2} fill="var(--color-neutral-120)" />
<span className="text-xs hidden sm:hidden md:block font-medium">{"What's new?"}</span>
<span className="hidden text-xs font-medium sm:hidden md:block">{"What's new?"}</span>
</a>
<a
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-neutral-component-surface-dark px-3 py-1.5 "
onClick={() =>
captureEvent(GITHUB_REDIRECTED, {
element: "navbar",
})
}
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-neutral-component-surface-dark px-3 py-1.5"
href="https://github.com/makeplane/plane"
target="_blank"
rel="noopener noreferrer"
@ -54,7 +69,7 @@ export const WorkspaceDashboardHeader = () => {
width={16}
alt="GitHub Logo"
/>
<span className="text-xs font-medium hidden sm:hidden md:block">Star us on GitHub</span>
<span className="hidden text-xs font-medium sm:hidden md:block">Star us on GitHub</span>
</a>
</div>
</div>

View File

@ -20,6 +20,7 @@ import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle
// types
import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types";
import { EUserProjectRoles } from "constants/project";
import { ISSUE_DELETED } from "constants/event-tracker";
type TInboxIssueActionsHeader = {
workspaceSlug: string;
@ -86,17 +87,12 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
throw new Error("Missing required parameters");
await removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId);
captureIssueEvent({
eventName: "Issue deleted",
eventName: ISSUE_DELETED,
payload: {
id: inboxIssueId,
state: "SUCCESS",
element: "Inbox page",
},
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
}
});
router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
@ -108,17 +104,12 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
message: "Something went wrong while deleting inbox issue. Please try again.",
});
captureIssueEvent({
eventName: "Issue deleted",
eventName: ISSUE_DELETED,
payload: {
id: inboxIssueId,
state: "FAILED",
element: "Inbox page",
},
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
});
}
},

View File

@ -18,6 +18,8 @@ import { GptAssistantPopover } from "components/core";
import { Button, Input, ToggleSwitch } from "@plane/ui";
// types
import { TIssue } from "@plane/types";
// constants
import { ISSUE_CREATED } from "constants/event-tracker";
type Props = {
isOpen: boolean;
@ -65,7 +67,6 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
config: { envConfig },
} = useApplication();
const { captureIssueEvent } = useEventTracker();
const { currentWorkspace } = useWorkspace();
const {
control,
@ -94,34 +95,24 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
handleClose();
} else reset(defaultValues);
captureIssueEvent({
eventName: "Issue created",
eventName: ISSUE_CREATED,
payload: {
...formData,
state: "SUCCESS",
element: "Inbox page",
},
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
path: router.pathname,
});
})
.catch((error) => {
console.error(error);
captureIssueEvent({
eventName: "Issue created",
eventName: ISSUE_CREATED,
payload: {
...formData,
state: "FAILED",
element: "Inbox page",
},
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
path: router.pathname,
});
});

View File

@ -38,7 +38,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
title: "Attachment uploaded",
});
captureIssueEvent({
eventName: "Issue updated",
eventName: "Issue attachment added",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "attachment",
@ -47,7 +47,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
});
} catch (error) {
captureIssueEvent({
eventName: "Issue updated",
eventName: "Issue attachment added",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
});
setToastAlert({
@ -67,7 +67,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
title: "Attachment removed",
});
captureIssueEvent({
eventName: "Issue updated",
eventName: "Issue attachment deleted",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "attachment",
@ -76,7 +76,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
});
} catch (error) {
captureIssueEvent({
eventName: "Issue updated",
eventName: "Issue attachment deleted",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "attachment",

View File

@ -16,6 +16,7 @@ import { TIssue } from "@plane/types";
// constants
import { EUserProjectRoles } from "constants/project";
import { EIssuesStoreType } from "constants/issue";
import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker";
export type TIssueOperations = {
fetch: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
@ -102,7 +103,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
});
}
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: Object.keys(data).join(","),
@ -112,7 +113,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
});
} catch (error) {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: Object.keys(data).join(","),
@ -138,7 +139,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
message: "Issue deleted successfully",
});
captureIssueEvent({
eventName: "Issue deleted",
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
path: router.asPath,
});
@ -149,7 +150,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
message: "Issue delete failed",
});
captureIssueEvent({
eventName: "Issue deleted",
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
path: router.asPath,
});
@ -164,7 +165,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
message: "Issue added to issue successfully",
});
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@ -174,7 +175,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
});
} catch (error) {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@ -198,7 +199,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
message: "Cycle removed from issue successfully",
});
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@ -208,7 +209,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
});
} catch (error) {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@ -232,7 +233,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
message: "Module added to issue successfully",
});
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "module_id",
@ -242,7 +243,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
});
} catch (error) {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "module_id",
@ -266,7 +267,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
message: "Module removed from issue successfully",
});
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "module_id",
@ -276,7 +277,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
});
} catch (error) {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "module_id",

View File

@ -13,6 +13,8 @@ import { createIssuePayload } from "helpers/issue.helper";
import { PlusIcon } from "lucide-react";
// types
import { TIssue } from "@plane/types";
// constants
import { ISSUE_CREATED } from "constants/event-tracker";
type Props = {
formKey: keyof TIssue;
@ -129,7 +131,7 @@ export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
viewId
).then((res) => {
captureIssueEvent({
eventName: "Issue created",
eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: "Calendar quick add" },
path: router.asPath,
});
@ -142,7 +144,7 @@ export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
} catch (err: any) {
console.error(err);
captureIssueEvent({
eventName: "Issue created",
eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED", element: "Calendar quick add" },
path: router.asPath,
});

View File

@ -2,7 +2,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import isEqual from "lodash/isEqual";
// hooks
import { useGlobalView, useIssues, useLabel, useUser } from "hooks/store";
import { useEventTracker, useGlobalView, useIssues, useLabel, useUser } from "hooks/store";
//ui
import { Button } from "@plane/ui";
// components
@ -11,6 +11,8 @@ import { AppliedFiltersList } from "components/issues";
import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types";
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "constants/workspace";
// constants
import { GLOBAL_VIEW_UPDATED } from "constants/event-tracker";
type Props = {
globalViewId: string;
@ -27,6 +29,7 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
} = useIssues(EIssuesStoreType.GLOBAL);
const { workspaceLabels } = useLabel();
const { globalViewMap, updateGlobalView } = useGlobalView();
const { captureEvent } = useEventTracker();
const {
membership: { currentWorkspaceRole },
} = useUser();
@ -91,6 +94,13 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
filters: {
...(appliedFilters ?? {}),
},
}).then((res) => {
captureEvent(GLOBAL_VIEW_UPDATED, {
view_id: res.id,
applied_filters: res.filters,
state: "SUCCESS",
element: "Spreadsheet view",
});
});
};

View File

@ -13,6 +13,8 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { createIssuePayload } from "helpers/issue.helper";
// types
import { IProject, TIssue } from "@plane/types";
// constants
import { ISSUE_CREATED } from "constants/event-tracker";
interface IInputProps {
formKey: string;
@ -111,7 +113,7 @@ export const GanttQuickAddIssueForm: React.FC<IGanttQuickAddIssueForm> = observe
quickAddCallback &&
(await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId).then((res) => {
captureIssueEvent({
eventName: "Issue created",
eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: "Gantt quick add" },
path: router.asPath,
});
@ -123,7 +125,7 @@ export const GanttQuickAddIssueForm: React.FC<IGanttQuickAddIssueForm> = observe
});
} catch (err: any) {
captureIssueEvent({
eventName: "Issue created",
eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED", element: "Gantt quick add" },
path: router.asPath,
});

View File

@ -25,6 +25,7 @@ import { IProfileIssues, IProfileIssuesFilter } from "store/issue/profile";
import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module";
import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views";
import { EIssueFilterType, TCreateModalStoreTypes } from "constants/issue";
import { ISSUE_DELETED } from "constants/event-tracker";
export interface IBaseKanBanLayout {
issues: IProjectIssues | ICycleIssues | IDraftIssues | IModuleIssues | IProjectViewIssues | IProfileIssues;
@ -212,7 +213,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
setDeleteIssueModal(false);
setDragState({});
captureIssueEvent({
eventName: "Issue deleted",
eventName: ISSUE_DELETED,
payload: { id: dragState.draggedIssueId!, state: "FAILED", element: "Kanban layout drag & drop" },
path: router.asPath,
});

View File

@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
import { createIssuePayload } from "helpers/issue.helper";
// types
import { TIssue } from "@plane/types";
// constants
import { ISSUE_CREATED } from "constants/event-tracker";
const Inputs = (props: any) => {
const { register, setFocus, projectDetail } = props;
@ -106,7 +108,7 @@ export const KanBanQuickAddIssueForm: React.FC<IKanBanQuickAddIssueForm> = obser
viewId
).then((res) => {
captureIssueEvent({
eventName: "Issue created",
eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: "Kanban quick add" },
path: router.asPath,
});
@ -118,7 +120,7 @@ export const KanBanQuickAddIssueForm: React.FC<IKanBanQuickAddIssueForm> = obser
});
} catch (err: any) {
captureIssueEvent({
eventName: "Issue created",
eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED", element: "Kanban quick add" },
path: router.asPath,
});

View File

@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
import { TIssue, IProject } from "@plane/types";
// types
import { createIssuePayload } from "helpers/issue.helper";
// constants
import { ISSUE_CREATED } from "constants/event-tracker";
interface IInputProps {
formKey: string;
@ -103,7 +105,7 @@ export const ListQuickAddIssueForm: FC<IListQuickAddIssueForm> = observer((props
quickAddCallback &&
(await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId).then((res) => {
captureIssueEvent({
eventName: "Issue created",
eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: "List quick add" },
path: router.asPath,
});
@ -115,7 +117,7 @@ export const ListQuickAddIssueForm: FC<IListQuickAddIssueForm> = observer((props
});
} catch (err: any) {
captureIssueEvent({
eventName: "Issue created",
eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED", element: "List quick add" },
path: router.asPath,
});

View File

@ -18,6 +18,8 @@ import {
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types";
// constants
import { ISSUE_UPDATED } from "constants/event-tracker";
export interface IIssueProperties {
issue: TIssue;
@ -40,7 +42,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const handleState = (stateId: string) => {
handleIssues({ ...issue, state_id: stateId }).then(() => {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath,
updates: {
@ -54,7 +56,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const handlePriority = (value: TIssuePriorities) => {
handleIssues({ ...issue, priority: value }).then(() => {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath,
updates: {
@ -68,7 +70,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const handleLabel = (ids: string[]) => {
handleIssues({ ...issue, label_ids: ids }).then(() => {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath,
updates: {
@ -82,7 +84,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const handleAssignee = (ids: string[]) => {
handleIssues({ ...issue, assignee_ids: ids }).then(() => {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath,
updates: {
@ -96,7 +98,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const handleStartDate = (date: Date | null) => {
handleIssues({ ...issue, start_date: date ? renderFormattedPayloadDate(date) : null }).then(() => {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath,
updates: {
@ -110,7 +112,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const handleTargetDate = (date: Date | null) => {
handleIssues({ ...issue, target_date: date ? renderFormattedPayloadDate(date) : null }).then(() => {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath,
updates: {
@ -124,7 +126,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const handleEstimate = (value: number | null) => {
handleIssues({ ...issue, estimate_point: value }).then(() => {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath,
updates: {

View File

@ -21,6 +21,7 @@ export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props)
<div className="h-11 border-b-[0.5px] border-neutral-border-medium">
<DateDropdown
value={issue.target_date}
minDate={issue.start_date ? new Date(issue.start_date) : undefined}
onChange={(data) => {
const targetDate = data ? renderFormattedPayloadDate(data) : null;
onChange(

View File

@ -21,6 +21,7 @@ export const SpreadsheetStartDateColumn: React.FC<Props> = observer((props: Prop
<div className="h-11 border-b-[0.5px] border-neutral-border-medium">
<DateDropdown
value={issue.start_date}
maxDate={issue.target_date ? new Date(issue.target_date) : undefined}
onChange={(data) => {
const startDate = data ? renderFormattedPayloadDate(data) : null;
onChange(

View File

@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
import { createIssuePayload } from "helpers/issue.helper";
// types
import { TIssue } from "@plane/types";
// constants
import { ISSUE_CREATED } from "constants/event-tracker";
type Props = {
formKey: keyof TIssue;
@ -162,7 +164,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC<Props> = observer((props) =>
(await quickAddCallback(currentWorkspace.slug, currentProjectDetails.id, { ...payload } as TIssue, viewId).then(
(res) => {
captureIssueEvent({
eventName: "Issue created",
eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: "Spreadsheet quick add" },
path: router.asPath,
});
@ -175,7 +177,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC<Props> = observer((props) =>
});
} catch (err: any) {
captureIssueEvent({
eventName: "Issue created",
eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED", element: "Spreadsheet quick add" },
path: router.asPath,
});

View File

@ -13,6 +13,8 @@ import { IssueFormRoot } from "./form";
import type { TIssue } from "@plane/types";
// constants
import { EIssuesStoreType, TCreateModalStoreTypes } from "constants/issue";
import { ISSUE_CREATED, ISSUE_UPDATED } from "constants/event-tracker";
export interface IssuesModalProps {
data?: Partial<TIssue>;
isOpen: boolean;
@ -157,14 +159,9 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
message: "Issue created successfully.",
});
captureIssueEvent({
eventName: "Issue created",
eventName: ISSUE_CREATED,
payload: { ...response, state: "SUCCESS" },
path: router.asPath,
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
});
!createMore && handleClose();
return response;
@ -175,14 +172,9 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
message: "Issue could not be created. Please try again.",
});
captureIssueEvent({
eventName: "Issue created",
eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED" },
path: router.asPath,
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
});
}
};
@ -198,14 +190,9 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
message: "Issue updated successfully.",
});
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS" },
path: router.asPath,
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
});
handleClose();
return response;
@ -216,14 +203,9 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
message: "Issue could not be created. Please try again.",
});
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...payload, state: "FAILED" },
path: router.asPath,
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
});
}
};

View File

@ -11,6 +11,7 @@ import { TIssue } from "@plane/types";
// constants
import { EUserProjectRoles } from "constants/project";
import { EIssuesStoreType } from "constants/issue";
import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker";
interface IIssuePeekOverview {
is_archived?: boolean;
@ -103,7 +104,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
message: "Issue updated successfully",
});
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" },
updates: {
changed_property: Object.keys(data).join(","),
@ -113,7 +114,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
});
} catch (error) {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue peek-overview" },
path: router.asPath,
});
@ -135,7 +136,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
message: "Issue deleted successfully",
});
captureIssueEvent({
eventName: "Issue deleted",
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" },
path: router.asPath,
});
@ -146,7 +147,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
message: "Issue delete failed",
});
captureIssueEvent({
eventName: "Issue deleted",
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" },
path: router.asPath,
});
@ -161,7 +162,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
message: "Issue added to issue successfully",
});
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" },
updates: {
changed_property: "cycle_id",
@ -171,7 +172,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
});
} catch (error) {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue peek-overview" },
updates: {
changed_property: "cycle_id",
@ -195,7 +196,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
message: "Cycle removed from issue successfully",
});
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" },
updates: {
changed_property: "cycle_id",
@ -210,7 +211,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
message: "Cycle remove from issue failed",
});
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue peek-overview" },
updates: {
changed_property: "cycle_id",
@ -229,7 +230,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
message: "Module added to issue successfully",
});
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" },
updates: {
changed_property: "module_id",
@ -239,7 +240,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
});
} catch (error) {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" },
updates: {
changed_property: "module_id",
@ -263,7 +264,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
message: "Module removed from issue successfully",
});
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" },
updates: {
changed_property: "module_id",
@ -273,7 +274,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
});
} catch (error) {
captureIssueEvent({
eventName: "Issue updated",
eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" },
updates: {
changed_property: "module_id",

View File

@ -11,6 +11,8 @@ import { Button } from "@plane/ui";
import { AlertTriangle } from "lucide-react";
// types
import type { IModule } from "@plane/types";
// constants
import { MODULE_DELETED } from "constants/event-tracker";
type Props = {
data: IModule;
@ -51,7 +53,7 @@ export const DeleteModuleModal: React.FC<Props> = observer((props) => {
message: "Module deleted successfully.",
});
captureModuleEvent({
eventName: "Module deleted",
eventName: MODULE_DELETED,
payload: { ...data, state: "SUCCESS" },
});
})
@ -62,7 +64,7 @@ export const DeleteModuleModal: React.FC<Props> = observer((props) => {
message: "Module could not be deleted. Please try again.",
});
captureModuleEvent({
eventName: "Module deleted",
eventName: MODULE_DELETED,
payload: { ...data, state: "FAILED" },
});
})

View File

@ -11,7 +11,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { IModule } from "@plane/types";
type Props = {
handleFormSubmit: (values: Partial<IModule>) => Promise<void>;
handleFormSubmit: (values: Partial<IModule>, dirtyFields: any) => Promise<void>;
handleClose: () => void;
status: boolean;
projectId: string;
@ -36,7 +36,7 @@ export const ModuleForm: React.FC<Props> = ({
data,
}) => {
const {
formState: { errors, isSubmitting },
formState: { errors, isSubmitting, dirtyFields },
handleSubmit,
watch,
control,
@ -53,7 +53,7 @@ export const ModuleForm: React.FC<Props> = ({
});
const handleCreateUpdateModule = async (formData: Partial<IModule>) => {
await handleFormSubmit(formData);
await handleFormSubmit(formData, dirtyFields);
reset({
...defaultValues,

View File

@ -9,6 +9,8 @@ import useToast from "hooks/use-toast";
import { ModuleForm } from "components/modules";
// types
import type { IModule } from "@plane/types";
// constants
import { MODULE_CREATED, MODULE_UPDATED } from "constants/event-tracker";
type Props = {
isOpen: boolean;
@ -59,7 +61,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
message: "Module created successfully.",
});
captureModuleEvent({
eventName: "Module created",
eventName: MODULE_CREATED,
payload: { ...res, state: "SUCCESS" },
});
})
@ -70,13 +72,13 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
message: err.detail ?? "Module could not be created. Please try again.",
});
captureModuleEvent({
eventName: "Module created",
eventName: MODULE_CREATED,
payload: { ...data, state: "FAILED" },
});
});
};
const handleUpdateModule = async (payload: Partial<IModule>) => {
const handleUpdateModule = async (payload: Partial<IModule>, dirtyFields: any) => {
if (!workspaceSlug || !projectId || !data) return;
const selectedProjectId = payload.project ?? projectId.toString();
@ -90,8 +92,8 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
message: "Module updated successfully.",
});
captureModuleEvent({
eventName: "Module updated",
payload: { ...res, state: "SUCCESS" },
eventName: MODULE_UPDATED,
payload: { ...res, changed_properties: Object.keys(dirtyFields), state: "SUCCESS" },
});
})
.catch((err) => {
@ -101,20 +103,20 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
message: err.detail ?? "Module could not be updated. Please try again.",
});
captureModuleEvent({
eventName: "Module updated",
eventName: MODULE_UPDATED,
payload: { ...data, state: "FAILED" },
});
});
};
const handleFormSubmit = async (formData: Partial<IModule>) => {
const handleFormSubmit = async (formData: Partial<IModule>, dirtyFields: any) => {
if (!workspaceSlug || !projectId) return;
const payload: Partial<IModule> = {
...formData,
};
if (!data) await handleCreateModule(payload);
else await handleUpdateModule(payload);
else await handleUpdateModule(payload, dirtyFields);
};
useEffect(() => {

View File

@ -16,6 +16,7 @@ import { renderFormattedDate } from "helpers/date-time.helper";
// constants
import { MODULE_STATUS } from "constants/module";
import { EUserProjectRoles } from "constants/project";
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "constants/event-tracker";
type Props = {
moduleId: string;
@ -36,7 +37,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
membership: { currentProjectRole },
} = useUser();
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { setTrackElement } = useEventTracker();
const { setTrackElement, captureEvent } = useEventTracker();
// derived values
const moduleDetails = getModuleById(moduleId);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
@ -46,13 +47,21 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the module to favorites. Please try again.",
addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId)
.then(() => {
captureEvent(MODULE_FAVORITED, {
module_id: moduleId,
element: "Grid layout",
state: "SUCCESS",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the module to favorites. Please try again.",
});
});
});
};
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
@ -60,13 +69,21 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the module from favorites. Please try again.",
removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId)
.then(() => {
captureEvent(MODULE_UNFAVORITED, {
module_id: moduleId,
element: "Grid layout",
state: "SUCCESS",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the module from favorites. Please try again.",
});
});
});
};
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
@ -84,14 +101,14 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
const handleEditModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page board layout");
setTrackElement("Modules page grid layout");
setEditModal(true);
};
const handleDeleteModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page board layout");
setTrackElement("Modules page grid layout");
setDeleteModal(true);
};

View File

@ -16,6 +16,7 @@ import { renderFormattedDate } from "helpers/date-time.helper";
// constants
import { MODULE_STATUS } from "constants/module";
import { EUserProjectRoles } from "constants/project";
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "constants/event-tracker";
type Props = {
moduleId: string;
@ -36,7 +37,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
membership: { currentProjectRole },
} = useUser();
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { setTrackElement } = useEventTracker();
const { setTrackElement, captureEvent } = useEventTracker();
// derived values
const moduleDetails = getModuleById(moduleId);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
@ -46,13 +47,21 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the module to favorites. Please try again.",
addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId)
.then(() => {
captureEvent(MODULE_FAVORITED, {
module_id: moduleId,
element: "Grid layout",
state: "SUCCESS",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the module to favorites. Please try again.",
});
});
});
};
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
@ -60,13 +69,21 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the module from favorites. Please try again.",
removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId)
.then(() => {
captureEvent(MODULE_UNFAVORITED, {
module_id: moduleId,
element: "Grid layout",
state: "SUCCESS",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the module from favorites. Please try again.",
});
});
});
};
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {

View File

@ -34,6 +34,7 @@ import { ILinkDetails, IModule, ModuleLink } from "@plane/types";
// constant
import { MODULE_STATUS } from "constants/module";
import { EUserProjectRoles } from "constants/project";
import { MODULE_LINK_CREATED, MODULE_LINK_DELETED, MODULE_LINK_UPDATED, MODULE_UPDATED } from "constants/event-tracker";
const defaultValues: Partial<IModule> = {
lead: "",
@ -66,7 +67,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
membership: { currentProjectRole },
} = useUser();
const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink } = useModule();
const { setTrackElement } = useEventTracker();
const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker();
const moduleDetails = getModuleById(moduleId);
const { setToastAlert } = useToast();
@ -77,7 +78,19 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
const submitChanges = (data: Partial<IModule>) => {
if (!workspaceSlug || !projectId || !moduleId) return;
updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data);
updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data)
.then((res) => {
captureModuleEvent({
eventName: MODULE_UPDATED,
payload: { ...res, changed_properties: Object.keys(data)[0], element: "Right side-peek", state: "SUCCESS" },
});
})
.catch((_) => {
captureModuleEvent({
eventName: MODULE_UPDATED,
payload: { ...data, state: "FAILED" },
});
});
};
const handleCreateLink = async (formData: ModuleLink) => {
@ -87,6 +100,10 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload)
.then(() => {
captureEvent(MODULE_LINK_CREATED, {
module_id: moduleId,
state: "SUCCESS",
});
setToastAlert({
type: "success",
title: "Module link created",
@ -109,6 +126,10 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload)
.then(() => {
captureEvent(MODULE_LINK_UPDATED, {
module_id: moduleId,
state: "SUCCESS",
});
setToastAlert({
type: "success",
title: "Module link updated",
@ -129,6 +150,10 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
deleteModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId)
.then(() => {
captureEvent(MODULE_LINK_DELETED, {
module_id: moduleId,
state: "SUCCESS",
});
setToastAlert({
type: "success",
title: "Module link deleted",
@ -187,8 +212,8 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
if (watch("start_date") && watch("target_date") && watch("start_date") !== "" && watch("start_date") !== "") {
submitChanges({
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
target_date: renderFormattedPayloadDate(`${watch("target_date")}`),
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
});
setToastAlert({
type: "success",
@ -294,7 +319,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<Controller
control={control}
name="status"
render={({ field: { value } }) => (
render={({ field: { value, onChange } }) => (
<CustomSelect
customButton={
<span

View File

@ -1,10 +1,12 @@
import React from "react";
import React, { useEffect, useRef } from "react";
import Image from "next/image";
import { useRouter } from "next/router";
import { ArchiveRestore, Clock, MessageSquare, User2 } from "lucide-react";
import Link from "next/link";
import { Menu } from "@headlessui/react";
import { ArchiveRestore, Clock, MessageSquare, MoreVertical, User2 } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store";
// icons
import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui";
// constants
@ -13,9 +15,12 @@ import { snoozeOptions } from "constants/notification";
import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "helpers/string.helper";
import { calculateTimeAgo, renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper";
// type
import type { IUserNotification } from "@plane/types";
import type { IUserNotification, NotificationType } from "@plane/types";
// constants
import { ISSUE_OPENED, NOTIFICATIONS_READ, NOTIFICATION_ARCHIVED, NOTIFICATION_SNOOZED } from "constants/event-tracker";
type NotificationCardProps = {
selectedTab: NotificationType;
notification: IUserNotification;
isSnoozedTabOpen: boolean;
closePopover: () => void;
@ -28,6 +33,7 @@ type NotificationCardProps = {
export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
const {
selectedTab,
notification,
isSnoozedTabOpen,
closePopover,
@ -37,11 +43,78 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
setSelectedNotificationForSnooze,
markSnoozeNotification,
} = props;
// store hooks
const { captureEvent } = useEventTracker();
const router = useRouter();
const { workspaceSlug } = router.query;
// states
const [showSnoozeOptions, setshowSnoozeOptions] = React.useState(false);
// toast alert
const { setToastAlert } = useToast();
// refs
const snoozeRef = useRef<HTMLDivElement | null>(null);
const moreOptions = [
{
id: 1,
name: notification.read_at ? "Mark as unread" : "Mark as read",
icon: <MessageSquare className="h-3.5 w-3.5 text-custom-text-300" />,
onClick: () => {
markNotificationReadStatusToggle(notification.id).then(() => {
setToastAlert({
title: notification.read_at ? "Notification marked as read" : "Notification marked as unread",
type: "success",
});
});
},
},
{
id: 2,
name: notification.archived_at ? "Unarchive" : "Archive",
icon: notification.archived_at ? (
<ArchiveRestore className="h-3.5 w-3.5 text-custom-text-300" />
) : (
<ArchiveIcon className="h-3.5 w-3.5 text-custom-text-300" />
),
onClick: () => {
markNotificationArchivedStatus(notification.id).then(() => {
setToastAlert({
title: notification.archived_at ? "Notification un-archived" : "Notification archived",
type: "success",
});
});
},
},
];
const snoozeOptionOnClick = (date: Date | null) => {
if (!date) {
setSelectedNotificationForSnooze(notification.id);
return;
}
markSnoozeNotification(notification.id, date).then(() => {
setToastAlert({
title: `Notification snoozed till ${renderFormattedDate(date)}`,
type: "success",
});
});
};
// close snooze options on outside click
useEffect(() => {
const handleClickOutside = (event: any) => {
if (snoozeRef.current && !snoozeRef.current?.contains(event.target)) {
setshowSnoozeOptions(false);
}
};
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("touchend", handleClickOutside, true);
return () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("touchend", handleClickOutside, true);
};
}, []);
if (isSnoozedTabOpen && new Date(notification.snoozed_till!) < new Date()) return null;
@ -49,6 +122,10 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
<Link
onClick={() => {
markNotificationReadStatus(notification.id);
captureEvent(ISSUE_OPENED, {
issue_id: notification.data.issue.id,
element: "notification",
});
closePopover();
}}
href={`/${workspaceSlug}/projects/${notification.project}/${
@ -87,57 +164,136 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
)}
</div>
<div className="w-full space-y-2.5 overflow-hidden">
{!notification.message ? (
<div className="w-full break-words text-sm">
<span className="font-semibold">
{notification.triggered_by_details.is_bot
? notification.triggered_by_details.first_name
: notification.triggered_by_details.display_name}{" "}
</span>
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.verb}{" "}
{notification.data.issue_activity.field === "comment"
? "commented"
: notification.data.issue_activity.field === "None"
? null
: replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "}
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.field !== "None"
? "to"
: ""}
<span className="font-semibold">
{" "}
{notification.data.issue_activity.field !== "None" ? (
notification.data.issue_activity.field !== "comment" ? (
notification.data.issue_activity.field === "target_date" ? (
renderFormattedDate(notification.data.issue_activity.new_value)
) : notification.data.issue_activity.field === "attachment" ? (
"the issue"
) : notification.data.issue_activity.field === "description" ? (
stripAndTruncateHTML(notification.data.issue_activity.new_value, 55)
<div className="flex items-start">
{!notification.message ? (
<div className="w-full break-words text-sm">
<span className="font-semibold">
{notification.triggered_by_details.is_bot
? notification.triggered_by_details.first_name
: notification.triggered_by_details.display_name}{" "}
</span>
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.verb}{" "}
{notification.data.issue_activity.field === "comment"
? "commented"
: notification.data.issue_activity.field === "None"
? null
: replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "}
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.field !== "None"
? "to"
: ""}
<span className="font-semibold">
{" "}
{notification.data.issue_activity.field !== "None" ? (
notification.data.issue_activity.field !== "comment" ? (
notification.data.issue_activity.field === "target_date" ? (
renderFormattedDate(notification.data.issue_activity.new_value)
) : notification.data.issue_activity.field === "attachment" ? (
"the issue"
) : notification.data.issue_activity.field === "description" ? (
stripAndTruncateHTML(notification.data.issue_activity.new_value, 55)
) : (
notification.data.issue_activity.new_value
)
) : (
notification.data.issue_activity.new_value
<span>
{`"`}
{notification.data.issue_activity.new_value.length > 55
? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..."
: notification.data.issue_activity.issue_comment}
{`"`}
</span>
)
) : (
<span>
{`"`}
{notification.data.issue_activity.new_value.length > 55
? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..."
: notification.data.issue_activity.issue_comment}
{`"`}
</span>
)
) : (
"the issue and assigned it to you."
"the issue and assigned it to you."
)}
</span>
</div>
) : (
<div className="w-full break-words text-sm">
<span className="semi-bold">{notification.message}</span>
</div>
)}
<div className="flex md:hidden items-start">
<Menu as="div" className={" w-min text-left"}>
{({ open }) => (
<>
<Menu.Button as={React.Fragment}>
<button
onClick={(e) => e.stopPropagation()}
className="flex w-full items-center gap-x-2 rounded p-0.5 text-sm"
>
<MoreVertical className="h-3.5 w-3.5 text-custom-text-300" />
</button>
</Menu.Button>
{open && (
<Menu.Items className={"absolute right-0 z-10"} static>
<div
className={
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap"
}
>
{moreOptions.map((item) => (
<Menu.Item as="div">
{({ close }) => (
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
item.onClick();
close();
}}
className="flex gap-x-2 items-center p-1.5"
>
{item.icon}
{item.name}
</button>
)}
</Menu.Item>
))}
<Menu.Item as="div">
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setshowSnoozeOptions(true);
}}
className="flex gap-x-2 items-center p-1.5"
>
<Clock className="h-3.5 w-3.5 text-custom-text-300" />
Snooze
</div>
</Menu.Item>
</div>
</Menu.Items>
)}
</>
)}
</span>
</Menu>
{showSnoozeOptions && (
<div
ref={snoozeRef}
className="absolute right-36 z-20 my-1 top-24 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap"
>
{snoozeOptions.map((item) => (
<p
className="p-1.5"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setshowSnoozeOptions(false);
snoozeOptionOnClick(item.value);
}}
>
{item.label}
</p>
))}
</div>
)}
</div>
) : (
<div className="w-full break-words text-sm">
<span className="semi-bold">{notification.message}</span>
</div>
)}
</div>
<div className="flex justify-between gap-2 text-xs">
<p className="text-neutral-text-medium">
<p className="text-neutral-text-medium line-clamp-1">
{truncateText(
`${notification.data.issue.identifier}-${notification.data.issue.sequence_id} ${notification.data.issue.name}`,
50
@ -152,7 +308,9 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
</span>
</p>
) : (
<p className="flex-shrink-0 text-neutral-text-medium">{calculateTimeAgo(notification.created_at)}</p>
<p className="flex-shrink-0 text-neutral-text-medium mt-auto">
{calculateTimeAgo(notification.created_at)}
</p>
)}
</div>
</div>
@ -164,6 +322,11 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
icon: <MessageSquare className="h-3.5 w-3.5 text-neutral-text-medium" />,
onClick: () => {
markNotificationReadStatusToggle(notification.id).then(() => {
captureEvent(NOTIFICATIONS_READ, {
issue_id: notification.data.issue.id,
tab: selectedTab,
state: "SUCCESS",
});
setToastAlert({
title: notification.read_at ? "Notification marked as read" : "Notification marked as unread",
type: "success",
@ -181,6 +344,11 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
),
onClick: () => {
markNotificationArchivedStatus(notification.id).then(() => {
captureEvent(NOTIFICATION_ARCHIVED, {
issue_id: notification.data.issue.id,
tab: selectedTab,
state: "SUCCESS",
});
setToastAlert({
title: notification.archived_at ? "Notification un-archived" : "Notification archived",
type: "success",
@ -195,7 +363,6 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
item.onClick();
}}
key={item.id}
@ -228,6 +395,11 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
}
markSnoozeNotification(notification.id, item.value).then(() => {
captureEvent(NOTIFICATION_SNOOZED, {
issue_id: notification.data.issue.id,
tab: selectedTab,
state: "SUCCESS",
});
setToastAlert({
title: `Notification snoozed till ${renderFormattedDate(item.value)}`,
type: "success",

View File

@ -1,11 +1,22 @@
import React from "react";
import { ArrowLeft, CheckCheck, Clock, ListFilter, MoreVertical, RefreshCw, X } from "lucide-react";
// components
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
// ui
import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui";
// hooks
import { useEventTracker } from "hooks/store";
// helpers
import { getNumberCount } from "helpers/string.helper";
// type
import type { NotificationType, NotificationCount } from "@plane/types";
// constants
import {
ARCHIVED_NOTIFICATIONS,
NOTIFICATIONS_READ,
SNOOZED_NOTIFICATIONS,
UNREAD_NOTIFICATIONS,
} from "constants/event-tracker";
type NotificationHeaderProps = {
notificationCount?: NotificationCount | null;
@ -39,6 +50,8 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
setSelectedTab,
markAllNotificationsAsRead,
} = props;
// store hooks
const { captureEvent } = useEventTracker();
const notificationTabs: Array<{
label: string;
@ -65,7 +78,11 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
return (
<>
<div className="flex items-center justify-between px-5 pt-5">
<h2 className="mb-2 text-xl font-semibold">Notifications</h2>
<div className="flex items-center gap-x-2 ">
<SidebarHamburgerToggle />
<h2 className="md:text-xl md:font-semibold">Notifications</h2>
</div>
<div className="flex items-center justify-center gap-x-4 text-neutral-text-medium">
<Tooltip tooltipContent="Refresh">
<button
@ -84,6 +101,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
setSnoozed(false);
setArchived(false);
setReadNotification((prev) => !prev);
captureEvent(UNREAD_NOTIFICATIONS);
}}
>
<ListFilter className="h-3.5 w-3.5" />
@ -97,7 +115,12 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
}
closeOnSelect
>
<CustomMenu.MenuItem onClick={markAllNotificationsAsRead}>
<CustomMenu.MenuItem
onClick={() => {
markAllNotificationsAsRead();
captureEvent(NOTIFICATIONS_READ);
}}
>
<div className="flex items-center gap-2">
<CheckCheck className="h-3.5 w-3.5" />
Mark all as read
@ -108,6 +131,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
setArchived(false);
setReadNotification(false);
setSnoozed((prev) => !prev);
captureEvent(SNOOZED_NOTIFICATIONS);
}}
>
<div className="flex items-center gap-2">
@ -120,6 +144,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
setSnoozed(false);
setReadNotification(false);
setArchived((prev) => !prev);
captureEvent(ARCHIVED_NOTIFICATIONS);
}}
>
<div className="flex items-center gap-2">
@ -128,11 +153,13 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
<Tooltip tooltipContent="Close">
<button type="button" onClick={() => closePopover()}>
<X className="h-3.5 w-3.5" />
</button>
</Tooltip>
<div className="hidden md:block">
<Tooltip tooltipContent="Close">
<button type="button" onClick={() => closePopover()}>
<X className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
</div>
</div>
<div className="mt-5 w-full border-b border-neutral-border-medium px-5">
@ -165,7 +192,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
onClick={() => setSelectedTab(tab.value)}
className={`whitespace-nowrap border-b-2 px-1 pb-4 text-sm font-medium outline-none ${
tab.value === selectedTab
? "border-custom-primary-100 text-primary-text-subtle"
? "border-primary-border-subtle text-primary-text-subtle"
: "border-transparent text-neutral-text-medium"
}`}
>
@ -174,7 +201,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
<span
className={`ml-2 rounded-full px-2 py-0.5 text-xs ${
tab.value === selectedTab
? "bg-custom-primary-100 text-white"
? "bg-primary-solid text-white"
: "bg-neutral-component-surface-dark text-neutral-text-medium"
}`}
>

View File

@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite";
// hooks
import { useApplication } from "hooks/store";
import useUserNotification from "hooks/use-user-notifications";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { EmptyState } from "components/common";
import { SnoozeNotificationModal, NotificationCard, NotificationHeader } from "components/notifications";
@ -16,8 +17,12 @@ import { getNumberCount } from "helpers/string.helper";
import { cn } from "helpers/common.helper";
export const NotificationPopover = observer(() => {
// states
const [isActive, setIsActive] = React.useState(false);
// store hooks
const { theme: themeStore } = useApplication();
// refs
const notificationPopoverRef = React.useRef<HTMLDivElement | null>(null);
const {
notifications,
@ -45,8 +50,11 @@ export const NotificationPopover = observer(() => {
setFetchNotifications,
markAllNotificationsAsRead,
} = useUserNotification();
const isSidebarCollapsed = themeStore.sidebarCollapsed;
useOutsideClickDetector(notificationPopoverRef, () => {
// if snooze modal is open, then don't close the popover
if (selectedNotificationForSnooze === null) setIsActive(false);
});
return (
<>
@ -55,144 +63,143 @@ export const NotificationPopover = observer(() => {
onClose={() => setSelectedNotificationForSnooze(null)}
onSubmit={markSnoozeNotification}
notification={notifications?.find((notification) => notification.id === selectedNotificationForSnooze) || null}
onSuccess={() => {
setSelectedNotificationForSnooze(null);
}}
onSuccess={() => setSelectedNotificationForSnooze(null)}
/>
<Popover className="relative w-full">
{({ open: isActive, close: closePopover }) => {
if (isActive) setFetchNotifications(true);
<Popover ref={notificationPopoverRef} className="md:relative w-full">
<>
<Tooltip tooltipContent="Notifications" position="right" className="ml-2" disabled={!isSidebarCollapsed}>
<button
className={`group relative flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
isActive
? "bg-primary-component-surface-light text-primary-text-subtle"
: "text-sidebar-neutral-text-medium hover:bg-sidebar-neutral-component-surface-dark"
} ${isSidebarCollapsed ? "justify-center" : ""}`}
onClick={() => {
if (window.innerWidth < 768) themeStore.toggleSidebar();
if (!isActive) setFetchNotifications(true);
setIsActive(!isActive);
}}
>
<Bell className="h-4 w-4" />
{isSidebarCollapsed ? null : <span>Notifications</span>}
{totalNotificationCount && totalNotificationCount > 0 ? (
isSidebarCollapsed ? (
<span className="absolute right-3.5 top-2 h-2 w-2 rounded-full bg-primary-solid" />
) : (
<span className="ml-auto rounded-full bg-primary-solid px-1.5 text-xs text-white">
{getNumberCount(totalNotificationCount)}
</span>
)
) : null}
</button>
</Tooltip>
<Transition
show={isActive}
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel
className="absolute top-0 left-[280px] md:-top-36 md:ml-8 md:h-[50vh] z-10 flex h-full w-[100vw] flex-col rounded-xl md:border border-neutral-border-medium bg-neutral-component-surface-light shadow-lg md:left-full md:w-[36rem]"
static
>
<NotificationHeader
notificationCount={notificationCount}
notificationMutate={notificationMutate}
closePopover={() => setIsActive(false)}
isRefreshing={isRefreshing}
snoozed={snoozed}
archived={archived}
readNotification={readNotification}
selectedTab={selectedTab}
setSnoozed={setSnoozed}
setArchived={setArchived}
setReadNotification={setReadNotification}
setSelectedTab={setSelectedTab}
markAllNotificationsAsRead={markAllNotificationsAsRead}
/>
return (
<>
<Tooltip tooltipContent="Notifications" position="right" className="ml-2" disabled={!isSidebarCollapsed}>
<Popover.Button
className={cn(
"group relative flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none",
{
"bg-primary-component-surface-light text-primary-text-subtle": isActive,
"text-sidebar-neutral-text-medium hover:bg-sidebar-neutral-component-surface-dark": !isActive,
"justify-center": isSidebarCollapsed,
}
)}
>
<Bell className="h-4 w-4" />
{isSidebarCollapsed ? null : <span>Notifications</span>}
{totalNotificationCount && totalNotificationCount > 0 ? (
isSidebarCollapsed ? (
<span className="absolute right-3.5 top-2 h-2 w-2 rounded-full bg-primary-component-surface-medium" />
) : (
<span className="ml-auto rounded-full bg-primary-component-surface-medium px-1.5 text-xs text-white">
{getNumberCount(totalNotificationCount)}
</span>
)
) : null}
</Popover.Button>
</Tooltip>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute -top-36 left-0 z-10 ml-8 flex h-[50vh] w-[20rem] flex-col rounded-xl border border-neutral-border-medium bg-neutral-component-surface-light shadow-lg md:left-full md:w-[36rem]">
<NotificationHeader
notificationCount={notificationCount}
notificationMutate={notificationMutate}
closePopover={closePopover}
isRefreshing={isRefreshing}
snoozed={snoozed}
archived={archived}
readNotification={readNotification}
selectedTab={selectedTab}
setSnoozed={setSnoozed}
setArchived={setArchived}
setReadNotification={setReadNotification}
setSelectedTab={setSelectedTab}
markAllNotificationsAsRead={markAllNotificationsAsRead}
/>
{notifications ? (
notifications.length > 0 ? (
<div className="h-full overflow-y-auto">
<div className="divide-y divide-neutral-border-subtle">
{notifications.map((notification) => (
<NotificationCard
key={notification.id}
isSnoozedTabOpen={snoozed}
closePopover={closePopover}
notification={notification}
markNotificationArchivedStatus={markNotificationArchivedStatus}
markNotificationReadStatus={markNotificationAsRead}
markNotificationReadStatusToggle={markNotificationReadStatus}
setSelectedNotificationForSnooze={setSelectedNotificationForSnooze}
markSnoozeNotification={markSnoozeNotification}
/>
))}
</div>
{isLoadingMore && (
<div className="my-6 flex items-center justify-center text-sm">
<div role="status">
<svg
aria-hidden="true"
className="mr-2 h-6 w-6 animate-spin fill-blue-600 text-neutral-text-medium"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
<p>Loading notifications</p>
</div>
)}
{hasMore && !isLoadingMore && (
<button
type="button"
className="my-6 flex w-full items-center justify-center text-sm font-medium text-primary-text-subtle"
disabled={isLoadingMore}
onClick={() => {
setSize((prev) => prev + 1);
}}
>
Load More
</button>
)}
</div>
) : (
<div className="grid h-full w-full scale-75 place-items-center overflow-hidden">
<EmptyState
title="You're updated with all the notifications"
description="You have read all the notifications."
image={emptyNotification}
{notifications ? (
notifications.length > 0 ? (
<div className="h-full overflow-y-auto">
<div className="divide-y divide-neutral-border-subtle">
{notifications.map((notification) => (
<NotificationCard
selectedTab={selectedTab}
key={notification.id}
isSnoozedTabOpen={snoozed}
closePopover={() => setIsActive(false)}
notification={notification}
markNotificationArchivedStatus={markNotificationArchivedStatus}
markNotificationReadStatus={markNotificationAsRead}
markNotificationReadStatusToggle={markNotificationReadStatus}
setSelectedNotificationForSnooze={setSelectedNotificationForSnooze}
markSnoozeNotification={markSnoozeNotification}
/>
))}
</div>
{isLoadingMore && (
<div className="my-6 flex items-center justify-center text-sm">
<div role="status">
<svg
aria-hidden="true"
className="mr-2 h-6 w-6 animate-spin fill-blue-600 text-neutral-text-medium"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
<p>Loading notifications</p>
</div>
)
) : (
<Loader className="space-y-4 overflow-y-auto p-5">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</Popover.Panel>
</Transition>
</>
);
}}
)}
{hasMore && !isLoadingMore && (
<button
type="button"
className="my-6 flex w-full items-center justify-center text-sm font-medium text-primary-text-subtle"
disabled={isLoadingMore}
onClick={() => {
setSize((prev) => prev + 1);
}}
>
Load More
</button>
)}
</div>
) : (
<div className="grid h-full w-full scale-75 place-items-center overflow-hidden">
<EmptyState
title="You're updated with all the notifications"
description="You have read all the notifications."
image={emptyNotification}
/>
</div>
)
) : (
<Loader className="space-y-4 overflow-y-auto p-5">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</Popover.Panel>
</Transition>
</>
</Popover>
</>
);

View File

@ -109,7 +109,12 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
};
const handleClose = () => {
onClose();
// This is a workaround to fix the issue of the Notification popover modal close on closing this modal
const closeTimeout = setTimeout(() => {
onClose();
clearTimeout(closeTimeout);
}, 50);
const timeout = setTimeout(() => {
reset({ ...defaultValues });
clearTimeout(timeout);
@ -142,7 +147,7 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-neutral-component-surface-light p-5 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-2xl">
<Dialog.Panel className="relative transform rounded-lg bg-neutral-component-surface-light p-5 text-left shadow-custom-shadow-md transition-all w-full sm:w-full sm:!max-w-2xl">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex items-center justify-between">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-neutral-text-strong">
@ -156,8 +161,8 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
</div>
</div>
<div className="mt-5 flex items-center gap-3">
<div className="flex-1">
<div className="mt-5 flex flex-col md:!flex-row md:items-center gap-3">
<div className="flex-1 pb-3 md:pb-0">
<h6 className="mb-2 block text-sm font-medium text-neutral-text-subtle">Pick a date</h6>
<Controller
name="date"

View File

@ -11,11 +11,13 @@ import { WorkspaceService } from "services/workspace.service";
// constants
import { USER_WORKSPACES, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
import { ROLE } from "constants/workspace";
import { MEMBER_ACCEPTED } from "constants/event-tracker";
// types
import { IWorkspaceMemberInvitation } from "@plane/types";
// icons
import { CheckCircle2, Search } from "lucide-react";
import {} from "hooks/store/use-event-tracker";
import { getUserRole } from "helpers/user.helper";
type Props = {
handleNextStep: () => void;
@ -58,11 +60,19 @@ export const Invitations: React.FC<Props> = (props) => {
if (invitationsRespond.length <= 0) return;
setIsJoiningWorkspaces(true);
const invitation = invitations?.find((invitation) => invitation.id === invitationsRespond[0]);
await workspaceService
.joinWorkspaces({ invitations: invitationsRespond })
.then(async (res) => {
captureEvent("Member accepted", { ...res, state: "SUCCESS", accepted_from: "App" });
.then(async () => {
captureEvent(MEMBER_ACCEPTED, {
member_id: invitation?.id,
role: getUserRole(invitation?.role!),
project_id: undefined,
accepted_from: "App",
state: "SUCCESS",
element: "Workspace invitations page",
});
await fetchWorkspaces();
await mutate(USER_WORKSPACES);
await updateLastWorkspace();
@ -71,7 +81,14 @@ export const Invitations: React.FC<Props> = (props) => {
})
.catch((error) => {
console.error(error);
captureEvent("Member accepted", { state: "FAILED", accepted_from: "App" });
captureEvent(MEMBER_ACCEPTED, {
member_id: invitation?.id,
role: getUserRole(invitation?.role!),
project_id: undefined,
accepted_from: "App",
state: "FAILED",
element: "Workspace invitations page",
});
})
.finally(() => setIsJoiningWorkspaces(false));
};

View File

@ -18,6 +18,7 @@ import { Check, ChevronDown, Plus, XCircle } from "lucide-react";
import { WorkspaceService } from "services/workspace.service";
// hooks
import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store";
// ui
import { Button, Input } from "@plane/ui";
// components
@ -28,6 +29,9 @@ import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown";
import { IUser, IWorkspace, TOnboardingSteps } from "@plane/types";
// constants
import { EUserWorkspaceRoles, ROLE } from "constants/workspace";
import { MEMBER_INVITED } from "constants/event-tracker";
// helpers
import { getUserRole } from "helpers/user.helper";
// assets
import user1 from "public/users/user-1.png";
import user2 from "public/users/user-2.png";
@ -267,6 +271,8 @@ export const InviteMembers: React.FC<Props> = (props) => {
const { setToastAlert } = useToast();
const { resolvedTheme } = useTheme();
// store hooks
const { captureEvent } = useEventTracker();
const {
control,
@ -305,6 +311,17 @@ export const InviteMembers: React.FC<Props> = (props) => {
})),
})
.then(async () => {
captureEvent(MEMBER_INVITED, {
emails: [
...payload.emails.map((email) => ({
email: email.email,
role: getUserRole(email.role),
})),
],
project_id: undefined,
state: "SUCCESS",
element: "Onboarding",
});
setToastAlert({
type: "success",
title: "Success!",
@ -313,13 +330,18 @@ export const InviteMembers: React.FC<Props> = (props) => {
await nextStep();
})
.catch((err) =>
.catch((err) => {
captureEvent(MEMBER_INVITED, {
project_id: undefined,
state: "FAILED",
element: "Onboarding",
});
setToastAlert({
type: "error",
title: "Error!",
message: err?.error,
})
);
});
});
};
const appendField = () => {

View File

@ -15,6 +15,8 @@ import CyclesTour from "public/onboarding/cycles.webp";
import ModulesTour from "public/onboarding/modules.webp";
import ViewsTour from "public/onboarding/views.webp";
import PagesTour from "public/onboarding/pages.webp";
// constants
import { PRODUCT_TOUR_SKIPPED, PRODUCT_TOUR_STARTED } from "constants/event-tracker";
type Props = {
onComplete: () => void;
@ -79,7 +81,7 @@ export const TourRoot: React.FC<Props> = observer((props) => {
const [step, setStep] = useState<TTourSteps>("welcome");
// store hooks
const { commandPalette: commandPaletteStore } = useApplication();
const { setTrackElement } = useEventTracker();
const { setTrackElement, captureEvent } = useEventTracker();
const { currentUser } = useUser();
const currentStepIndex = TOUR_STEPS.findIndex((tourStep) => tourStep.key === step);
@ -103,13 +105,22 @@ export const TourRoot: React.FC<Props> = observer((props) => {
</p>
<div className="flex h-full items-end">
<div className="mt-8 flex items-center gap-6">
<Button variant="primary" onClick={() => setStep("issues")}>
<Button
variant="primary"
onClick={() => {
captureEvent(PRODUCT_TOUR_STARTED);
setStep("issues");
}}
>
Take a Product Tour
</Button>
<button
type="button"
className="bg-transparent text-xs font-medium text-primary-text-subtle outline-custom-text-100"
onClick={onComplete}
onClick={() => {
captureEvent(PRODUCT_TOUR_SKIPPED);
onComplete();
}}
>
No thanks, I will explore it myself
</button>
@ -156,8 +167,8 @@ export const TourRoot: React.FC<Props> = observer((props) => {
<Button
variant="primary"
onClick={() => {
setTrackElement("Product tour");
onComplete();
setTrackElement("Onboarding tour");
commandPaletteStore.toggleCreateProjectModal(true);
}}
>

View File

@ -4,7 +4,7 @@ import { Controller, useForm } from "react-hook-form";
import { observer } from "mobx-react-lite";
import { Camera, User2 } from "lucide-react";
// hooks
import { useUser, useWorkspace } from "hooks/store";
import { useEventTracker, useUser, useWorkspace } from "hooks/store";
// components
import { Button, Input } from "@plane/ui";
import { OnboardingSidebar, OnboardingStepIndicator } from "components/onboarding";
@ -15,6 +15,7 @@ import { IUser } from "@plane/types";
import { FileService } from "services/file.service";
// assets
import IssuesSvg from "public/onboarding/onboarding-issues.webp";
import { USER_DETAILS } from "constants/event-tracker";
const defaultValues: Partial<IUser> = {
first_name: "",
@ -48,6 +49,7 @@ export const UserDetails: React.FC<Props> = observer((props) => {
// store hooks
const { updateCurrentUser } = useUser();
const { workspaces } = useWorkspace();
const { captureEvent } = useEventTracker();
// derived values
const workspaceName = workspaces ? Object.values(workspaces)?.[0]?.name : "New Workspace";
// form info
@ -76,7 +78,21 @@ export const UserDetails: React.FC<Props> = observer((props) => {
},
};
await updateCurrentUser(payload);
await updateCurrentUser(payload)
.then(() => {
captureEvent(USER_DETAILS, {
use_case: formData.use_case,
state: "SUCCESS",
element: "Onboarding",
});
})
.catch(() => {
captureEvent(USER_DETAILS, {
use_case: formData.use_case,
state: "FAILED",
element: "Onboarding",
});
});
};
const handleDelete = (url: string | null | undefined) => {
if (!url) return;

View File

@ -5,12 +5,13 @@ import { Button, Input } from "@plane/ui";
// types
import { IUser, IWorkspace, TOnboardingSteps } from "@plane/types";
// hooks
import { useUser, useWorkspace } from "hooks/store";
import { useEventTracker, useUser, useWorkspace } from "hooks/store";
import useToast from "hooks/use-toast";
// services
import { WorkspaceService } from "services/workspace.service";
// constants
import { RESTRICTED_URLS } from "constants/workspace";
import { WORKSPACE_CREATED } from "constants/event-tracker";
type Props = {
stepChange: (steps: Partial<TOnboardingSteps>) => Promise<void>;
@ -33,6 +34,7 @@ export const Workspace: React.FC<Props> = (props) => {
// store hooks
const { updateCurrentUser } = useUser();
const { createWorkspace, fetchWorkspaces, workspaces } = useWorkspace();
const { captureWorkspaceEvent } = useEventTracker();
// toast alert
const { setToastAlert } = useToast();
@ -46,31 +48,48 @@ export const Workspace: React.FC<Props> = (props) => {
setSlugError(false);
await createWorkspace(formData)
.then(async () => {
.then(async (res) => {
setToastAlert({
type: "success",
title: "Success!",
message: "Workspace created successfully.",
});
captureWorkspaceEvent({
eventName: WORKSPACE_CREATED,
payload: {
...res,
state: "SUCCESS",
first_time: true,
element: "Onboarding",
},
});
await fetchWorkspaces();
await completeStep();
})
.catch(() =>
.catch(() => {
captureWorkspaceEvent({
eventName: WORKSPACE_CREATED,
payload: {
state: "FAILED",
first_time: true,
element: "Onboarding",
},
});
setToastAlert({
type: "error",
title: "Error!",
message: "Workspace could not be created. Please try again.",
})
);
});
});
} else setSlugError(true);
})
.catch(() => {
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Some error occurred while creating workspace. Please try again.",
});
});
})
);
};
const completeStep = async () => {

View File

@ -13,6 +13,7 @@ import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
import { Spinner } from "@plane/ui";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
import { PRODUCT_TOUR_COMPLETED } from "constants/event-tracker";
export const WorkspaceDashboardView = observer(() => {
// theme
@ -37,9 +38,8 @@ export const WorkspaceDashboardView = observer(() => {
const handleTourCompleted = () => {
updateTourCompleted()
.then(() => {
captureEvent("User tour complete", {
captureEvent(PRODUCT_TOUR_COMPLETED, {
user_id: currentUser?.id,
email: currentUser?.email,
state: "SUCCESS",
});
})
@ -84,7 +84,7 @@ export const WorkspaceDashboardView = observer(() => {
primaryButton={{
text: "Build your first project",
onClick: () => {
setTrackElement("Dashboard");
setTrackElement("Dashboard empty state");
toggleCreateProjectModal(true);
},
}}

View File

@ -3,10 +3,14 @@ import { useRouter } from "next/router";
import { Dialog, Transition } from "@headlessui/react";
// components
import { PageForm } from "./page-form";
// hooks
import { useEventTracker } from "hooks/store";
// types
import { IPage } from "@plane/types";
import { useProjectPages } from "hooks/store/use-project-page";
import { IPageStore } from "store/page.store";
// constants
import { PAGE_CREATED, PAGE_UPDATED } from "constants/event-tracker";
type Props = {
// data?: IPage | null;
@ -21,12 +25,30 @@ export const CreateUpdatePageModal: FC<Props> = (props) => {
// router
const router = useRouter();
const { workspaceSlug } = router.query;
// store hooks
const { createPage } = useProjectPages();
const { capturePageEvent } = useEventTracker();
const createProjectPage = async (payload: IPage) => {
if (!workspaceSlug) return;
await createPage(workspaceSlug.toString(), projectId, payload);
await createPage(workspaceSlug.toString(), projectId, payload)
.then((res) => {
capturePageEvent({
eventName: PAGE_CREATED,
payload: {
...res,
state: "SUCCESS",
},
});
})
.catch(() => {
capturePageEvent({
eventName: PAGE_CREATED,
payload: {
state: "FAILED",
},
});
});
};
const handleFormSubmit = async (formData: IPage) => {
@ -39,6 +61,14 @@ export const CreateUpdatePageModal: FC<Props> = (props) => {
if (pageStore.access !== formData.access) {
formData.access === 1 ? await pageStore.makePrivate() : await pageStore.makePublic();
}
capturePageEvent({
eventName: PAGE_UPDATED,
payload: {
...pageStore,
state: "SUCCESS",
},
});
console.log("Page updated successfully", pageStore);
} else {
await createProjectPage(formData);
}

View File

@ -4,12 +4,14 @@ import { observer } from "mobx-react-lite";
import { Dialog, Transition } from "@headlessui/react";
import { AlertTriangle } from "lucide-react";
// hooks
import { usePage } from "hooks/store";
import { useEventTracker, usePage } from "hooks/store";
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// types
import { useProjectPages } from "hooks/store/use-project-page";
// constants
import { PAGE_DELETED } from "constants/event-tracker";
type TConfirmPageDeletionProps = {
pageId: string;
@ -27,6 +29,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
const { workspaceSlug, projectId } = router.query;
// store hooks
const { deletePage } = useProjectPages();
const { capturePageEvent } = useEventTracker();
const pageStore = usePage(pageId);
// toast alert
@ -49,6 +52,13 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
// Delete Page will only delete the page from the archive page map, at this point only archived pages can be deleted
await deletePage(workspaceSlug.toString(), projectId as string, pageId)
.then(() => {
capturePageEvent({
eventName: PAGE_DELETED,
payload: {
...pageStore,
state: "SUCCESS",
},
});
handleClose();
setToastAlert({
type: "success",
@ -57,6 +67,13 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
});
})
.catch(() => {
capturePageEvent({
eventName: PAGE_DELETED,
payload: {
...pageStore,
state: "FAILED",
},
});
setToastAlert({
type: "error",
title: "Error!",

View File

@ -18,6 +18,7 @@ import { getRandomEmoji, renderEmoji } from "helpers/emoji.helper";
import { NETWORK_CHOICES, PROJECT_UNSPLASH_COVERS } from "constants/project";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
import { PROJECT_CREATED } from "constants/event-tracker";
type Props = {
isOpen: boolean;
@ -134,13 +135,8 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
state: "SUCCESS",
};
captureProjectEvent({
eventName: "Project created",
eventName: PROJECT_CREATED,
payload: newPayload,
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: res.workspace,
},
});
setToastAlert({
type: "success",
@ -160,16 +156,11 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
message: err.data[key],
});
captureProjectEvent({
eventName: "Project created",
eventName: PROJECT_CREATED,
payload: {
...payload,
state: "FAILED",
},
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
}
});
});
});

View File

@ -10,6 +10,8 @@ import useToast from "hooks/use-toast";
import { Button, Input } from "@plane/ui";
// types
import type { IProject } from "@plane/types";
// constants
import { PROJECT_DELETED } from "constants/event-tracker";
type DeleteProjectModal = {
isOpen: boolean;
@ -62,13 +64,8 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => {
handleClose();
captureProjectEvent({
eventName: "Project deleted",
eventName: PROJECT_DELETED,
payload: { ...project, state: "SUCCESS", element: "Project general settings" },
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
});
setToastAlert({
type: "success",
@ -78,13 +75,8 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => {
})
.catch(() => {
captureProjectEvent({
eventName: "Project deleted",
eventName: PROJECT_DELETED,
payload: { ...project, state: "FAILED", element: "Project general settings" },
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
});
setToastAlert({
type: "error",

View File

@ -18,6 +18,7 @@ import { renderFormattedDate } from "helpers/date-time.helper";
import { NETWORK_CHOICES } from "constants/project";
// services
import { ProjectService } from "services/project";
import { PROJECT_UPDATED } from "constants/event-tracker";
export interface IProjectDetailsForm {
project: IProject;
@ -45,7 +46,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
setValue,
setError,
reset,
formState: { errors },
formState: { errors, dirtyFields },
} = useForm<IProject>({
defaultValues: {
...project,
@ -77,13 +78,15 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
return updateProject(workspaceSlug.toString(), project.id, payload)
.then((res) => {
const changed_properties = Object.keys(dirtyFields);
console.log(dirtyFields);
captureProjectEvent({
eventName: "Project updated",
payload: { ...res, state: "SUCCESS", element: "Project general settings" },
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: res.workspace,
eventName: PROJECT_UPDATED,
payload: {
...res,
changed_properties: changed_properties,
state: "SUCCESS",
element: "Project general settings",
},
});
setToastAlert({
@ -94,13 +97,8 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
})
.catch((error) => {
captureProjectEvent({
eventName: "Project updated",
eventName: PROJECT_UPDATED,
payload: { ...payload, state: "FAILED", element: "Project general settings" },
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id,
},
});
setToastAlert({
type: "error",
@ -153,7 +151,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
<img src={watch("cover_image")!} alt={watch("cover_image")!} className="h-44 w-full rounded-md object-cover" />
<div className="absolute bottom-4 z-5 flex w-full items-end justify-between gap-3 px-4">
<div className="z-5 absolute bottom-4 flex w-full items-end justify-between gap-3 px-4">
<div className="flex flex-grow gap-3 truncate">
<div className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-lg bg-neutral-component-surface-medium">
<div className="grid h-7 w-7 place-items-center">

View File

@ -11,6 +11,8 @@ import useToast from "hooks/use-toast";
import { Button, Input } from "@plane/ui";
// types
import { IProject } from "@plane/types";
// constants
import { PROJECT_MEMBER_LEAVE } from "constants/event-tracker";
type FormData = {
projectName: string;
@ -63,8 +65,9 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => {
.then(() => {
handleClose();
router.push(`/${workspaceSlug}/projects`);
captureEvent("Project member leave", {
captureEvent(PROJECT_MEMBER_LEAVE, {
state: "SUCCESS",
element: "Project settings members page",
});
})
.catch(() => {
@ -73,8 +76,9 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => {
title: "Error!",
message: "Something went wrong please try again later.",
});
captureEvent("Project member leave", {
captureEvent(PROJECT_MEMBER_LEAVE, {
state: "FAILED",
element: "Project settings members page",
});
});
} else {

View File

@ -3,7 +3,7 @@ import { useRouter } from "next/router";
import Link from "next/link";
import { observer } from "mobx-react-lite";
// hooks
import { useMember, useProject, useUser } from "hooks/store";
import { useEventTracker, useMember, useProject, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { ConfirmProjectMemberRemove } from "components/project";
@ -14,6 +14,7 @@ import { ChevronDown, Dot, XCircle } from "lucide-react";
// constants
import { ROLE } from "constants/workspace";
import { EUserProjectRoles } from "constants/project";
import { PROJECT_MEMBER_LEAVE } from "constants/event-tracker";
type Props = {
userId: string;
@ -35,6 +36,7 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
const {
project: { removeMemberFromProject, getProjectMemberDetails, updateMember },
} = useMember();
const { captureEvent } = useEventTracker();
// toast alert
const { setToastAlert } = useToast();
@ -48,8 +50,11 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
if (userDetails.member.id === currentUser?.id) {
await leaveProject(workspaceSlug.toString(), projectId.toString())
.then(async () => {
captureEvent(PROJECT_MEMBER_LEAVE, {
state: "SUCCESS",
element: "Project settings members page",
});
await fetchProjects(workspaceSlug.toString());
router.push(`/${workspaceSlug}/projects`);
})
.catch((err) =>

View File

@ -9,9 +9,12 @@ import { useEventTracker, useMember, useUser, useWorkspace } from "hooks/store";
import useToast from "hooks/use-toast";
// ui
import { Avatar, Button, CustomSelect, CustomSearchSelect } from "@plane/ui";
// helpers
import { getUserRole } from "helpers/user.helper";
// constants
import { ROLE } from "constants/workspace";
import { EUserProjectRoles } from "constants/project";
import { PROJECT_MEMBER_ADDED } from "constants/event-tracker";
type Props = {
isOpen: boolean;
@ -49,7 +52,6 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
const {
membership: { currentProjectRole },
} = useUser();
const { currentWorkspace } = useWorkspace();
const {
project: { projectMemberIds, bulkAddMembersToProject },
workspace: { workspaceMemberIds, getWorkspaceMemberDetails },
@ -79,7 +81,7 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
const payload = { ...formData };
await bulkAddMembersToProject(workspaceSlug.toString(), projectId.toString(), payload)
.then((res) => {
.then(() => {
if (onSuccess) onSuccess();
onClose();
setToastAlert({
@ -87,32 +89,23 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
type: "success",
message: "Members added successfully.",
});
captureEvent(
"Member added",
{
...res,
state: "SUCCESS",
},
{
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
}
);
captureEvent(PROJECT_MEMBER_ADDED, {
members: [
...payload.members.map((member) => ({
member_id: member.member_id,
role: ROLE[member.role],
})),
],
state: "SUCCESS",
element: "Project settings members page",
});
})
.catch((error) => {
console.error(error);
captureEvent(
"Member added",
{
state: "FAILED",
},
{
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
}
);
captureEvent(PROJECT_MEMBER_ADDED, {
state: "FAILED",
element: "Project settings members page",
});
})
.finally(() => {
reset(defaultValues);

View File

@ -51,12 +51,11 @@ export const ProjectFeaturesList: FC<Props> = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store hooks
const { setTrackElement, captureEvent } = useEventTracker();
const { captureEvent } = useEventTracker();
const {
currentUser,
membership: { currentProjectRole },
} = useUser();
const { currentWorkspace } = useWorkspace();
const { currentProjectDetails, updateProject } = useProject();
const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN;
// toast alert
@ -93,14 +92,9 @@ export const ProjectFeaturesList: FC<Props> = observer(() => {
<ToggleSwitch
value={Boolean(currentProjectDetails?.[feature.property as keyof IProject])}
onChange={() => {
setTrackElement("PROJECT_SETTINGS_FEATURES_PAGE");
captureEvent(`Toggle ${feature.title.toLowerCase()}`, {
workspace_id: currentWorkspace?.id,
workspace_slug: currentWorkspace?.slug,
project_id: currentProjectDetails?.id,
project_name: currentProjectDetails?.name,
project_identifier: currentProjectDetails?.identifier,
enabled: !currentProjectDetails?.[feature.property as keyof IProject],
element: "Project settings feature page",
});
handleSubmit({
[feature.property]: !currentProjectDetails?.[feature.property as keyof IProject],

View File

@ -13,6 +13,7 @@ import { Button, CustomSelect, Input, Tooltip } from "@plane/ui";
import type { IState } from "@plane/types";
// constants
import { GROUP_CHOICES } from "constants/project";
import { STATE_CREATED, STATE_UPDATED } from "constants/event-tracker";
type Props = {
data: IState | null;
@ -36,7 +37,7 @@ export const CreateUpdateStateInline: React.FC<Props> = observer((props) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store hooks
const { captureEvent, setTrackElement } = useEventTracker();
const { captureProjectStateEvent, setTrackElement } = useEventTracker();
const { createState, updateState } = useProjectState();
// toast alert
const { setToastAlert } = useToast();
@ -86,9 +87,13 @@ export const CreateUpdateStateInline: React.FC<Props> = observer((props) => {
title: "Success!",
message: "State created successfully.",
});
captureEvent("State created", {
...res,
state: "SUCCESS",
captureProjectStateEvent({
eventName: STATE_CREATED,
payload: {
...res,
state: "SUCCESS",
element: "Project settings states page",
},
});
})
.catch((error) => {
@ -104,8 +109,14 @@ export const CreateUpdateStateInline: React.FC<Props> = observer((props) => {
title: "Error!",
message: "State could not be created. Please try again.",
});
captureEvent("State created", {
state: "FAILED",
captureProjectStateEvent({
eventName: STATE_CREATED,
payload: {
...formData,
state: "FAILED",
element: "Project settings states page",
},
});
});
};
@ -116,9 +127,13 @@ export const CreateUpdateStateInline: React.FC<Props> = observer((props) => {
await updateState(workspaceSlug.toString(), projectId.toString(), data.id, formData)
.then((res) => {
handleClose();
captureEvent("State updated", {
...res,
state: "SUCCESS",
captureProjectStateEvent({
eventName: STATE_UPDATED,
payload: {
...res,
state: "SUCCESS",
element: "Project settings states page",
},
});
setToastAlert({
type: "success",
@ -139,8 +154,13 @@ export const CreateUpdateStateInline: React.FC<Props> = observer((props) => {
title: "Error!",
message: "State could not be updated. Please try again.",
});
captureEvent("State updated", {
state: "FAILED",
captureProjectStateEvent({
eventName: STATE_UPDATED,
payload: {
...formData,
state: "FAILED",
element: "Project settings states page",
},
});
});
};

View File

@ -10,6 +10,8 @@ import useToast from "hooks/use-toast";
import { Button } from "@plane/ui";
// types
import type { IState } from "@plane/types";
// constants
import { STATE_DELETED } from "constants/event-tracker";
type Props = {
isOpen: boolean;
@ -25,7 +27,7 @@ export const DeleteStateModal: React.FC<Props> = observer((props) => {
const router = useRouter();
const { workspaceSlug } = router.query;
// store hooks
const { captureEvent } = useEventTracker();
const { captureProjectStateEvent } = useEventTracker();
const { deleteState } = useProjectState();
// toast alert
const { setToastAlert } = useToast();
@ -42,8 +44,12 @@ export const DeleteStateModal: React.FC<Props> = observer((props) => {
await deleteState(workspaceSlug.toString(), data.project_id, data.id)
.then(() => {
captureEvent("State deleted", {
state: "SUCCESS",
captureProjectStateEvent({
eventName: STATE_DELETED,
payload: {
...data,
state: "SUCCESS",
},
});
handleClose();
})
@ -61,8 +67,12 @@ export const DeleteStateModal: React.FC<Props> = observer((props) => {
title: "Error!",
message: "State could not be deleted. Please try again.",
});
captureEvent("State deleted", {
state: "FAILED",
captureProjectStateEvent({
eventName: STATE_DELETED,
payload: {
...data,
state: "FAILED",
},
});
})
.finally(() => {

View File

@ -13,6 +13,7 @@ import { Button, CustomSelect, Input } from "@plane/ui";
import { IWorkspace } from "@plane/types";
// constants
import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "constants/workspace";
import { WORKSPACE_CREATED } from "constants/event-tracker";
type Props = {
onSubmit?: (res: IWorkspace) => Promise<void>;
@ -48,7 +49,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
// router
const router = useRouter();
// store hooks
const { captureEvent } = useEventTracker();
const { captureWorkspaceEvent } = useEventTracker();
const { createWorkspace } = useWorkspace();
// toast alert
const { setToastAlert } = useToast();
@ -70,9 +71,13 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
await createWorkspace(formData)
.then(async (res) => {
captureEvent("Workspace created", {
...res,
state: "SUCCESS",
captureWorkspaceEvent({
eventName: WORKSPACE_CREATED,
payload: {
...res,
state: "SUCCESS",
element: "Create workspace page",
},
});
setToastAlert({
type: "success",
@ -83,14 +88,18 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
if (onSubmit) await onSubmit(res);
})
.catch(() => {
captureWorkspaceEvent({
eventName: WORKSPACE_CREATED,
payload: {
state: "FAILED",
element: "Create workspace page",
},
});
setToastAlert({
type: "error",
title: "Error!",
message: "Workspace could not be created. Please try again.",
});
captureEvent("Workspace created", {
state: "FAILED",
});
});
} else setSlugError(true);
})
@ -100,9 +109,6 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
title: "Error!",
message: "Some error occurred while creating workspace. Please try again.",
});
captureEvent("Workspace created", {
state: "FAILED",
});
});
};

View File

@ -11,6 +11,8 @@ import useToast from "hooks/use-toast";
import { Button, Input } from "@plane/ui";
// types
import type { IWorkspace } from "@plane/types";
// constants
import { WORKSPACE_DELETED } from "constants/event-tracker";
type Props = {
isOpen: boolean;
@ -28,7 +30,7 @@ export const DeleteWorkspaceModal: React.FC<Props> = observer((props) => {
// router
const router = useRouter();
// store hooks
const { captureEvent } = useEventTracker();
const { captureWorkspaceEvent } = useEventTracker();
const { deleteWorkspace } = useWorkspace();
// toast alert
const { setToastAlert } = useToast();
@ -59,9 +61,13 @@ export const DeleteWorkspaceModal: React.FC<Props> = observer((props) => {
.then((res) => {
handleClose();
router.push("/");
captureEvent("Workspace deleted", {
res,
state: "SUCCESS",
captureWorkspaceEvent({
eventName: WORKSPACE_DELETED,
payload: {
...data,
state: "SUCCESS",
element: "Workspace general settings page",
},
});
setToastAlert({
type: "success",
@ -75,8 +81,13 @@ export const DeleteWorkspaceModal: React.FC<Props> = observer((props) => {
title: "Error!",
message: "Something went wrong. Please try again later.",
});
captureEvent("Workspace deleted", {
state: "FAILED",
captureWorkspaceEvent({
eventName: WORKSPACE_DELETED,
payload: {
...data,
state: "FAILED",
element: "Workspace general settings page",
},
});
});
};

View File

@ -4,7 +4,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { ChevronDown, Dot, XCircle } from "lucide-react";
// hooks
import { useMember, useUser } from "hooks/store";
import { useEventTracker, useMember, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { ConfirmWorkspaceMemberRemove } from "components/workspace";
@ -12,6 +12,7 @@ import { ConfirmWorkspaceMemberRemove } from "components/workspace";
import { CustomSelect, Tooltip } from "@plane/ui";
// constants
import { EUserWorkspaceRoles, ROLE } from "constants/workspace";
import { WORKSPACE_MEMBER_lEAVE } from "constants/event-tracker";
type Props = {
memberId: string;
@ -33,6 +34,7 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
const {
workspace: { updateMember, removeMemberFromWorkspace, getWorkspaceMemberDetails },
} = useMember();
const { captureEvent } = useEventTracker();
// toast alert
const { setToastAlert } = useToast();
// derived values
@ -42,7 +44,13 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
if (!workspaceSlug || !currentUserSettings) return;
await leaveWorkspace(workspaceSlug.toString())
.then(() => router.push("/profile"))
.then(() => {
captureEvent(WORKSPACE_MEMBER_lEAVE, {
state: "SUCCESS",
element: "Workspace settings members page",
});
router.push("/profile");
})
.catch((err) =>
setToastAlert({
type: "error",

View File

@ -19,6 +19,7 @@ import { copyUrlToClipboard } from "helpers/string.helper";
import { IWorkspace } from "@plane/types";
// constants
import { EUserWorkspaceRoles, ORGANIZATION_SIZE } from "constants/workspace";
import { WORKSPACE_UPDATED } from "constants/event-tracker";
const defaultValues: Partial<IWorkspace> = {
name: "",
@ -37,7 +38,7 @@ export const WorkspaceDetails: FC = observer(() => {
const [isImageRemoving, setIsImageRemoving] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
const { captureWorkspaceEvent } = useEventTracker();
const {
membership: { currentWorkspaceRole },
} = useUser();
@ -68,9 +69,13 @@ export const WorkspaceDetails: FC = observer(() => {
await updateWorkspace(currentWorkspace.slug, payload)
.then((res) => {
captureEvent("Workspace updated", {
...res,
state: "SUCCESS",
captureWorkspaceEvent({
eventName: WORKSPACE_UPDATED,
payload: {
...res,
state: "SUCCESS",
element: "Workspace general settings page",
},
});
setToastAlert({
title: "Success",
@ -79,8 +84,12 @@ export const WorkspaceDetails: FC = observer(() => {
});
})
.catch((err) => {
captureEvent("Workspace updated", {
state: "FAILED",
captureWorkspaceEvent({
eventName: WORKSPACE_UPDATED,
payload: {
state: "FAILED",
element: "Workspace general settings page",
},
});
console.error(err);
});

View File

@ -222,7 +222,6 @@ export const WorkspaceSidebarDropdown = observer(() => {
<div className="flex w-full flex-col items-start justify-start gap-2 px-4 py-2 text-sm">
<Link
href="/create-workspace"
onClick={() => setTrackElement("APP_SIDEBAR_WORKSPACE_DROPDOWN")}
className="w-full"
>
<Menu.Item

View File

@ -3,7 +3,7 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// hooks
import { useApplication, useUser } from "hooks/store";
import { useApplication, useEventTracker, useUser } from "hooks/store";
// components
import { NotificationPopover } from "components/notifications";
// ui
@ -12,12 +12,14 @@ import { Crown } from "lucide-react";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
import { SIDEBAR_MENU_ITEMS } from "constants/dashboard";
import { SIDEBAR_CLICKED } from "constants/event-tracker";
// helper
import { cn } from "helpers/common.helper";
export const WorkspaceSidebarMenu = observer(() => {
// store hooks
const { theme: themeStore } = useApplication();
const { captureEvent } = useEventTracker();
const {
membership: { currentWorkspaceRole },
} = useUser();
@ -27,10 +29,13 @@ export const WorkspaceSidebarMenu = observer(() => {
// computed
const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST;
const handleLinkClick = () => {
const handleLinkClick = (itemKey: string) => {
if (window.innerWidth < 768) {
themeStore.toggleSidebar();
}
captureEvent(SIDEBAR_CLICKED, {
destination: itemKey,
});
};
return (
@ -38,8 +43,8 @@ export const WorkspaceSidebarMenu = observer(() => {
{SIDEBAR_MENU_ITEMS.map(
(link) =>
workspaceMemberInfo >= link.access && (
<Link key={link.key} href={`/${workspaceSlug}${link.href}`} onClick={handleLinkClick}>
<span className="block w-full my-1">
<Link key={link.key} href={`/${workspaceSlug}${link.href}`} onClick={() => handleLinkClick(link.key)}>
<span className="my-1 block w-full">
<Tooltip
tooltipContent={link.label}
position="right"

View File

@ -4,12 +4,14 @@ import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
import { AlertTriangle } from "lucide-react";
// store hooks
import { useGlobalView } from "hooks/store";
import { useGlobalView, useEventTracker } from "hooks/store";
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// types
import { IWorkspaceView } from "@plane/types";
// constants
import { GLOBAL_VIEW_DELETED } from "constants/event-tracker";
type Props = {
data: IWorkspaceView;
@ -26,6 +28,7 @@ export const DeleteGlobalViewModal: React.FC<Props> = observer((props) => {
const { workspaceSlug } = router.query;
// store hooks
const { deleteGlobalView } = useGlobalView();
const { captureEvent } = useEventTracker();
// toast alert
const { setToastAlert } = useToast();
@ -39,13 +42,23 @@ export const DeleteGlobalViewModal: React.FC<Props> = observer((props) => {
setIsDeleteLoading(true);
await deleteGlobalView(workspaceSlug.toString(), data.id)
.catch(() =>
.then(() => {
captureEvent(GLOBAL_VIEW_DELETED, {
view_id: data.id,
state: "SUCCESS",
});
})
.catch(() => {
captureEvent(GLOBAL_VIEW_DELETED, {
view_id: data.id,
state: "FAILED",
});
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong while deleting the view. Please try again.",
})
)
});
})
.finally(() => {
setIsDeleteLoading(false);
handleClose();

View File

@ -4,11 +4,12 @@ import Link from "next/link";
import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react";
// store hooks
import { useGlobalView, useUser } from "hooks/store";
import { useEventTracker, useGlobalView, useUser } from "hooks/store";
// components
import { CreateUpdateWorkspaceViewModal } from "components/workspace";
// constants
import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "constants/workspace";
import { GLOBAL_VIEW_OPENED } from "constants/event-tracker";
const ViewTab = observer((props: { viewId: string }) => {
const { viewId } = props;
@ -49,11 +50,19 @@ export const GlobalViewsHeader: React.FC = observer(() => {
const {
membership: { currentWorkspaceRole },
} = useUser();
const { captureEvent } = useEventTracker();
// bring the active view to the centre of the header
useEffect(() => {
if (!globalViewId) return;
captureEvent(GLOBAL_VIEW_OPENED, {
view_id: globalViewId,
view_type: ["all-issues", "assigned", "created", "subscribed"].includes(globalViewId.toString())
? "Default"
: "Custom",
});
const activeTabElement = document.querySelector(`#global-view-${globalViewId.toString()}`);
if (activeTabElement) activeTabElement.scrollIntoView({ behavior: "smooth", inline: "center" });

View File

@ -3,12 +3,14 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Dialog, Transition } from "@headlessui/react";
// store hooks
import { useGlobalView } from "hooks/store";
import { useEventTracker, useGlobalView } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { WorkspaceViewForm } from "components/workspace";
// types
import { IWorkspaceView } from "@plane/types";
// constants
import { GLOBAL_VIEW_CREATED, GLOBAL_VIEW_UPDATED } from "constants/event-tracker";
type Props = {
data?: IWorkspaceView;
@ -24,6 +26,7 @@ export const CreateUpdateWorkspaceViewModal: React.FC<Props> = observer((props)
const { workspaceSlug } = router.query;
// store hooks
const { createGlobalView, updateGlobalView } = useGlobalView();
const { captureEvent } = useEventTracker();
// toast alert
const { setToastAlert } = useToast();
@ -43,6 +46,11 @@ export const CreateUpdateWorkspaceViewModal: React.FC<Props> = observer((props)
await createGlobalView(workspaceSlug.toString(), payloadData)
.then((res) => {
captureEvent(GLOBAL_VIEW_CREATED, {
view_id: res.id,
applied_filters: res.filters,
state: "SUCCESS",
});
setToastAlert({
type: "success",
title: "Success!",
@ -52,13 +60,17 @@ export const CreateUpdateWorkspaceViewModal: React.FC<Props> = observer((props)
router.push(`/${workspaceSlug}/workspace-views/${res.id}`);
handleClose();
})
.catch(() =>
.catch(() => {
captureEvent(GLOBAL_VIEW_CREATED, {
applied_filters: payload?.filters,
state: "FAILED",
});
setToastAlert({
type: "error",
title: "Error!",
message: "View could not be created. Please try again.",
})
);
});
});
};
const handleUpdateView = async (payload: Partial<IWorkspaceView>) => {
@ -72,7 +84,12 @@ export const CreateUpdateWorkspaceViewModal: React.FC<Props> = observer((props)
};
await updateGlobalView(workspaceSlug.toString(), data.id, payloadData)
.then(() => {
.then((res) => {
captureEvent(GLOBAL_VIEW_UPDATED, {
view_id: res.id,
applied_filters: res.filters,
state: "SUCCESS",
});
setToastAlert({
type: "success",
title: "Success!",
@ -80,13 +97,18 @@ export const CreateUpdateWorkspaceViewModal: React.FC<Props> = observer((props)
});
handleClose();
})
.catch(() =>
.catch(() => {
captureEvent(GLOBAL_VIEW_UPDATED, {
view_id: data.id,
applied_filters: data.filters,
state: "FAILED",
});
setToastAlert({
type: "error",
title: "Error!",
message: "View could not be updated. Please try again.",
})
);
});
});
};
const handleFormSubmit = async (formData: Partial<IWorkspaceView>) => {

View File

@ -4,7 +4,7 @@ import Link from "next/link";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2 } from "lucide-react";
// store hooks
import { useGlobalView } from "hooks/store";
import { useEventTracker, useGlobalView } from "hooks/store";
// components
import { CreateUpdateWorkspaceViewModal, DeleteGlobalViewModal } from "components/workspace";
// ui
@ -25,6 +25,7 @@ export const GlobalViewListItem: React.FC<Props> = observer((props) => {
const { workspaceSlug } = router.query;
// store hooks
const { getViewDetailsById } = useGlobalView();
const {setTrackElement} = useEventTracker();
// derived data
const view = getViewDetailsById(viewId);
@ -59,6 +60,7 @@ export const GlobalViewListItem: React.FC<Props> = observer((props) => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("List view");
setUpdateViewModal(true);
}}
>

View File

@ -2,26 +2,31 @@ export type IssueEventProps = {
eventName: string;
payload: any;
updates?: any;
group?: EventGroupProps;
path?: string;
};
export type EventProps = {
eventName: string;
payload: any;
group?: EventGroupProps;
};
export type EventGroupProps = {
isGrouping?: boolean;
groupType?: string;
groupId?: string;
};
export const getWorkspaceEventPayload = (payload: any) => ({
workspace_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
organization_size: payload.organization_size,
first_time: payload.first_time,
state: payload.state,
element: payload.element,
});
export const getProjectEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.id,
identifier: payload.identifier,
project_visibility: payload.network == 2 ? "Public" : "Private",
changed_properties: payload.changed_properties,
lead_id: payload.project_lead,
created_at: payload.created_at,
updated_at: payload.updated_at,
state: payload.state,
@ -30,26 +35,43 @@ export const getProjectEventPayload = (payload: any) => ({
export const getCycleEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.id,
project_id: payload.project,
cycle_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
start_date: payload.start_date,
target_date: payload.target_date,
cycle_status: payload.status,
changed_properties: payload.changed_properties,
state: payload.state,
element: payload.element,
});
export const getModuleEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.id,
project_id: payload.project,
module_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
start_date: payload.start_date,
target_date: payload.target_date,
module_status: payload.status,
lead_id: payload.lead,
changed_properties: payload.changed_properties,
member_ids: payload.members,
state: payload.state,
element: payload.element,
});
export const getPageEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.project,
created_at: payload.created_at,
updated_at: payload.updated_at,
access: payload.access === 0 ? "Public" : "Private",
is_locked: payload.is_locked,
archived_at: payload.archived_at,
created_by: payload.created_by,
state: payload.state,
element: payload.element,
});
@ -71,6 +93,7 @@ export const getIssueEventPayload = (props: IssueEventProps) => {
sub_issues_count: payload.sub_issues_count,
parent_id: payload.parent_id,
project_id: payload.project_id,
workspace_id: payload.workspace_id,
priority: payload.priority,
state_id: payload.state_id,
start_date: payload.start_date,
@ -82,7 +105,7 @@ export const getIssueEventPayload = (props: IssueEventProps) => {
view_id: path?.includes("workspace-views") || path?.includes("views") ? path.split("/").pop() : "",
};
if (eventName === "Issue updated") {
if (eventName === ISSUE_UPDATED) {
eventPayload = {
...eventPayload,
...updates,
@ -103,3 +126,99 @@ export const getIssueEventPayload = (props: IssueEventProps) => {
}
return eventPayload;
};
export const getProjectStateEventPayload = (payload: any) => {
return {
workspace_id: payload.workspace_id,
project_id: payload.id,
state_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
group: payload.group,
color: payload.color,
default: payload.default,
state: payload.state,
element: payload.element,
};
};
// Workspace crud Events
export const WORKSPACE_CREATED = "Workspace created";
export const WORKSPACE_UPDATED = "Workspace updated";
export const WORKSPACE_DELETED = "Workspace deleted";
// Project Events
export const PROJECT_CREATED = "Project created";
export const PROJECT_UPDATED = "Project updated";
export const PROJECT_DELETED = "Project deleted";
// Cycle Events
export const CYCLE_CREATED = "Cycle created";
export const CYCLE_UPDATED = "Cycle updated";
export const CYCLE_DELETED = "Cycle deleted";
export const CYCLE_FAVORITED = "Cycle favorited";
export const CYCLE_UNFAVORITED = "Cycle unfavorited";
// Module Events
export const MODULE_CREATED = "Module created";
export const MODULE_UPDATED = "Module updated";
export const MODULE_DELETED = "Module deleted";
export const MODULE_FAVORITED = "Module favorited";
export const MODULE_UNFAVORITED = "Module unfavorited";
export const MODULE_LINK_CREATED = "Module link created";
export const MODULE_LINK_UPDATED = "Module link updated";
export const MODULE_LINK_DELETED = "Module link deleted";
// Issue Events
export const ISSUE_CREATED = "Issue created";
export const ISSUE_UPDATED = "Issue updated";
export const ISSUE_DELETED = "Issue deleted";
export const ISSUE_OPENED = "Issue opened";
// Project State Events
export const STATE_CREATED = "State created";
export const STATE_UPDATED = "State updated";
export const STATE_DELETED = "State deleted";
// Project Page Events
export const PAGE_CREATED = "Page created";
export const PAGE_UPDATED = "Page updated";
export const PAGE_DELETED = "Page deleted";
// Member Events
export const MEMBER_INVITED = "Member invited";
export const MEMBER_ACCEPTED = "Member accepted";
export const PROJECT_MEMBER_ADDED = "Project member added";
export const PROJECT_MEMBER_LEAVE = "Project member leave";
export const WORKSPACE_MEMBER_lEAVE = "Workspace member leave";
// Sign-in & Sign-up Events
export const NAVIGATE_TO_SIGNUP = "Navigate to sign-up page";
export const NAVIGATE_TO_SIGNIN = "Navigate to sign-in page";
export const CODE_VERIFIED = "Code verified";
export const SETUP_PASSWORD = "Password setup";
export const PASSWORD_CREATE_SELECTED = "Password created";
export const PASSWORD_CREATE_SKIPPED = "Skipped to setup";
export const SIGN_IN_WITH_PASSWORD = "Sign in with password";
export const FORGOT_PASSWORD = "Forgot password clicked";
export const FORGOT_PASS_LINK = "Forgot password link generated";
export const NEW_PASS_CREATED = "New password created";
// Onboarding Events
export const USER_DETAILS = "User details added";
export const USER_ONBOARDING_COMPLETED = "User onboarding completed";
// Product Tour Events
export const PRODUCT_TOUR_STARTED = "Product tour started";
export const PRODUCT_TOUR_COMPLETED = "Product tour completed";
export const PRODUCT_TOUR_SKIPPED = "Product tour skipped";
// Dashboard Events
export const CHANGELOG_REDIRECTED = "Changelog redirected";
export const GITHUB_REDIRECTED = "Github redirected";
// Sidebar Events
export const SIDEBAR_CLICKED = "Sidenav clicked";
// Global View Events
export const GLOBAL_VIEW_CREATED = "Global view created";
export const GLOBAL_VIEW_UPDATED = "Global view updated";
export const GLOBAL_VIEW_DELETED = "Global view deleted";
export const GLOBAL_VIEW_OPENED = "Global view opened";
// Notification Events
export const NOTIFICATION_ARCHIVED = "Notification archived";
export const NOTIFICATION_SNOOZED = "Notification snoozed";
export const NOTIFICATION_READ = "Notification marked read";
export const UNREAD_NOTIFICATIONS = "Unread notifications viewed";
export const NOTIFICATIONS_READ = "All notifications marked read";
export const SNOOZED_NOTIFICATIONS= "Snoozed notifications viewed";
export const ARCHIVED_NOTIFICATIONS = "Archived notifications viewed";
// Groups
export const GROUP_WORKSPACE = "Workspace_metrics";

View File

@ -5,7 +5,7 @@ import NProgress from "nprogress";
import { observer } from "mobx-react-lite";
import { ThemeProvider } from "next-themes";
// hooks
import { useApplication, useUser } from "hooks/store";
import { useApplication, useUser, useWorkspace } from "hooks/store";
// constants
import { THEMES } from "constants/themes";
// layouts
@ -37,6 +37,7 @@ export const AppProvider: FC<IAppProvider> = observer((props) => {
currentUser,
membership: { currentProjectRole, currentWorkspaceRole },
} = useUser();
const { currentWorkspace } = useWorkspace();
const {
config: { envConfig },
} = useApplication();
@ -49,6 +50,7 @@ export const AppProvider: FC<IAppProvider> = observer((props) => {
<CrispWrapper user={currentUser}>
<PostHogProvider
user={currentUser}
currentWorkspaceId= {currentWorkspace?.id}
workspaceRole={currentWorkspaceRole}
projectRole={currentProjectRole}
posthogAPIKey={envConfig?.posthog_api_key || null}

View File

@ -1,4 +1,4 @@
import { FC, ReactNode, useEffect } from "react";
import { FC, ReactNode, useEffect, useState } from "react";
import { useRouter } from "next/router";
import posthog from "posthog-js";
import { PostHogProvider as PHProvider } from "posthog-js/react";
@ -6,10 +6,13 @@ import { PostHogProvider as PHProvider } from "posthog-js/react";
import { IUser } from "@plane/types";
// helpers
import { getUserRole } from "helpers/user.helper";
// constants
import { GROUP_WORKSPACE } from "constants/event-tracker";
export interface IPosthogWrapper {
children: ReactNode;
user: IUser | null;
currentWorkspaceId: string | undefined;
workspaceRole: number | undefined;
projectRole: number | undefined;
posthogAPIKey: string | null;
@ -17,7 +20,9 @@ export interface IPosthogWrapper {
}
const PostHogProvider: FC<IPosthogWrapper> = (props) => {
const { children, user, workspaceRole, projectRole, posthogAPIKey, posthogHost } = props;
const { children, user, workspaceRole, currentWorkspaceId, projectRole, posthogAPIKey, posthogHost } = props;
// states
const [lastWorkspaceId, setLastWorkspaceId] = useState(currentWorkspaceId);
// router
const router = useRouter();
@ -25,10 +30,11 @@ const PostHogProvider: FC<IPosthogWrapper> = (props) => {
if (user) {
// Identify sends an event, so you want may want to limit how often you call it
posthog?.identify(user.email, {
email: user.email,
id: user.id,
first_name: user.first_name,
last_name: user.last_name,
id: user.id,
email: user.email,
use_case: user.use_case,
workspace_role: workspaceRole ? getUserRole(workspaceRole) : undefined,
project_role: projectRole ? getUserRole(projectRole) : undefined,
});
@ -45,6 +51,15 @@ const PostHogProvider: FC<IPosthogWrapper> = (props) => {
}
}, [posthogAPIKey, posthogHost]);
useEffect(() => {
// Join workspace group on workspace change
if (lastWorkspaceId !== currentWorkspaceId && currentWorkspaceId && user) {
setLastWorkspaceId(currentWorkspaceId);
posthog?.identify(user.email);
posthog?.group(GROUP_WORKSPACE, currentWorkspaceId);
}
}, [currentWorkspaceId, user]);
useEffect(() => {
// Track page views
const handleRouteChange = () => {

View File

@ -45,7 +45,7 @@
"next-pwa": "^5.6.0",
"next-themes": "^0.2.1",
"nprogress": "^0.2.0",
"posthog-js": "^1.88.4",
"posthog-js": "^1.105.0",
"react": "18.2.0",
"react-color": "^2.19.3",
"react-datepicker": "^4.8.0",

View File

@ -6,7 +6,7 @@ import useSWR from "swr";
import { observer } from "mobx-react-lite";
import { useTheme } from "next-themes";
// hooks
import { useApplication, useUser } from "hooks/store";
import { useApplication, useEventTracker, useUser } from "hooks/store";
import useLocalStorage from "hooks/use-local-storage";
import useUserAuth from "hooks/use-user-auth";
// layouts
@ -60,6 +60,7 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
const {
commandPalette: { toggleCreatePageModal },
} = useApplication();
const { setTrackElement } = useEventTracker();
const { fetchProjectPages, fetchArchivedProjectPages, loader, archivedPageLoader, projectPageIds, archivedPageIds } =
useProjectPages();
@ -194,7 +195,10 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
description="Pages are thoughts potting space in Plane. Take down meeting notes, format them easily, embed issues, lay them out using a library of components, and keep them all in your projects context. To make short work of any doc, invoke Galileo, Planes AI, with a shortcut or the click of a button."
primaryButton={{
text: "Create your first page",
onClick: () => toggleCreatePageModal(true),
onClick: () => {
setTrackElement("Pages empty state");
toggleCreatePageModal(true);
},
}}
comicBox={{
title: "A page can be a doc or a doc of docs.",

View File

@ -16,8 +16,11 @@ import { Button } from "@plane/ui";
// types
import { NextPageWithLayout } from "lib/types";
import { IWorkspaceBulkInviteFormData } from "@plane/types";
// helpers
import { getUserRole } from "helpers/user.helper";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
import { MEMBER_INVITED } from "constants/event-tracker";
const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => {
// states
@ -43,7 +46,17 @@ const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => {
return inviteMembersToWorkspace(workspaceSlug.toString(), data)
.then(() => {
setInviteModal(false);
captureEvent("Member invited", { state: "SUCCESS" });
captureEvent(MEMBER_INVITED, {
emails: [
...data.emails.map((email) => ({
email: email.email,
role: getUserRole(email.role),
})),
],
project_id: undefined,
state: "SUCCESS",
element: "Workspace settings member page",
});
setToastAlert({
type: "success",
title: "Success!",
@ -51,7 +64,17 @@ const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => {
});
})
.catch((err) => {
captureEvent("Member invited", { state: "FAILED" });
captureEvent(MEMBER_INVITED, {
emails: [
...data.emails.map((email) => ({
email: email.email,
role: getUserRole(email.role),
})),
],
project_id: undefined,
state: "FAILED",
element: "Workspace settings member page",
});
setToastAlert({
type: "error",
title: "Error!",
@ -84,14 +107,7 @@ const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => {
/>
</div>
{hasAddMemberPermission && (
<Button
variant="primary"
size="sm"
onClick={() => {
setTrackElement("WORKSPACE_SETTINGS_MEMBERS_PAGE_HEADER");
setInviteModal(true);
}}
>
<Button variant="primary" size="sm" onClick={() => setInviteModal(true)}>
Add member
</Button>
)}

View File

@ -7,6 +7,7 @@ import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
import { useEventTracker } from "hooks/store";
// layouts
import DefaultLayout from "layouts/default-layout";
// components
@ -19,6 +20,7 @@ import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
import { checkEmailValidity } from "helpers/string.helper";
// type
import { NextPageWithLayout } from "lib/types";
import { FORGOT_PASS_LINK } from "constants/event-tracker";
type TForgotPasswordFormValues = {
email: string;
@ -35,6 +37,8 @@ const ForgotPasswordPage: NextPageWithLayout = () => {
// router
const router = useRouter();
const { email } = router.query;
// store hooks
const { captureEvent } = useEventTracker();
// toast
const { setToastAlert } = useToast();
// timer
@ -57,6 +61,9 @@ const ForgotPasswordPage: NextPageWithLayout = () => {
email: formData.email,
})
.then(() => {
captureEvent(FORGOT_PASS_LINK, {
state: "SUCCESS",
});
setToastAlert({
type: "success",
title: "Email sent",
@ -65,13 +72,16 @@ const ForgotPasswordPage: NextPageWithLayout = () => {
});
setResendCodeTimer(30);
})
.catch((err) =>
.catch((err) => {
captureEvent(FORGOT_PASS_LINK, {
state: "FAILED",
});
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
});
});
};
return (

View File

@ -7,6 +7,7 @@ import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import useSignInRedirection from "hooks/use-sign-in-redirection";
import { useEventTracker } from "hooks/store";
// layouts
import DefaultLayout from "layouts/default-layout";
// components
@ -21,6 +22,8 @@ import { checkEmailValidity } from "helpers/string.helper";
import { NextPageWithLayout } from "lib/types";
// icons
import { Eye, EyeOff } from "lucide-react";
// constants
import { NEW_PASS_CREATED } from "constants/event-tracker";
type TResetPasswordFormValues = {
email: string;
@ -41,6 +44,8 @@ const ResetPasswordPage: NextPageWithLayout = () => {
const { uidb64, token, email } = router.query;
// states
const [showPassword, setShowPassword] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
// toast
const { setToastAlert } = useToast();
// sign in redirection hook
@ -66,14 +71,22 @@ const ResetPasswordPage: NextPageWithLayout = () => {
await authService
.resetPassword(uidb64.toString(), token.toString(), payload)
.then(() => handleRedirection())
.catch((err) =>
.then(() => {
captureEvent(NEW_PASS_CREATED, {
state: "SUCCESS",
});
handleRedirection();
})
.catch((err) => {
captureEvent(NEW_PASS_CREATED, {
state: "FAILED",
});
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
});
});
};
return (

View File

@ -23,11 +23,13 @@ import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-l
import emptyInvitation from "public/empty-state/invitation.svg";
// helpers
import { truncateText } from "helpers/string.helper";
import { getUserRole } from "helpers/user.helper";
// types
import { NextPageWithLayout } from "lib/types";
import type { IWorkspaceMemberInvitation } from "@plane/types";
// constants
import { ROLE } from "constants/workspace";
import { MEMBER_ACCEPTED } from "constants/event-tracker";
// components
import { EmptyState } from "components/common";
@ -40,7 +42,7 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => {
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
const { captureEvent, joinWorkspaceMetricGroup } = useEventTracker();
const { currentUser, currentUserSettings } = useUser();
// router
const router = useRouter();
@ -81,11 +83,16 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => {
.then((res) => {
mutate("USER_WORKSPACES");
const firstInviteId = invitationsRespond[0];
const invitation = invitations?.find((i) => i.id === firstInviteId);
const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace;
captureEvent("Member accepted", {
...res,
state: "SUCCESS",
joinWorkspaceMetricGroup(redirectWorkspace?.id);
captureEvent(MEMBER_ACCEPTED, {
member_id: invitation?.id,
role: getUserRole(invitation?.role!),
project_id: undefined,
accepted_from: "App",
state: "SUCCESS",
element: "Workspace invitations page",
});
userService
.updateUser({ last_workspace_id: redirectWorkspace?.id })
@ -103,6 +110,12 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => {
});
})
.catch(() => {
captureEvent(MEMBER_ACCEPTED, {
project_id: undefined,
accepted_from: "App",
state: "FAILED",
element: "Workspace invitations page",
});
setToastAlert({
type: "error",
title: "Error!",

View File

@ -24,6 +24,8 @@ import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
// types
import { IUser, TOnboardingSteps } from "@plane/types";
import { NextPageWithLayout } from "lib/types";
// constants
import { USER_ONBOARDING_COMPLETED } from "constants/event-tracker";
// services
const workspaceService = new WorkspaceService();
@ -79,7 +81,7 @@ const OnboardingPage: NextPageWithLayout = observer(() => {
await updateUserOnBoard()
.then(() => {
captureEvent("User onboarding completed", {
captureEvent(USER_ONBOARDING_COMPLETED, {
user_role: user.role,
email: user.email,
user_id: user.id,

View File

@ -3,39 +3,49 @@ import posthog from "posthog-js";
// stores
import { RootStore } from "./root.store";
import {
EventGroupProps,
GROUP_WORKSPACE,
WORKSPACE_CREATED,
EventProps,
IssueEventProps,
getCycleEventPayload,
getIssueEventPayload,
getModuleEventPayload,
getProjectEventPayload,
getProjectStateEventPayload,
getWorkspaceEventPayload,
getPageEventPayload,
} from "constants/event-tracker";
export interface IEventTrackerStore {
// properties
trackElement: string;
trackElement: string | undefined;
// computed
getRequiredPayload: any;
getRequiredProperties: any;
// actions
resetSession: () => void;
setTrackElement: (element: string) => void;
captureEvent: (eventName: string, payload: object | [] | null, group?: EventGroupProps) => void;
captureEvent: (eventName: string, payload?: any) => void;
joinWorkspaceMetricGroup: (workspaceId?: string) => void;
captureWorkspaceEvent: (props: EventProps) => void;
captureProjectEvent: (props: EventProps) => void;
captureCycleEvent: (props: EventProps) => void;
captureModuleEvent: (props: EventProps) => void;
capturePageEvent: (props: EventProps) => void;
captureIssueEvent: (props: IssueEventProps) => void;
captureProjectStateEvent: (props: EventProps) => void;
}
export class EventTrackerStore implements IEventTrackerStore {
trackElement: string = "";
trackElement: string | undefined = undefined;
rootStore;
constructor(_rootStore: RootStore) {
makeObservable(this, {
// properties
trackElement: observable,
// computed
getRequiredPayload: computed,
getRequiredProperties: computed,
// actions
resetSession: action,
setTrackElement: action,
captureEvent: action,
captureProjectEvent: action,
@ -48,12 +58,12 @@ export class EventTrackerStore implements IEventTrackerStore {
/**
* @description: Returns the necessary property for the event tracking
*/
get getRequiredPayload() {
get getRequiredProperties() {
const currentWorkspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
const currentProjectDetails = this.rootStore.projectRoot.project.currentProjectDetails;
return {
workspace_id: currentWorkspaceDetails?.id ?? "",
project_id: currentProjectDetails?.id ?? "",
workspace_id: currentWorkspaceDetails?.id,
project_id: currentProjectDetails?.id,
};
}
@ -61,42 +71,74 @@ export class EventTrackerStore implements IEventTrackerStore {
* @description: Set the trigger point of event.
* @param {string} element
*/
setTrackElement = (element: string) => {
setTrackElement = (element?: string) => {
this.trackElement = element;
};
postHogGroup = (group: EventGroupProps) => {
if (group && group!.isGrouping === true) {
posthog?.group(group!.groupType!, group!.groupId!, {
date: new Date(),
workspace_id: group!.groupId,
});
}
/**
* @description: Reset the session.
*/
resetSession = () => {
posthog?.reset();
};
captureEvent = (eventName: string, payload: object | [] | null) => {
posthog?.capture(eventName, {
...payload,
element: this.trackElement ?? "",
/**
* @description: Creates the workspace metric group.
* @param {string} userEmail
* @param {string} workspaceId
*/
joinWorkspaceMetricGroup = (workspaceId?: string) => {
if (!workspaceId) return;
posthog?.group(GROUP_WORKSPACE, workspaceId, {
date: new Date().toDateString(),
workspace_id: workspaceId,
});
};
/**
* @description: Captures the event.
* @param {string} eventName
* @param {any} payload
*/
captureEvent = (eventName: string, payload?: any) => {
posthog?.capture(eventName, {
...this.getRequiredProperties,
...payload,
element: payload?.element ?? this.trackElement,
});
this.setTrackElement(undefined);
};
/**
* @description: Captures the workspace crud related events.
* @param {EventProps} props
*/
captureWorkspaceEvent = (props: EventProps) => {
const { eventName, payload } = props;
if (eventName === WORKSPACE_CREATED && payload.state == "SUCCESS") {
this.joinWorkspaceMetricGroup(payload.id);
}
const eventPayload: any = getWorkspaceEventPayload({
...payload,
element: payload.element ?? this.trackElement,
});
posthog?.capture(eventName, eventPayload);
this.setTrackElement(undefined);
};
/**
* @description: Captures the project related events.
* @param {EventProps} props
*/
captureProjectEvent = (props: EventProps) => {
const { eventName, payload, group } = props;
if (group) {
this.postHogGroup(group);
}
const { eventName, payload } = props;
const eventPayload: any = getProjectEventPayload({
...this.getRequiredPayload,
...this.getRequiredProperties,
...payload,
element: payload.element ?? this.trackElement,
});
posthog?.capture(eventName, eventPayload);
this.setTrackElement("");
this.setTrackElement(undefined);
};
/**
@ -104,17 +146,14 @@ export class EventTrackerStore implements IEventTrackerStore {
* @param {EventProps} props
*/
captureCycleEvent = (props: EventProps) => {
const { eventName, payload, group } = props;
if (group) {
this.postHogGroup(group);
}
const { eventName, payload } = props;
const eventPayload: any = getCycleEventPayload({
...this.getRequiredPayload,
...this.getRequiredProperties,
...payload,
element: payload.element ?? this.trackElement,
});
posthog?.capture(eventName, eventPayload);
this.setTrackElement("");
this.setTrackElement(undefined);
};
/**
@ -122,17 +161,29 @@ export class EventTrackerStore implements IEventTrackerStore {
* @param {EventProps} props
*/
captureModuleEvent = (props: EventProps) => {
const { eventName, payload, group } = props;
if (group) {
this.postHogGroup(group);
}
const { eventName, payload } = props;
const eventPayload: any = getModuleEventPayload({
...this.getRequiredPayload,
...this.getRequiredProperties,
...payload,
element: payload.element ?? this.trackElement,
});
posthog?.capture(eventName, eventPayload);
this.setTrackElement("");
this.setTrackElement(undefined);
};
/**
* @description: Captures the project pages related events.
* @param {EventProps} props
*/
capturePageEvent = (props: EventProps) => {
const { eventName, payload } = props;
const eventPayload: any = getPageEventPayload({
...this.getRequiredProperties,
...payload,
element: payload.element ?? this.trackElement,
});
posthog?.capture(eventName, eventPayload);
this.setTrackElement(undefined);
};
/**
@ -140,16 +191,29 @@ export class EventTrackerStore implements IEventTrackerStore {
* @param {IssueEventProps} props
*/
captureIssueEvent = (props: IssueEventProps) => {
const { eventName, payload, group } = props;
if (group) {
this.postHogGroup(group);
}
const { eventName, payload } = props;
const eventPayload: any = {
...getIssueEventPayload(props),
...this.getRequiredPayload,
...this.getRequiredProperties,
state_group: this.rootStore.state.getStateById(payload.state_id)?.group ?? "",
element: payload.element ?? this.trackElement,
};
posthog?.capture(eventName, eventPayload);
this.setTrackElement(undefined);
};
/**
* @description: Captures the issue related events.
* @param {IssueEventProps} props
*/
captureProjectStateEvent = (props: EventProps) => {
const { eventName, payload } = props;
const eventPayload: any = getProjectStateEventPayload({
...this.getRequiredProperties,
...payload,
element: payload.element ?? this.trackElement,
});
posthog?.capture(eventName, eventPayload);
this.setTrackElement(undefined);
};
}

View File

@ -1,7 +1,7 @@
import sortBy from "lodash/sortBy";
import orderBy from "lodash/orderBy";
import get from "lodash/get";
import indexOf from "lodash/indexOf";
import reverse from "lodash/reverse";
import isEmpty from "lodash/isEmpty";
import values from "lodash/values";
// types
import { TIssue, TIssueMap, TIssueGroupByOptions, TIssueOrderByOptions } from "@plane/types";
@ -144,98 +144,189 @@ export class IssueHelperStore implements TIssueHelperStore {
issueDisplayFiltersDefaultData = (groupBy: string | null): string[] => {
switch (groupBy) {
case "state":
return this.rootStore?.states || [];
return Object.keys(this.rootStore?.stateMap || {});
case "state_detail.group":
return Object.keys(STATE_GROUPS);
case "priority":
return ISSUE_PRIORITIES.map((i) => i.key);
case "labels":
return this.rootStore?.labels || [];
return Object.keys(this.rootStore?.labelMap || {});
case "created_by":
return this.rootStore?.members || [];
return Object.keys(this.rootStore?.workSpaceMemberRolesMap || {});
case "assignees":
return this.rootStore?.members || [];
return Object.keys(this.rootStore?.workSpaceMemberRolesMap || {});
case "project":
return this.rootStore?.projects || [];
return Object.keys(this.rootStore?.projectMap || {});
default:
return [];
}
};
/**
* This Method is used to get data of the issue based on the ids of the data for states, labels adn assignees
* @param dataType what type of data is being sent
* @param dataIds id/ids of the data that is to be populated
* @param order ascending or descending for arrays of data
* @returns string | string[] of sortable fields to be used for sorting
*/
populateIssueDataForSorting(
dataType: "state_id" | "label_ids" | "assignee_ids",
dataIds: string | string[] | null | undefined,
order?: "asc" | "desc"
) {
if (!dataIds) return;
const dataValues: string[] = [];
const isDataIdsArray = Array.isArray(dataIds);
const dataIdsArray = isDataIdsArray ? dataIds : [dataIds];
switch (dataType) {
case "state_id":
const stateMap = this.rootStore?.stateMap;
if (!stateMap) break;
for (const dataId of dataIdsArray) {
const state = stateMap[dataId];
if (state && state.name) dataValues.push(state.name.toLocaleLowerCase());
}
break;
case "label_ids":
const labelMap = this.rootStore?.labelMap;
if (!labelMap) break;
for (const dataId of dataIdsArray) {
const label = labelMap[dataId];
if (label && label.name) dataValues.push(label.name.toLocaleLowerCase());
}
break;
case "assignee_ids":
const memberMap = this.rootStore?.memberMap;
if (!memberMap) break;
for (const dataId of dataIdsArray) {
const member = memberMap[dataId];
if (memberMap && member.first_name) dataValues.push(member.first_name.toLocaleLowerCase());
}
break;
}
return isDataIdsArray ? (order ? orderBy(dataValues, undefined, [order]) : dataValues) : dataValues[0];
}
/**
* This Method is mainly used to filter out empty values in the begining
* @param key key of the value that is to be checked if empty
* @param object any object in which the key's value is to be checked
* @returns 1 if emoty, 0 if not empty
*/
getSortOrderToFilterEmptyValues(key: string, object: any) {
const value = object?.[key];
if (typeof value !== "number" && isEmpty(value)) return 1;
return 0;
}
issuesSortWithOrderBy = (issueObject: TIssueMap, key: Partial<TIssueOrderByOptions>): TIssue[] => {
let array = values(issueObject);
array = reverse(sortBy(array, "created_at"));
array = orderBy(array, "created_at");
switch (key) {
case "sort_order":
return sortBy(array, "sort_order");
return orderBy(array, "sort_order");
case "state__name":
return reverse(sortBy(array, "state"));
return orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue["state_id"]));
case "-state__name":
return sortBy(array, "state");
return orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue["state_id"]), ["desc"]);
// dates
case "created_at":
return sortBy(array, "created_at");
return orderBy(array, "created_at");
case "-created_at":
return reverse(sortBy(array, "created_at"));
return orderBy(array, "created_at", ["desc"]);
case "updated_at":
return sortBy(array, "updated_at");
return orderBy(array, "updated_at");
case "-updated_at":
return reverse(sortBy(array, "updated_at"));
return orderBy(array, "updated_at", ["desc"]);
case "start_date":
return sortBy(array, "start_date");
return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"]); //preferring sorting based on empty values to always keep the empty values below
case "-start_date":
return reverse(sortBy(array, "start_date"));
return orderBy(
array,
[this.getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"], //preferring sorting based on empty values to always keep the empty values below
["asc", "desc"]
);
case "target_date":
return sortBy(array, "target_date");
return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"]); //preferring sorting based on empty values to always keep the empty values below
case "-target_date":
return reverse(sortBy(array, "target_date"));
return orderBy(
array,
[this.getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"], //preferring sorting based on empty values to always keep the empty values below
["asc", "desc"]
);
// custom
case "priority": {
const sortArray = ISSUE_PRIORITIES.map((i) => i.key);
return reverse(sortBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority)));
return orderBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority), ["desc"]);
}
case "-priority": {
const sortArray = ISSUE_PRIORITIES.map((i) => i.key);
return sortBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority));
return orderBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority));
}
// number
case "attachment_count":
return sortBy(array, "attachment_count");
return orderBy(array, "attachment_count");
case "-attachment_count":
return reverse(sortBy(array, "attachment_count"));
return orderBy(array, "attachment_count", ["desc"]);
case "estimate_point":
return sortBy(array, "estimate_point");
return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"]); //preferring sorting based on empty values to always keep the empty values below
case "-estimate_point":
return reverse(sortBy(array, "estimate_point"));
return orderBy(
array,
[this.getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"], //preferring sorting based on empty values to always keep the empty values below
["asc", "desc"]
);
case "link_count":
return sortBy(array, "link_count");
return orderBy(array, "link_count");
case "-link_count":
return reverse(sortBy(array, "link_count"));
return orderBy(array, "link_count", ["desc"]);
case "sub_issues_count":
return sortBy(array, "sub_issues_count");
return orderBy(array, "sub_issues_count");
case "-sub_issues_count":
return reverse(sortBy(array, "sub_issues_count"));
return orderBy(array, "sub_issues_count", ["desc"]);
// Array
case "labels__name":
return reverse(sortBy(array, "labels"));
return orderBy(array, [
this.getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below
(issue) => this.populateIssueDataForSorting("label_ids", issue["label_ids"], "asc"),
]);
case "-labels__name":
return sortBy(array, "labels");
return orderBy(
array,
[
this.getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below
(issue) => this.populateIssueDataForSorting("label_ids", issue["label_ids"], "desc"),
],
["asc", "desc"]
);
case "assignees__first_name":
return reverse(sortBy(array, "assignees"));
return orderBy(array, [
this.getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below
(issue) => this.populateIssueDataForSorting("assignee_ids", issue["assignee_ids"], "asc"),
]);
case "-assignees__first_name":
return sortBy(array, "assignees");
return orderBy(
array,
[
this.getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below
(issue) => this.populateIssueDataForSorting("assignee_ids", issue["assignee_ids"], "desc"),
],
["asc", "desc"]
);
default:
return array;

View File

@ -4,7 +4,7 @@ import isEmpty from "lodash/isEmpty";
import { RootStore } from "../root.store";
import { IStateStore, StateStore } from "../state.store";
// issues data store
import { IState } from "@plane/types";
import { IIssueLabel, IProject, IState, IUserLite } from "@plane/types";
import { IIssueStore, IssueStore } from "./issue.store";
import { IIssueDetail, IssueDetail } from "./issue-details/root.store";
import { IWorkspaceIssuesFilter, WorkspaceIssuesFilter, IWorkspaceIssues, WorkspaceIssues } from "./workspace";
@ -22,6 +22,7 @@ import { IArchivedIssuesFilter, ArchivedIssuesFilter, IArchivedIssues, ArchivedI
import { IDraftIssuesFilter, DraftIssuesFilter, IDraftIssues, DraftIssues } from "./draft";
import { IIssueKanBanViewStore, IssueKanBanViewStore } from "./issue_kanban_view.store";
import { ICalendarStore, CalendarStore } from "./issue_calendar_view.store";
import { IWorkspaceMembership } from "store/member/workspace-member.store";
export interface IIssueRootStore {
currentUserId: string | undefined;
@ -32,11 +33,12 @@ export interface IIssueRootStore {
viewId: string | undefined;
globalViewId: string | undefined; // all issues view id
userId: string | undefined; // user profile detail Id
states: string[] | undefined;
stateMap: Record<string, IState> | undefined;
stateDetails: IState[] | undefined;
labels: string[] | undefined;
members: string[] | undefined;
projects: string[] | undefined;
labelMap: Record<string, IIssueLabel> | undefined;
workSpaceMemberRolesMap: Record<string, IWorkspaceMembership> | undefined;
memberMap: Record<string, IUserLite> | undefined;
projectMap: Record<string, IProject> | undefined;
rootStore: RootStore;
@ -83,11 +85,12 @@ export class IssueRootStore implements IIssueRootStore {
viewId: string | undefined = undefined;
globalViewId: string | undefined = undefined;
userId: string | undefined = undefined;
states: string[] | undefined = undefined;
stateMap: Record<string, IState> | undefined = undefined;
stateDetails: IState[] | undefined = undefined;
labels: string[] | undefined = undefined;
members: string[] | undefined = undefined;
projects: string[] | undefined = undefined;
labelMap: Record<string, IIssueLabel> | undefined = undefined;
workSpaceMemberRolesMap: Record<string, IWorkspaceMembership> | undefined = undefined;
memberMap: Record<string, IUserLite> | undefined = undefined;
projectMap: Record<string, IProject> | undefined = undefined;
rootStore: RootStore;
@ -133,11 +136,12 @@ export class IssueRootStore implements IIssueRootStore {
viewId: observable.ref,
userId: observable.ref,
globalViewId: observable.ref,
states: observable,
stateMap: observable,
stateDetails: observable,
labels: observable,
members: observable,
projects: observable,
labelMap: observable,
memberMap: observable,
workSpaceMemberRolesMap: observable,
projectMap: observable,
});
this.rootStore = rootStore;
@ -151,13 +155,14 @@ export class IssueRootStore implements IIssueRootStore {
if (rootStore.app.router.viewId) this.viewId = rootStore.app.router.viewId;
if (rootStore.app.router.globalViewId) this.globalViewId = rootStore.app.router.globalViewId;
if (rootStore.app.router.userId) this.userId = rootStore.app.router.userId;
if (!isEmpty(rootStore?.state?.stateMap)) this.states = Object.keys(rootStore?.state?.stateMap);
if (!isEmpty(rootStore?.state?.stateMap)) this.stateMap = rootStore?.state?.stateMap;
if (!isEmpty(rootStore?.state?.projectStates)) this.stateDetails = rootStore?.state?.projectStates;
if (!isEmpty(rootStore?.label?.labelMap)) this.labels = Object.keys(rootStore?.label?.labelMap);
if (!isEmpty(rootStore?.label?.labelMap)) this.labelMap = rootStore?.label?.labelMap;
if (!isEmpty(rootStore?.memberRoot?.workspace?.workspaceMemberMap))
this.members = Object.keys(rootStore?.memberRoot?.workspace?.workspaceMemberMap);
this.workSpaceMemberRolesMap = rootStore?.memberRoot?.workspace?.memberMap || undefined;
if (!isEmpty(rootStore?.memberRoot?.memberMap)) this.memberMap = rootStore?.memberRoot?.memberMap || undefined;
if (!isEmpty(rootStore?.projectRoot?.project?.projectMap))
this.projects = Object.keys(rootStore?.projectRoot?.project?.projectMap);
this.projectMap = rootStore?.projectRoot?.project?.projectMap;
});
this.issues = new IssueStore();

View File

@ -26,6 +26,7 @@ export interface IWorkspaceMemberStore {
// computed
workspaceMemberIds: string[] | null;
workspaceMemberInvitationIds: string[] | null;
memberMap: Record<string, IWorkspaceMembership> | null;
// computed actions
getSearchedWorkspaceMemberIds: (searchQuery: string) => string[] | null;
getSearchedWorkspaceInvitationIds: (searchQuery: string) => string[] | null;
@ -68,6 +69,7 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
// computed
workspaceMemberIds: computed,
workspaceMemberInvitationIds: computed,
memberMap: computed,
// actions
fetchWorkspaceMembers: action,
updateMember: action,
@ -100,6 +102,12 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
return memberIds;
}
get memberMap() {
const workspaceSlug = this.routerStore.workspaceSlug;
if (!workspaceSlug) return null;
return this.workspaceMemberMap?.[workspaceSlug] ?? {};
}
get workspaceMemberInvitationIds() {
const workspaceSlug = this.routerStore.workspaceSlug;
if (!workspaceSlug) return null;

View File

@ -250,6 +250,7 @@ export class UserRootStore implements IUserRootStore {
this.isUserLoggedIn = false;
});
this.membership = new UserMembershipStore(this.rootStore);
this.rootStore.eventTracker.resetSession();
this.rootStore.resetOnSignout();
});
@ -264,6 +265,7 @@ export class UserRootStore implements IUserRootStore {
this.isUserLoggedIn = false;
});
this.membership = new UserMembershipStore(this.rootStore);
this.rootStore.eventTracker.resetSession();
this.rootStore.resetOnSignout();
});
}