Merge branch 'develop' of github.com:makeplane/plane into update-file-uploads

This commit is contained in:
pablohashescobar 2024-02-12 11:37:47 +05:30
commit c25ac3b3a6
145 changed files with 3507 additions and 1539 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

@ -20,6 +20,7 @@ from django.core import serializers
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework.response import Response
@ -312,6 +313,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"labels": label_distribution,
"completion_chart": {},
}
if data[0]["start_date"] and data[0]["end_date"]:
data[0]["distribution"][
"completion_chart"
@ -840,10 +842,230 @@ class TransferCycleIssueEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
new_cycle = Cycle.objects.get(
new_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
).first()
old_cycle = (
Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
)
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
cycle_id=cycle_id,
)
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None,
"avatar": item["avatar"],
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": str(item["label_id"]) if item["label_id"] else None,
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
current_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
).first()
current_cycle.progress_snapshot = {
"total_issues": old_cycle.first().total_issues,
"completed_issues": old_cycle.first().completed_issues,
"cancelled_issues": old_cycle.first().cancelled_issues,
"started_issues": old_cycle.first().started_issues,
"unstarted_issues": old_cycle.first().unstarted_issues,
"backlog_issues": old_cycle.first().backlog_issues,
"total_estimates": old_cycle.first().total_estimates,
"completed_estimates": old_cycle.first().completed_estimates,
"started_estimates": old_cycle.first().started_estimates,
"distribution":{
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
}
current_cycle.save(update_fields=["progress_snapshot"])
if (
new_cycle.end_date is not None
and new_cycle.end_date < timezone.now().date()

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

@ -0,0 +1,33 @@
# Generated by Django 4.2.7 on 2024-02-08 09:57
from django.db import migrations
def widgets_filter_change(apps, schema_editor):
Widget = apps.get_model("db", "Widget")
widgets_to_update = []
# Define the filter dictionaries for each widget key
filters_mapping = {
"assigned_issues": {"duration": "none", "tab": "pending"},
"created_issues": {"duration": "none", "tab": "pending"},
"issues_by_state_groups": {"duration": "none"},
"issues_by_priority": {"duration": "none"},
}
# Iterate over widgets and update filters if applicable
for widget in Widget.objects.all():
if widget.key in filters_mapping:
widget.filters = filters_mapping[widget.key]
widgets_to_update.append(widget)
# Bulk update the widgets
Widget.objects.bulk_update(widgets_to_update, ["filters"], batch_size=10)
class Migration(migrations.Migration):
dependencies = [
('db', '0058_alter_moduleissue_issue_and_more'),
]
operations = [
migrations.RunPython(widgets_filter_change)
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-02-08 09:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0059_auto_20240208_0957'),
]
operations = [
migrations.AddField(
model_name='cycle',
name='progress_snapshot',
field=models.JSONField(default=dict),
),
]

View File

@ -68,6 +68,7 @@ class Cycle(ProjectBaseModel):
sort_order = models.FloatField(default=65535)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
progress_snapshot = models.JSONField(default=dict)
class Meta:
verbose_name = "Cycle"

View File

@ -31,6 +31,7 @@ export interface ICycle {
issue: string;
name: string;
owned_by: string;
progress_snapshot: TProgressSnapshot;
project: string;
project_detail: IProjectLite;
status: TCycleGroups;
@ -49,6 +50,23 @@ export interface ICycle {
workspace_detail: IWorkspaceLite;
}
export type TProgressSnapshot = {
backlog_issues: number;
cancelled_issues: number;
completed_estimates: number | null;
completed_issues: number;
distribution?: {
assignees: TAssigneesDistribution[];
completion_chart: TCompletionChartDistribution;
labels: TLabelsDistribution[];
};
started_estimates: number | null;
started_issues: number;
total_estimates: number | null;
total_issues: number;
unstarted_issues: number;
};
export type TAssigneesDistribution = {
assignee_id: string | null;
avatar: string | null;

View File

@ -24,21 +24,21 @@ export type TDurationFilterOptions =
// widget filters
export type TAssignedIssuesWidgetFilters = {
target_date?: TDurationFilterOptions;
duration?: TDurationFilterOptions;
tab?: TIssuesListTypes;
};
export type TCreatedIssuesWidgetFilters = {
target_date?: TDurationFilterOptions;
duration?: TDurationFilterOptions;
tab?: TIssuesListTypes;
};
export type TIssuesByStateGroupsWidgetFilters = {
target_date?: TDurationFilterOptions;
duration?: TDurationFilterOptions;
};
export type TIssuesByPriorityWidgetFilters = {
target_date?: TDurationFilterOptions;
duration?: TDurationFilterOptions;
};
export type TWidgetFiltersFormData =

View File

@ -221,3 +221,12 @@ export interface IGroupByColumn {
export interface IIssueMap {
[key: string]: TIssue;
}
export interface IIssueListRow {
id: string;
groupId: string;
type: "HEADER" | "NO_ISSUES" | "QUICK_ADD" | "ISSUE";
name?: string;
icon?: ReactElement | undefined;
payload?: Partial<TIssue>;
}

View File

@ -10,7 +10,7 @@ type BreadcrumbsProps = {
const Breadcrumbs = ({ children }: BreadcrumbsProps) => (
<div className="flex items-center space-x-2">
{React.Children.map(children, (child, index) => (
<div key={index} className="flex flex-wrap items-center gap-2.5">
<div key={index} className="flex items-center gap-2.5">
{child}
{index !== React.Children.count(children) - 1 && (
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />

View File

@ -54,10 +54,6 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
setIsOpen(false);
};
const handleOnChange = () => {
if (closeOnSelect) closeDropdown();
};
const selectActiveItem = () => {
const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector(
`[data-headlessui-state="active"] button`
@ -66,6 +62,11 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
};
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem);
const handleOnClick = () => {
if (closeOnSelect) closeDropdown();
};
useOutsideClickDetector(dropdownRef, closeDropdown);
let menuItems = (
@ -101,7 +102,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
tabIndex={tabIndex}
className={cn("relative w-min text-left", className)}
onKeyDownCapture={handleKeyDown}
onChange={handleOnChange}
onClick={handleOnClick}
>
{({ open }) => (
<>
@ -110,7 +111,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
<button
ref={setReferenceElement}
type="button"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}
@ -127,7 +129,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
<button
ref={setReferenceElement}
type="button"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}
@ -152,7 +155,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
onClick={() => {
onClick={(e) => {
e.stopPropagation();
openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}

View File

@ -0,0 +1,67 @@
import * as React from "react";
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
intermediate?: boolean;
className?: string;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref) => {
const { id, name, checked, intermediate = false, disabled, className = "", ...rest } = props;
return (
<div className={`relative w-full flex gap-2 ${className}`}>
<input
id={id}
ref={ref}
type="checkbox"
name={name}
checked={checked}
className={`
appearance-none shrink-0 w-4 h-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50
${
disabled
? "border-custom-border-200 bg-custom-background-80 cursor-not-allowed"
: `cursor-pointer ${
checked || intermediate
? "border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200"
: "border-custom-border-300 hover:border-custom-border-400 bg-white"
}`
}
`}
disabled={disabled}
{...rest}
/>
<svg
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
} ${checked ? "block" : "hidden"}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
<svg
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
} ${intermediate && !checked ? "block" : "hidden"}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5.75 4H2.25" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
);
});
Checkbox.displayName = "form-checkbox-field";
export { Checkbox };

View File

@ -1,3 +1,4 @@
export * from "./input";
export * from "./textarea";
export * from "./input-color-picker";
export * from "./checkbox";

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-custom-primary-100"
>

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-custom-primary-100 font-medium underline">
<Link
href="/accounts/sign-up"
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
className="text-custom-primary-100 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-custom-primary-100 font-medium underline">
<Link
href="/"
onClick={() => captureEvent(NAVIGATE_TO_SIGNIN, {})}
className="text-custom-primary-100 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

@ -10,6 +10,8 @@ import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSi
import { IAnalyticsParams } from "@plane/types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
import { cn } from "helpers/common.helper";
import { useApplication } from "hooks/store";
type Props = {
additionalParams?: Partial<IAnalyticsParams>;
@ -46,11 +48,13 @@ export const CustomAnalytics: React.FC<Props> = observer((props) => {
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
);
const { theme: themeStore } = useApplication();
const isProjectLevel = projectId ? true : false;
return (
<div className={`flex flex-col-reverse overflow-hidden ${fullScreen ? "md:grid md:h-full md:grid-cols-4" : ""}`}>
<div className="col-span-3 flex h-full flex-col overflow-hidden">
<div className={cn("relative w-full h-full flex overflow-hidden", isProjectLevel ? "flex-col-reverse" : "")}>
<div className="w-full flex h-full flex-col overflow-hidden">
<CustomAnalyticsSelectBar
control={control}
setValue={setValue}
@ -61,16 +65,22 @@ export const CustomAnalytics: React.FC<Props> = observer((props) => {
<CustomAnalyticsMainContent
analytics={analytics}
error={analyticsError}
fullScreen={fullScreen}
params={params}
fullScreen={fullScreen}
/>
</div>
<CustomAnalyticsSidebar
analytics={analytics}
params={params}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
/>
<div
className={cn(
"border-l border-custom-border-200 transition-all",
!isProjectLevel
? "absolute right-0 top-0 bottom-0 md:relative flex-shrink-0 h-full max-w-[250px] sm:max-w-full"
: ""
)}
style={themeStore.workspaceAnalyticsSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}}
>
<CustomAnalyticsSidebar analytics={analytics} params={params} isProjectLevel={isProjectLevel} />
</div>
</div>
);
});

View File

@ -22,9 +22,8 @@ export const CustomAnalyticsSelectBar: React.FC<Props> = observer((props) => {
return (
<div
className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-3" : "grid-cols-2"} ${
fullScreen ? "md:py-5 lg:grid-cols-4" : ""
}`}
className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-1 sm:grid-cols-3" : "grid-cols-2"} ${fullScreen ? "md:py-5 lg:grid-cols-4" : ""
}`}
>
{!isProjectLevel && (
<div>

View File

@ -17,9 +17,9 @@ export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((pro
const { getProjectById } = useProject();
return (
<div className="hidden h-full overflow-hidden md:flex md:flex-col">
<div className="relative flex flex-col gap-4 h-full">
<h4 className="font-medium">Selected Projects</h4>
<div className="mt-4 h-full space-y-6 overflow-y-auto">
<div className="relative space-y-6 overflow-hidden overflow-y-auto">
{projectIds.map((projectId) => {
const project = getProjectById(projectId);

View File

@ -26,7 +26,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
<>
{projectId ? (
cycleDetails ? (
<div className="hidden h-full overflow-y-auto md:block">
<div className="h-full overflow-y-auto">
<h4 className="break-words font-medium">Analytics for {cycleDetails.name}</h4>
<div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs">
@ -52,7 +52,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
</div>
</div>
) : moduleDetails ? (
<div className="hidden h-full overflow-y-auto md:block">
<div className="h-full overflow-y-auto">
<h4 className="break-words font-medium">Analytics for {moduleDetails.name}</h4>
<div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs">
@ -78,7 +78,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
</div>
</div>
) : (
<div className="hidden h-full overflow-y-auto md:flex md:flex-col">
<div className="h-full overflow-y-auto">
<div className="flex items-center gap-1">
{projectDetails?.emoji ? (
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.emoji)}</div>

View File

@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
@ -19,18 +19,18 @@ import { renderFormattedDate } from "helpers/date-time.helper";
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
import { cn } from "helpers/common.helper";
type Props = {
analytics: IAnalyticsResponse | undefined;
params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean;
};
const analyticsService = new AnalyticsService();
export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
const { analytics, params, fullScreen, isProjectLevel = false } = props;
const { analytics, params, isProjectLevel = false } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
@ -138,18 +138,14 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds;
return (
<div
className={`flex items-center justify-between space-y-2 px-5 py-2.5 ${
fullScreen
? "overflow-hidden border-l border-custom-border-200 md:h-full md:flex-col md:items-start md:space-y-4 md:border-l md:border-custom-border-200 md:py-5"
: ""
}`}
<div className={cn("relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100", !isProjectLevel ? "flex-col" : "")}
>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
<LayersIcon height={14} width={14} />
{analytics ? analytics.total : "..."} Issues
{analytics ? analytics.total : "..."} <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
</div>
{isProjectLevel && (
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
@ -158,36 +154,36 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
(cycleId
? cycleDetails?.created_at
: moduleId
? moduleDetails?.created_at
: projectDetails?.created_at) ?? ""
? moduleDetails?.created_at
: projectDetails?.created_at) ?? ""
)}
</div>
)}
</div>
<div className="h-full w-full overflow-hidden">
{fullScreen ? (
<>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
)}
<CustomAnalyticsSidebarHeader />
</>
) : null}
<div className={cn("h-full w-full overflow-hidden", isProjectLevel ? "hidden" : "block")}>
<>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
)}
<CustomAnalyticsSidebarHeader />
</>
</div>
<div className="flex flex-wrap items-center gap-2 justify-self-end">
<div className="flex flex-wrap items-center gap-2 justify-end">
<Button
variant="neutral-primary"
prependIcon={<RefreshCw className="h-3.5 w-3.5" />}
prependIcon={<RefreshCw className="h-3 md:h-3.5 w-3 md:w-3.5" />}
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
Refresh
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Refresh</div>
</Button>
<Button variant="primary" prependIcon={<Download className="h-3.5 w-3.5" />} onClick={exportAnalytics}>
Export as CSV
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Export as CSV</div>
</Button>
</div>
</div>

View File

@ -20,16 +20,15 @@ export const ProjectAnalyticsModalMainContent: React.FC<Props> = observer((props
return (
<Tab.Group as={React.Fragment}>
<Tab.List as="div" className="space-x-2 border-b border-custom-border-200 p-5 pt-0">
<Tab.List as="div" className="flex space-x-2 border-b border-custom-border-200 px-0 md:px-5 py-0 md:py-3">
{ANALYTICS_TABS.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${
selected ? "bg-custom-background-80" : ""
`rounded-0 w-full md:w-max md:rounded-3xl border-b md:border border-custom-border-200 focus:outline-none px-0 md:px-4 py-2 text-xs hover:bg-custom-background-80 ${selected ? "border-custom-primary-100 text-custom-primary-100 md:bg-custom-background-80 md:text-custom-text-200 md:border-custom-border-200" : "border-transparent"
}`
}
onClick={() => {}}
onClick={() => { }}
>
{tab.title}
</Tab>

View File

@ -0,0 +1,80 @@
import { cn } from "helpers/common.helper";
import React, { useState, useRef, useEffect, ReactNode, MutableRefObject } from "react";
type Props = {
defaultHeight?: string;
verticalOffset?: number;
horizonatlOffset?: number;
root?: MutableRefObject<HTMLElement | null>;
children: ReactNode;
as?: keyof JSX.IntrinsicElements;
classNames?: string;
alwaysRender?: boolean;
placeholderChildren?: ReactNode;
pauseHeightUpdateWhileRendering?: boolean;
changingReference?: any;
};
const RenderIfVisible: React.FC<Props> = (props) => {
const {
defaultHeight = "300px",
root,
verticalOffset = 50,
horizonatlOffset = 0,
as = "div",
children,
classNames = "",
alwaysRender = false, //render the children even if it is not visble in root
placeholderChildren = null, //placeholder children
pauseHeightUpdateWhileRendering = false, //while this is true the height of the blocks are maintained
changingReference, //This is to force render when this reference is changed
} = props;
const [shouldVisible, setShouldVisible] = useState<boolean>(alwaysRender);
const placeholderHeight = useRef<string>(defaultHeight);
const intersectionRef = useRef<HTMLElement | null>(null);
const isVisible = alwaysRender || shouldVisible;
// Set visibility with intersection observer
useEffect(() => {
if (intersectionRef.current) {
const observer = new IntersectionObserver(
(entries) => {
if (typeof window !== undefined && window.requestIdleCallback) {
window.requestIdleCallback(() => setShouldVisible(entries[0].isIntersecting), {
timeout: 300,
});
} else {
setShouldVisible(entries[0].isIntersecting);
}
},
{
root: root?.current,
rootMargin: `${verticalOffset}% ${horizonatlOffset}% ${verticalOffset}% ${horizonatlOffset}%`,
}
);
observer.observe(intersectionRef.current);
return () => {
if (intersectionRef.current) {
observer.unobserve(intersectionRef.current);
}
};
}
}, [root?.current, intersectionRef, children, changingReference]);
//Set height after render
useEffect(() => {
if (intersectionRef.current && isVisible) {
placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`;
}
}, [isVisible, intersectionRef, alwaysRender, pauseHeightUpdateWhileRendering]);
const child = isVisible ? <>{children}</> : placeholderChildren;
const style =
isVisible && !pauseHeightUpdateWhileRendering ? {} : { height: placeholderHeight.current, width: "100%" };
const className = isVisible ? classNames : cn(classNames, "bg-custom-background-80");
return React.createElement(as, { ref: intersectionRef, style, className }, child);
};
export default RenderIfVisible;

View File

@ -3,7 +3,7 @@ import { Menu } from "lucide-react";
import { useApplication } from "hooks/store";
import { observer } from "mobx-react";
export const SidebarHamburgerToggle: FC = observer (() => {
export const SidebarHamburgerToggle: FC = observer(() => {
const { theme: themStore } = useApplication();
return (
<div

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-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90">
<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-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 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-custom-text-400" />
</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-custom-text-300">
{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 ? (

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

@ -3,6 +3,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { useForm } from "react-hook-form";
import { Disclosure, Popover, Transition } from "@headlessui/react";
import isEmpty from "lodash/isEmpty";
// services
import { CycleService } from "services/cycle.service";
// hooks
@ -38,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";
@ -66,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();
@ -82,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 = () => {
@ -145,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!",
@ -173,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!",
@ -218,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!",
@ -245,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!",
@ -293,7 +329,11 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const isEndValid = new Date(`${cycleDetails?.end_date}`) >= new Date(`${cycleDetails?.start_date}`);
const progressPercentage = cycleDetails
? Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100)
? isCompleted
? Math.round(
(cycleDetails.progress_snapshot.completed_issues / cycleDetails.progress_snapshot.total_issues) * 100
)
: Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100)
: null;
if (!cycleDetails)
@ -317,7 +357,14 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const issueCount =
cycleDetails.total_issues === 0 ? "0 Issue" : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
isCompleted && !isEmpty(cycleDetails.progress_snapshot)
? cycleDetails.progress_snapshot.total_issues === 0
? "0 Issue"
: `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}`
: cycleDetails.total_issues === 0
? "0 Issue"
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
@ -568,49 +615,105 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<Transition show={open}>
<Disclosure.Panel>
<div className="flex flex-col gap-3">
{cycleDetails.distribution?.completion_chart &&
cycleDetails.start_date &&
cycleDetails.end_date ? (
<div className="h-full w-full pt-4">
<div className="flex items-start gap-4 py-2 text-xs">
<div className="flex items-center gap-3 text-custom-text-100">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
{isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? (
<>
{cycleDetails.progress_snapshot.distribution?.completion_chart &&
cycleDetails.start_date &&
cycleDetails.end_date && (
<div className="h-full w-full pt-4">
<div className="flex items-start gap-4 py-2 text-xs">
<div className="flex items-center gap-3 text-custom-text-100">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
</div>
<div className="relative h-40 w-80">
<ProgressChart
distribution={cycleDetails.progress_snapshot.distribution?.completion_chart}
startDate={cycleDetails.start_date}
endDate={cycleDetails.end_date}
totalIssues={cycleDetails.progress_snapshot.total_issues}
/>
</div>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
</div>
<div className="relative h-40 w-80">
<ProgressChart
distribution={cycleDetails.distribution?.completion_chart}
startDate={cycleDetails.start_date}
endDate={cycleDetails.end_date}
totalIssues={cycleDetails.total_issues}
/>
</div>
</div>
)}
</>
) : (
""
<>
{cycleDetails.distribution?.completion_chart &&
cycleDetails.start_date &&
cycleDetails.end_date && (
<div className="h-full w-full pt-4">
<div className="flex items-start gap-4 py-2 text-xs">
<div className="flex items-center gap-3 text-custom-text-100">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
</div>
<div className="relative h-40 w-80">
<ProgressChart
distribution={cycleDetails.distribution?.completion_chart}
startDate={cycleDetails.start_date}
endDate={cycleDetails.end_date}
totalIssues={cycleDetails.total_issues}
/>
</div>
</div>
)}
</>
)}
{cycleDetails.total_issues > 0 && cycleDetails.distribution && (
<div className="h-full w-full border-t border-custom-border-200 pt-5">
<SidebarProgressStats
distribution={cycleDetails.distribution}
groupedIssues={{
backlog: cycleDetails.backlog_issues,
unstarted: cycleDetails.unstarted_issues,
started: cycleDetails.started_issues,
completed: cycleDetails.completed_issues,
cancelled: cycleDetails.cancelled_issues,
}}
totalIssues={cycleDetails.total_issues}
isPeekView={Boolean(peekCycle)}
/>
</div>
{/* stats */}
{isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? (
<>
{cycleDetails.progress_snapshot.total_issues > 0 &&
cycleDetails.progress_snapshot.distribution && (
<div className="h-full w-full border-t border-custom-border-200 pt-5">
<SidebarProgressStats
distribution={cycleDetails.progress_snapshot.distribution}
groupedIssues={{
backlog: cycleDetails.progress_snapshot.backlog_issues,
unstarted: cycleDetails.progress_snapshot.unstarted_issues,
started: cycleDetails.progress_snapshot.started_issues,
completed: cycleDetails.progress_snapshot.completed_issues,
cancelled: cycleDetails.progress_snapshot.cancelled_issues,
}}
totalIssues={cycleDetails.progress_snapshot.total_issues}
isPeekView={Boolean(peekCycle)}
/>
</div>
)}
</>
) : (
<>
{cycleDetails.total_issues > 0 && cycleDetails.distribution && (
<div className="h-full w-full border-t border-custom-border-200 pt-5">
<SidebarProgressStats
distribution={cycleDetails.distribution}
groupedIssues={{
backlog: cycleDetails.backlog_issues,
unstarted: cycleDetails.unstarted_issues,
started: cycleDetails.started_issues,
completed: cycleDetails.completed_issues,
cancelled: cycleDetails.cancelled_issues,
}}
totalIssues={cycleDetails.total_issues}
isPeekView={Boolean(peekCycle)}
/>
</div>
)}
</>
)}
</div>
</Disclosure.Panel>

View File

@ -13,7 +13,7 @@ import {
WidgetProps,
} from "components/dashboard/widgets";
// helpers
import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper";
import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper";
// types
import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
// constants
@ -30,8 +30,8 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
// derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedTab = widgetDetails?.widget_filters.tab ?? "pending";
const selectedDurationFilter = widgetDetails?.widget_filters.target_date ?? "none";
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? "none";
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => {
if (!widgetDetails) return;
@ -43,7 +43,7 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
filters,
});
const filterDates = getCustomDates(filters.target_date ?? selectedDurationFilter);
const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
issue_type: filters.tab ?? selectedTab,
@ -86,19 +86,19 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
// switch to pending tab if target date is changed to none
if (val === "none" && selectedTab !== "completed") {
handleUpdateFilters({ target_date: val, tab: "pending" });
handleUpdateFilters({ duration: val, tab: "pending" });
return;
}
// switch to upcoming tab if target date is changed to other than none
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") {
handleUpdateFilters({
target_date: val,
duration: val,
tab: "upcoming",
});
return;
}
handleUpdateFilters({ target_date: val });
handleUpdateFilters({ duration: val });
}}
/>
</div>

View File

@ -13,7 +13,7 @@ import {
WidgetProps,
} from "components/dashboard/widgets";
// helpers
import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper";
import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper";
// types
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
// constants
@ -30,8 +30,8 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
// derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedTab = widgetDetails?.widget_filters.tab ?? "pending";
const selectedDurationFilter = widgetDetails?.widget_filters.target_date ?? "none";
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? "none";
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
if (!widgetDetails) return;
@ -43,7 +43,7 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
filters,
});
const filterDates = getCustomDates(filters.target_date ?? selectedDurationFilter);
const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
issue_type: filters.tab ?? selectedTab,
@ -83,19 +83,19 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
// switch to pending tab if target date is changed to none
if (val === "none" && selectedTab !== "completed") {
handleUpdateFilters({ target_date: val, tab: "pending" });
handleUpdateFilters({ duration: val, tab: "pending" });
return;
}
// switch to upcoming tab if target date is changed to other than none
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") {
handleUpdateFilters({
target_date: val,
duration: val,
tab: "upcoming",
});
return;
}
handleUpdateFilters({ target_date: val });
handleUpdateFilters({ duration: val });
}}
/>
</div>

View File

@ -16,42 +16,40 @@ export const TabsList: React.FC<Props> = observer((props) => {
const { durationFilter, selectedTab } = props;
const tabsList = durationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === (selectedTab ?? "pending"));
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
return (
<Tab.List
as="div"
className="relative border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 grid"
className="relative border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 p-[1px] grid"
style={{
gridTemplateColumns: `repeat(${tabsList.length}, 1fr)`,
}}
>
<div
className={cn("absolute bg-custom-background-100 rounded transition-all duration-500 ease-in-out", {
// right shadow
"shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1,
// left shadow
"shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0,
})}
className={cn(
"absolute top-1/2 left-[1px] bg-custom-background-100 rounded-[3px] transition-all duration-500 ease-in-out",
{
// right shadow
"shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1,
// left shadow
"shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0,
}
)}
style={{
height: "calc(100% - 1px)",
width: `${100 / tabsList.length}%`,
transform: `translateX(${selectedTabIndex * 100}%)`,
height: "calc(100% - 2px)",
width: `calc(${100 / tabsList.length}% - 1px)`,
transform: `translate(${selectedTabIndex * 100}%, -50%)`,
}}
/>
{tabsList.map((tab) => (
<Tab
key={tab.key}
className={cn(
"relative z-[1] font-semibold text-xs rounded py-1.5 text-custom-text-400 focus:outline-none",
"transition duration-500",
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
{
"text-custom-text-100 bg-custom-background-100": selectedTab === tab.key,
"hover:text-custom-text-300": selectedTab !== tab.key,
// // right shadow
// "shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1,
// // left shadow
// "shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0,
}
)}
>

View File

@ -73,8 +73,10 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
const { dashboardId, workspaceSlug } = props;
// store hooks
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard();
// derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TIssuesByPriorityWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedDuration = widgetDetails?.widget_filters.duration ?? "none";
const handleUpdateFilters = async (filters: Partial<TIssuesByPriorityWidgetFilters>) => {
if (!widgetDetails) return;
@ -84,7 +86,7 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
filters,
});
const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none");
const filterDates = getCustomDates(filters.duration ?? selectedDuration);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
@ -92,7 +94,7 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
};
useEffect(() => {
const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none");
const filterDates = getCustomDates(selectedDuration);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
@ -139,10 +141,10 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
Assigned by priority
</Link>
<DurationFilterDropdown
value={widgetDetails.widget_filters.target_date ?? "none"}
value={selectedDuration}
onChange={(val) =>
handleUpdateFilters({
target_date: val,
duration: val,
})
}
/>

View File

@ -34,6 +34,7 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
// derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TIssuesByStateGroupsWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedDuration = widgetDetails?.widget_filters.duration ?? "none";
const handleUpdateFilters = async (filters: Partial<TIssuesByStateGroupsWidgetFilters>) => {
if (!widgetDetails) return;
@ -43,7 +44,7 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
filters,
});
const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none");
const filterDates = getCustomDates(filters.duration ?? selectedDuration);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
@ -52,7 +53,7 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
// fetch widget stats
useEffect(() => {
const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none");
const filterDates = getCustomDates(selectedDuration);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
@ -138,10 +139,10 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
Assigned by state
</Link>
<DurationFilterDropdown
value={widgetDetails.widget_filters.target_date ?? "none"}
value={selectedDuration}
onChange={(val) =>
handleUpdateFilters({
target_date: val,
duration: val,
})
}
/>

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

@ -1,18 +1,78 @@
// ui
import { Breadcrumbs } from "@plane/ui";
import { Breadcrumbs, CustomMenu } from "@plane/ui";
import { BreadcrumbLink } from "components/common";
// components
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
import { cn } from "helpers/common.helper";
import { FC } from "react";
import { useApplication, useUser } from "hooks/store";
import { ChevronDown, PanelRight } from "lucide-react";
import { observer } from "mobx-react-lite";
import { PROFILE_ADMINS_TAB, PROFILE_VIEWER_TAB } from "constants/profile";
import Link from "next/link";
import { useRouter } from "next/router";
export const UserProfileHeader = () => (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
type TUserProfileHeader = {
type?: string | undefined
}
export const UserProfileHeader: FC<TUserProfileHeader> = observer((props) => {
const { type = undefined } = props
const router = useRouter();
const { workspaceSlug, userId } = router.query;
const AUTHORIZED_ROLES = [20, 15, 10];
const {
membership: { currentWorkspaceRole },
} = useUser();
if (!currentWorkspaceRole) return null;
const isAuthorized = AUTHORIZED_ROLES.includes(currentWorkspaceRole);
const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB;
const { theme: themStore } = useApplication();
return (<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<SidebarHamburgerToggle />
<div>
<div className="flex justify-between w-full">
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink href="/profile" label="Activity Overview" />} />
</Breadcrumbs>
<div className="flex gap-4 md:hidden">
<CustomMenu
maxHeight={"md"}
className="flex flex-grow justify-center text-custom-text-200 text-sm"
placement="bottom-start"
customButton={
<div className="flex gap-2 items-center px-2 py-1.5 border border-custom-border-400 rounded-md">
<span className="flex flex-grow justify-center text-custom-text-200 text-sm">{type}</span>
<ChevronDown className="w-4 h-4 text-custom-text-400" />
</div>
}
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
closeOnSelect
>
<></>
{tabsList.map((tab) => (
<CustomMenu.MenuItem
className="flex items-center gap-2"
>
<Link key={tab.route} href={`/${workspaceSlug}/profile/${userId}/${tab.route}`} className="text-custom-text-300 w-full">{tab.label}</Link>
</CustomMenu.MenuItem>
))}
</CustomMenu>
<button className="transition-all block md:hidden" onClick={() => { themStore.toggleProfileSidebar() }}>
<PanelRight className={
cn("w-4 h-4 block md:hidden", !themStore.profileSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")
} />
</button>
</div>
</div>
</div>
</div>
);
</div>)
});

View File

@ -1,13 +1,35 @@
import { useRouter } from "next/router";
import { ArrowLeft, BarChart2 } from "lucide-react";
import { BarChart2, PanelRight } from "lucide-react";
// ui
import { Breadcrumbs } from "@plane/ui";
// components
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
import { BreadcrumbLink } from "components/common";
import { useApplication } from "hooks/store";
import { observer } from "mobx-react";
import { cn } from "helpers/common.helper";
import { useEffect } from "react";
export const WorkspaceAnalyticsHeader = () => {
export const WorkspaceAnalyticsHeader = observer(() => {
const router = useRouter();
const { analytics_tab } = router.query;
const { theme: themeStore } = useApplication();
useEffect(() => {
const handleToggleWorkspaceAnalyticsSidebar = () => {
if (window && window.innerWidth < 768) {
themeStore.toggleWorkspaceAnalyticsSidebar(true);
}
if (window && themeStore.workspaceAnalyticsSidebarCollapsed && window.innerWidth >= 768) {
themeStore.toggleWorkspaceAnalyticsSidebar(false);
}
};
window.addEventListener("resize", handleToggleWorkspaceAnalyticsSidebar);
handleToggleWorkspaceAnalyticsSidebar();
return () => window.removeEventListener("resize", handleToggleWorkspaceAnalyticsSidebar);
}, [themeStore]);
return (
<>
@ -16,7 +38,7 @@ export const WorkspaceAnalyticsHeader = () => {
>
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<SidebarHamburgerToggle />
<div>
<div className="flex items-center justify-between w-full">
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
@ -25,9 +47,14 @@ export const WorkspaceAnalyticsHeader = () => {
}
/>
</Breadcrumbs>
{analytics_tab === 'custom' &&
<button className="block md:hidden" onClick={() => { themeStore.toggleWorkspaceAnalyticsSidebar() }}>
<PanelRight className={cn("w-4 h-4 block md:hidden", !themeStore.workspaceAnalyticsSidebarCollapsed ? "text-custom-primary-100" : "text-custom-text-200")} />
</button>
}
</div>
</div>
</div>
</>
);
};
});

View File

@ -4,13 +4,18 @@ 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 (
@ -31,16 +36,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-custom-background-80 px-3 py-1.5"
>
<Zap size={14} strokeWidth={2} fill="rgb(var(--color-text-100))" />
<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-custom-background-80 px-3 py-1.5 "
onClick={() =>
captureEvent(GITHUB_REDIRECTED, {
element: "navbar",
})
}
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5"
href="https://github.com/makeplane/plane"
target="_blank"
rel="noopener noreferrer"
@ -51,7 +66,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

@ -1,4 +1,4 @@
import { FC, useCallback, useState } from "react";
import { FC, useCallback, useRef, useState } from "react";
import { DragDropContext, DragStart, DraggableLocation, DropResult, Droppable } from "@hello-pangea/dnd";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
@ -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;
@ -94,6 +95,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {};
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
// states
const [isDragStarted, setIsDragStarted] = useState<boolean>(false);
const [dragState, setDragState] = useState<KanbanDragState>({});
@ -210,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,
});
@ -245,7 +248,10 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
</div>
)}
<div className="horizontal-scroll-enable relative h-full w-full overflow-auto bg-custom-background-90">
<div
className="flex horizontal-scroll-enable relative h-full w-full overflow-auto bg-custom-background-90"
ref={scrollableContainerRef}
>
<div className="relative h-max w-max min-w-full bg-custom-background-90 px-2">
<DragDropContext onDragStart={onDragStart} onDragEnd={onDragEnd}>
{/* drag and delete component */}
@ -289,6 +295,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
canEditProperties={canEditProperties}
storeType={storeType}
addIssuesToView={addIssuesToView}
scrollableContainerRef={scrollableContainerRef}
isDragStarted={isDragStarted}
/>
</DragDropContext>
</div>

View File

@ -1,4 +1,4 @@
import { memo } from "react";
import { MutableRefObject, memo } from "react";
import { Draggable, DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd";
import { observer } from "mobx-react-lite";
// hooks
@ -13,6 +13,7 @@ import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
import { EIssueActions } from "../types";
// helper
import { cn } from "helpers/common.helper";
import RenderIfVisible from "components/core/render-if-visible-HOC";
interface IssueBlockProps {
peekIssueId?: string;
@ -25,6 +26,9 @@ interface IssueBlockProps {
handleIssues: (issue: TIssue, action: EIssueActions) => void;
quickActions: (issue: TIssue) => React.ReactNode;
canEditProperties: (projectId: string | undefined) => boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
isDragStarted?: boolean;
issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization
}
interface IssueDetailsBlockProps {
@ -107,6 +111,9 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
handleIssues,
quickActions,
canEditProperties,
scrollableContainerRef,
isDragStarted,
issueIds,
} = props;
const issue = issuesMap[issueId];
@ -129,24 +136,31 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
{...provided.dragHandleProps}
ref={provided.innerRef}
>
{issue.tempId !== undefined && (
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
)}
<div
className={cn(
"space-y-2 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm transition-all hover:border-custom-border-400",
"rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm transition-all hover:border-custom-border-400",
{ "hover:cursor-grab": !isDragDisabled },
{ "border-custom-primary-100": snapshot.isDragging },
{ "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id }
)}
>
<KanbanIssueDetailsBlock
issue={issue}
displayProperties={displayProperties}
handleIssues={handleIssues}
quickActions={quickActions}
isReadOnly={!canEditIssueProperties}
/>
<RenderIfVisible
classNames="space-y-2"
root={scrollableContainerRef}
defaultHeight="100px"
horizonatlOffset={50}
alwaysRender={snapshot.isDragging}
pauseHeightUpdateWhileRendering={isDragStarted}
changingReference={issueIds}
>
<KanbanIssueDetailsBlock
issue={issue}
displayProperties={displayProperties}
handleIssues={handleIssues}
quickActions={quickActions}
isReadOnly={!canEditIssueProperties}
/>
</RenderIfVisible>
</div>
</div>
)}

View File

@ -1,4 +1,4 @@
import { memo } from "react";
import { MutableRefObject, memo } from "react";
//types
import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
import { EIssueActions } from "../types";
@ -16,6 +16,8 @@ interface IssueBlocksListProps {
handleIssues: (issue: TIssue, action: EIssueActions) => void;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
canEditProperties: (projectId: string | undefined) => boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
isDragStarted?: boolean;
}
const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
@ -30,6 +32,8 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
handleIssues,
quickActions,
canEditProperties,
scrollableContainerRef,
isDragStarted,
} = props;
return (
@ -56,6 +60,9 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
index={index}
isDragDisabled={isDragDisabled}
canEditProperties={canEditProperties}
scrollableContainerRef={scrollableContainerRef}
isDragStarted={isDragStarted}
issueIds={issueIds} //passing to force render for virtualization whenever parent rerenders
/>
);
})}

View File

@ -20,6 +20,7 @@ import {
import { EIssueActions } from "../types";
import { getGroupByColumns } from "../utils";
import { TCreateModalStoreTypes } from "constants/issue";
import { MutableRefObject } from "react";
export interface IGroupByKanBan {
issuesMap: IIssueMap;
@ -45,6 +46,8 @@ export interface IGroupByKanBan {
storeType?: TCreateModalStoreTypes;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
canEditProperties: (projectId: string | undefined) => boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
isDragStarted?: boolean;
}
const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
@ -67,6 +70,8 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
storeType,
addIssuesToView,
canEditProperties,
scrollableContainerRef,
isDragStarted,
} = props;
const member = useMember();
@ -92,11 +97,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
const groupByVisibilityToggle = visibilityGroupBy(_list);
return (
<div
className={`relative flex flex-shrink-0 flex-col h-full group ${
groupByVisibilityToggle ? `` : `w-[340px]`
}`}
>
<div className={`relative flex flex-shrink-0 flex-col group ${groupByVisibilityToggle ? `` : `w-[340px]`}`}>
{sub_group_by === null && (
<div className="flex-shrink-0 sticky top-0 z-[2] w-full bg-custom-background-90 py-1">
<HeaderGroupByCard
@ -135,6 +136,8 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
disableIssueCreation={disableIssueCreation}
canEditProperties={canEditProperties}
groupByVisibilityToggle={groupByVisibilityToggle}
scrollableContainerRef={scrollableContainerRef}
isDragStarted={isDragStarted}
/>
)}
</div>
@ -168,6 +171,8 @@ export interface IKanBan {
storeType?: TCreateModalStoreTypes;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
canEditProperties: (projectId: string | undefined) => boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
isDragStarted?: boolean;
}
export const KanBan: React.FC<IKanBan> = observer((props) => {
@ -189,6 +194,8 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
storeType,
addIssuesToView,
canEditProperties,
scrollableContainerRef,
isDragStarted,
} = props;
const issueKanBanView = useKanbanView();
@ -213,6 +220,8 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
storeType={storeType}
addIssuesToView={addIssuesToView}
canEditProperties={canEditProperties}
scrollableContainerRef={scrollableContainerRef}
isDragStarted={isDragStarted}
/>
);
});

View File

@ -1,3 +1,4 @@
import { MutableRefObject } from "react";
import { Droppable } from "@hello-pangea/dnd";
// hooks
import { useProjectState } from "hooks/store";
@ -37,6 +38,8 @@ interface IKanbanGroup {
disableIssueCreation?: boolean;
canEditProperties: (projectId: string | undefined) => boolean;
groupByVisibilityToggle: boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
isDragStarted?: boolean;
}
export const KanbanGroup = (props: IKanbanGroup) => {
@ -57,6 +60,8 @@ export const KanbanGroup = (props: IKanbanGroup) => {
disableIssueCreation,
quickAddCallback,
viewId,
scrollableContainerRef,
isDragStarted,
} = props;
// hooks
const projectState = useProjectState();
@ -127,6 +132,8 @@ export const KanbanGroup = (props: IKanbanGroup) => {
handleIssues={handleIssues}
quickActions={quickActions}
canEditProperties={canEditProperties}
scrollableContainerRef={scrollableContainerRef}
isDragStarted={isDragStarted}
/>
{provided.placeholder}

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

@ -1,3 +1,4 @@
import { MutableRefObject } from "react";
import { observer } from "mobx-react-lite";
// components
import { KanBan } from "./default";
@ -80,6 +81,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
viewId?: string
) => Promise<TIssue | undefined>;
viewId?: string;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
}
const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
const {
@ -99,6 +101,8 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
addIssuesToView,
quickAddCallback,
viewId,
scrollableContainerRef,
isDragStarted,
} = props;
const calculateIssueCount = (column_id: string) => {
@ -150,6 +154,8 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
addIssuesToView={addIssuesToView}
quickAddCallback={quickAddCallback}
viewId={viewId}
scrollableContainerRef={scrollableContainerRef}
isDragStarted={isDragStarted}
/>
</div>
)}
@ -183,6 +189,7 @@ export interface IKanBanSwimLanes {
) => Promise<TIssue | undefined>;
viewId?: string;
canEditProperties: (projectId: string | undefined) => boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
}
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
@ -204,6 +211,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
addIssuesToView,
quickAddCallback,
viewId,
scrollableContainerRef,
} = props;
const member = useMember();
@ -249,6 +257,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
canEditProperties={canEditProperties}
quickAddCallback={quickAddCallback}
viewId={viewId}
scrollableContainerRef={scrollableContainerRef}
/>
)}
</div>

View File

@ -122,26 +122,24 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
);
return (
<>
<div className={`relative h-full w-full bg-custom-background-90`}>
<List
issuesMap={issueMap}
displayProperties={displayProperties}
group_by={group_by}
handleIssues={handleIssues}
quickActions={renderQuickActions}
issueIds={issueIds}
showEmptyGroup={showEmptyGroup}
viewId={viewId}
quickAddCallback={issues?.quickAddIssue}
enableIssueQuickAdd={!!enableQuickAdd}
canEditProperties={canEditProperties}
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
storeType={storeType}
addIssuesToView={addIssuesToView}
isCompletedCycle={isCompletedCycle}
/>
</div>
</>
<div className={`relative h-full w-full bg-custom-background-90`}>
<List
issuesMap={issueMap}
displayProperties={displayProperties}
group_by={group_by}
handleIssues={handleIssues}
quickActions={renderQuickActions}
issueIds={issueIds}
showEmptyGroup={showEmptyGroup}
viewId={viewId}
quickAddCallback={issues?.quickAddIssue}
enableIssueQuickAdd={!!enableQuickAdd}
canEditProperties={canEditProperties}
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
storeType={storeType}
addIssuesToView={addIssuesToView}
isCompletedCycle={isCompletedCycle}
/>
</div>
);
});

View File

@ -48,64 +48,59 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
const projectDetails = getProjectById(issue.project_id);
return (
<>
<div
className={cn(
"relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm border border-transparent border-b-custom-border-200",
{
"border border-custom-primary-70 hover:border-custom-primary-70":
<div
className={cn("min-h-12 relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm", {
"border border-custom-primary-70 hover:border-custom-primary-70":
peekIssue && peekIssue.issueId === issue.id,
"last:border-b-transparent": peekIssue?.issueId !== issue.id,
}
)}
>
{displayProperties && displayProperties?.key && (
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
{projectDetails?.identifier}-{issue.sequence_id}
</div>
)}
"last:border-b-transparent": peekIssue?.issueId !== issue.id
})}
>
{displayProperties && displayProperties?.key && (
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
{projectDetails?.identifier}-{issue.sequence_id}
</div>
)}
{issue?.tempId !== undefined && (
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
)}
{issue?.tempId !== undefined && (
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
)}
{issue?.is_draft ? (
{issue?.is_draft ? (
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
<span>{issue.name}</span>
</Tooltip>
) : (
<ControlLink
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`}
target="_blank"
onClick={() => handleIssuePeekOverview(issue)}
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
>
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
<span>{issue.name}</span>
</Tooltip>
) : (
<ControlLink
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`}
target="_blank"
onClick={() => handleIssuePeekOverview(issue)}
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
>
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
<span>{issue.name}</span>
</Tooltip>
</ControlLink>
)}
</ControlLink>
)}
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
{!issue?.tempId ? (
<>
<IssueProperties
className="relative flex items-center gap-2 whitespace-nowrap"
issue={issue}
isReadOnly={!canEditIssueProperties}
handleIssues={updateIssue}
displayProperties={displayProperties}
activeLayout="List"
/>
{quickActions(issue)}
</>
) : (
<div className="h-4 w-4">
<Spinner className="h-4 w-4" />
</div>
)}
</div>
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
{!issue?.tempId ? (
<>
<IssueProperties
className="relative flex items-center gap-2 whitespace-nowrap"
issue={issue}
isReadOnly={!canEditIssueProperties}
handleIssues={updateIssue}
displayProperties={displayProperties}
activeLayout="List"
/>
{quickActions(issue)}
</>
) : (
<div className="h-4 w-4">
<Spinner className="h-4 w-4" />
</div>
)}
</div>
</>
</div>
);
});

View File

@ -1,9 +1,10 @@
import { FC } from "react";
import { FC, MutableRefObject } from "react";
// components
import { IssueBlock } from "components/issues";
// types
import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types";
import { EIssueActions } from "../types";
import RenderIfVisible from "components/core/render-if-visible-HOC";
interface Props {
issueIds: TGroupedIssues | TUnGroupedIssues | any;
@ -12,27 +13,34 @@ interface Props {
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
quickActions: (issue: TIssue) => React.ReactNode;
displayProperties: IIssueDisplayProperties | undefined;
containerRef: MutableRefObject<HTMLDivElement | null>;
}
export const IssueBlocksList: FC<Props> = (props) => {
const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties } = props;
const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties, containerRef } = props;
return (
<div className="relative h-full w-full">
{issueIds && issueIds.length > 0 ? (
issueIds.map((issueId: string) => {
if (!issueId) return null;
return (
<IssueBlock
key={issueId}
issueId={issueId}
issuesMap={issuesMap}
handleIssues={handleIssues}
quickActions={quickActions}
canEditProperties={canEditProperties}
displayProperties={displayProperties}
/>
<RenderIfVisible
key={`${issueId}`}
defaultHeight="3rem"
root={containerRef}
classNames={"relative border border-transparent border-b-custom-border-200 last:border-b-transparent"}
changingReference={issueIds}
>
<IssueBlock
issueId={issueId}
issuesMap={issuesMap}
handleIssues={handleIssues}
quickActions={quickActions}
canEditProperties={canEditProperties}
displayProperties={displayProperties}
/>
</RenderIfVisible>
);
})
) : (

View File

@ -1,5 +1,7 @@
import { useRef } from "react";
// components
import { IssueBlocksList, ListQuickAddIssueForm } from "components/issues";
import { HeaderGroupByCard } from "./headers/group-by-card";
// hooks
import { useLabel, useMember, useProject, useProjectState } from "hooks/store";
// types
@ -10,12 +12,12 @@ import {
IIssueDisplayProperties,
TIssueMap,
TUnGroupedIssues,
IGroupByColumn,
} from "@plane/types";
import { EIssueActions } from "../types";
// constants
import { HeaderGroupByCard } from "./headers/group-by-card";
import { getGroupByColumns } from "../utils";
import { TCreateModalStoreTypes } from "constants/issue";
import { getGroupByColumns } from "../utils";
export interface IGroupByList {
issueIds: TGroupedIssues | TUnGroupedIssues | any;
@ -64,9 +66,11 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
const label = useLabel();
const projectState = useProjectState();
const list = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true);
const containerRef = useRef<HTMLDivElement | null>(null);
if (!list) return null;
const groups = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true);
if (!groups) return null;
const prePopulateQuickAddData = (groupByKey: string | null, value: any) => {
const defaultState = projectState.projectStates?.find((state) => state.default);
@ -104,11 +108,11 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
const isGroupByCreatedBy = group_by === "created_by";
return (
<div className="relative h-full w-full">
{list &&
list.length > 0 &&
list.map(
(_list: any) =>
<div ref={containerRef} className="relative overflow-auto h-full w-full">
{groups &&
groups.length > 0 &&
groups.map(
(_list: IGroupByColumn) =>
validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
<div key={_list.id} className={`flex flex-shrink-0 flex-col`}>
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 px-3 py-1">
@ -131,6 +135,7 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
quickActions={quickActions}
displayProperties={displayProperties}
canEditProperties={canEditProperties}
containerRef={containerRef}
/>
)}

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-custom-border-200">
<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-custom-border-200">
<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

@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { Dispatch, MutableRefObject, SetStateAction, useRef, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// icons
@ -7,6 +7,7 @@ import { ChevronRight, MoreHorizontal } from "lucide-react";
import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
// components
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import RenderIfVisible from "components/core/render-if-visible-HOC";
import { IssueColumn } from "./issue-column";
// ui
import { ControlLink, Tooltip } from "@plane/ui";
@ -32,6 +33,9 @@ interface Props {
portalElement: React.MutableRefObject<HTMLDivElement | null>;
nestingLevel: number;
issueId: string;
isScrolled: MutableRefObject<boolean>;
containerRef: MutableRefObject<HTMLTableElement | null>;
issueIds: string[];
}
export const SpreadsheetIssueRow = observer((props: Props) => {
@ -44,8 +48,96 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
handleIssues,
quickActions,
canEditProperties,
isScrolled,
containerRef,
issueIds,
} = props;
const [isExpanded, setExpanded] = useState<boolean>(false);
const { subIssues: subIssuesStore } = useIssueDetail();
const subIssues = subIssuesStore.subIssuesByIssueId(issueId);
return (
<>
{/* first column/ issue name and key column */}
<RenderIfVisible
as="tr"
defaultHeight="calc(2.75rem - 1px)"
root={containerRef}
placeholderChildren={<td colSpan={100} className="border-b-[0.5px]" />}
changingReference={issueIds}
>
<IssueRowDetails
issueId={issueId}
displayProperties={displayProperties}
quickActions={quickActions}
canEditProperties={canEditProperties}
nestingLevel={nestingLevel}
isEstimateEnabled={isEstimateEnabled}
handleIssues={handleIssues}
portalElement={portalElement}
isScrolled={isScrolled}
isExpanded={isExpanded}
setExpanded={setExpanded}
/>
</RenderIfVisible>
{isExpanded &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssueId: string) => (
<SpreadsheetIssueRow
key={subIssueId}
issueId={subIssueId}
displayProperties={displayProperties}
quickActions={quickActions}
canEditProperties={canEditProperties}
nestingLevel={nestingLevel + 1}
isEstimateEnabled={isEstimateEnabled}
handleIssues={handleIssues}
portalElement={portalElement}
isScrolled={isScrolled}
containerRef={containerRef}
issueIds={issueIds}
/>
))}
</>
);
});
interface IssueRowDetailsProps {
displayProperties: IIssueDisplayProperties;
isEstimateEnabled: boolean;
quickActions: (
issue: TIssue,
customActionButton?: React.ReactElement,
portalElement?: HTMLDivElement | null
) => React.ReactNode;
canEditProperties: (projectId: string | undefined) => boolean;
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
portalElement: React.MutableRefObject<HTMLDivElement | null>;
nestingLevel: number;
issueId: string;
isScrolled: MutableRefObject<boolean>;
isExpanded: boolean;
setExpanded: Dispatch<SetStateAction<boolean>>;
}
const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
const {
displayProperties,
issueId,
isEstimateEnabled,
nestingLevel,
portalElement,
handleIssues,
quickActions,
canEditProperties,
isScrolled,
isExpanded,
setExpanded,
} = props;
// router
const router = useRouter();
const { workspaceSlug } = router.query;
@ -54,8 +146,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
const { peekIssue, setPeekIssue } = useIssueDetail();
// states
const [isMenuActive, setIsMenuActive] = useState(false);
const [isExpanded, setExpanded] = useState<boolean>(false);
const menuActionRef = useRef<HTMLDivElement | null>(null);
const handleIssuePeekOverview = (issue: TIssue) => {
@ -66,7 +156,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
const { subIssues: subIssuesStore, issue } = useIssueDetail();
const issueDetail = issue.getIssueById(issueId);
const subIssues = subIssuesStore.subIssuesByIssueId(issueId);
const paddingLeft = `${nestingLevel * 54}px`;
@ -91,81 +180,77 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
<MoreHorizontal className="h-3.5 w-3.5" />
</div>
);
if (!issueDetail) return null;
const disableUserActions = !canEditProperties(issueDetail.project_id);
return (
<>
<tr
className={cn({
"border border-custom-primary-70 hover:border-custom-primary-70": peekIssue?.issueId === issueDetail.id,
})}
<td
className={cn(
"sticky group left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] border-custom-border-200",
{
"border-b-[0.5px]": peekIssue?.issueId !== issueDetail.id,
},
{
"border border-custom-primary-70 hover:border-custom-primary-70": peekIssue?.issueId === issueDetail.id,
},
{
"shadow-[8px_22px_22px_10px_rgba(0,0,0,0.05)]": isScrolled.current,
}
)}
tabIndex={0}
>
{/* first column/ issue name and key column */}
<td
className={cn(
"sticky group left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] border-custom-border-200 focus:border-custom-primary-70",
{
"border-b-[0.5px]": peekIssue?.issueId !== issueDetail.id,
}
)}
tabIndex={0}
>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
<div
className="flex min-w-min items-center gap-1.5 px-4 py-2.5 pr-0"
style={issueDetail.parent_id && nestingLevel !== 0 ? { paddingLeft } : {}}
>
<div className="relative flex cursor-pointer items-center text-center text-xs hover:text-custom-text-100">
<span
className={`flex items-center justify-center font-medium group-hover:opacity-0 ${
isMenuActive ? "opacity-0" : "opacity-100"
}`}
>
{getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id}
</span>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
<div
className="flex min-w-min items-center gap-1.5 px-4 py-2.5 pr-0"
style={issueDetail.parent_id && nestingLevel !== 0 ? { paddingLeft } : {}}
>
<div className="relative flex cursor-pointer items-center text-center text-xs hover:text-custom-text-100">
<span
className={`flex items-center justify-center font-medium group-hover:opacity-0 ${
isMenuActive ? "opacity-0" : "opacity-100"
}`}
>
{getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id}
</span>
{canEditProperties(issueDetail.project_id) && (
<div className={`absolute left-2.5 top-0 hidden group-hover:block ${isMenuActive ? "!block" : ""}`}>
{quickActions(issueDetail, customActionButton, portalElement.current)}
</div>
)}
</div>
{issueDetail.sub_issues_count > 0 && (
<div className="flex h-6 w-6 items-center justify-center">
<button
className="h-5 w-5 cursor-pointer rounded-sm hover:bg-custom-background-90 hover:text-custom-text-100"
onClick={() => handleToggleExpand()}
>
<ChevronRight className={`h-3.5 w-3.5 ${isExpanded ? "rotate-90" : ""}`} />
</button>
{canEditProperties(issueDetail.project_id) && (
<div className={`absolute left-2.5 top-0 hidden group-hover:block ${isMenuActive ? "!block" : ""}`}>
{quickActions(issueDetail, customActionButton, portalElement.current)}
</div>
)}
</div>
</WithDisplayPropertiesHOC>
<ControlLink
href={`/${workspaceSlug}/projects/${issueDetail.project_id}/issues/${issueId}`}
target="_blank"
onClick={() => handleIssuePeekOverview(issueDetail)}
className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
>
<div className="w-full overflow-hidden">
<Tooltip tooltipHeading="Title" tooltipContent={issueDetail.name}>
<div
className="h-full w-full cursor-pointer truncate px-4 py-2.5 text-left text-[0.825rem] text-custom-text-100"
tabIndex={-1}
{issueDetail.sub_issues_count > 0 && (
<div className="flex h-6 w-6 items-center justify-center">
<button
className="h-5 w-5 cursor-pointer rounded-sm hover:bg-custom-background-90 hover:text-custom-text-100"
onClick={() => handleToggleExpand()}
>
{issueDetail.name}
</div>
</Tooltip>
</div>
</ControlLink>
</td>
{/* Rest of the columns */}
{SPREADSHEET_PROPERTY_LIST.map((property) => (
<ChevronRight className={`h-3.5 w-3.5 ${isExpanded ? "rotate-90" : ""}`} />
</button>
</div>
)}
</div>
</WithDisplayPropertiesHOC>
<ControlLink
href={`/${workspaceSlug}/projects/${issueDetail.project_id}/issues/${issueId}`}
target="_blank"
onClick={() => handleIssuePeekOverview(issueDetail)}
className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
>
<div className="w-full overflow-hidden">
<Tooltip tooltipHeading="Title" tooltipContent={issueDetail.name}>
<div className="h-full w-full cursor-pointer truncate px-4 py-2.5 text-left text-[0.825rem] text-custom-text-100" tabIndex={-1}>
{issueDetail.name}
</div>
</Tooltip>
</div>
</ControlLink>
</td>
{/* Rest of the columns */}
{SPREADSHEET_PROPERTY_LIST.map((property) => (
<IssueColumn
displayProperties={displayProperties}
issueDetail={issueDetail}
@ -175,24 +260,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
isEstimateEnabled={isEstimateEnabled}
/>
))}
</tr>
{isExpanded &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssueId: string) => (
<SpreadsheetIssueRow
key={subIssueId}
issueId={subIssueId}
displayProperties={displayProperties}
quickActions={quickActions}
canEditProperties={canEditProperties}
nestingLevel={nestingLevel + 1}
isEstimateEnabled={isEstimateEnabled}
handleIssues={handleIssues}
portalElement={portalElement}
/>
))}
</>
);
});

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

@ -1,4 +1,5 @@
import { observer } from "mobx-react-lite";
import { MutableRefObject, useEffect, useRef } from "react";
//types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types";
import { EIssueActions } from "../types";
@ -21,6 +22,7 @@ type Props = {
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
canEditProperties: (projectId: string | undefined) => boolean;
portalElement: React.MutableRefObject<HTMLDivElement | null>;
containerRef: MutableRefObject<HTMLTableElement | null>;
};
export const SpreadsheetTable = observer((props: Props) => {
@ -34,8 +36,45 @@ export const SpreadsheetTable = observer((props: Props) => {
quickActions,
handleIssues,
canEditProperties,
containerRef,
} = props;
// states
const isScrolled = useRef(false);
const handleScroll = () => {
if (!containerRef.current) return;
const scrollLeft = containerRef.current.scrollLeft;
const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns
const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers
//The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly
if (scrollLeft > 0 !== isScrolled.current) {
const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child");
for (let i = 0; i < firtColumns.length; i++) {
const shadow = i === 0 ? headerShadow : columnShadow;
if (scrollLeft > 0) {
(firtColumns[i] as HTMLElement).style.boxShadow = shadow;
} else {
(firtColumns[i] as HTMLElement).style.boxShadow = "none";
}
}
isScrolled.current = scrollLeft > 0;
}
};
useEffect(() => {
const currentContainerRef = containerRef.current;
if (currentContainerRef) currentContainerRef.addEventListener("scroll", handleScroll);
return () => {
if (currentContainerRef) currentContainerRef.removeEventListener("scroll", handleScroll);
};
}, []);
const handleKeyBoardNavigation = useTableKeyboardNavigation();
return (
@ -58,6 +97,9 @@ export const SpreadsheetTable = observer((props: Props) => {
isEstimateEnabled={isEstimateEnabled}
handleIssues={handleIssues}
portalElement={portalElement}
containerRef={containerRef}
isScrolled={isScrolled}
issueIds={issueIds}
/>
))}
</tbody>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef } from "react";
import React, { useRef } from "react";
import { observer } from "mobx-react-lite";
// components
import { Spinner } from "@plane/ui";
@ -48,8 +48,6 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
enableQuickCreateIssue,
disableIssueCreation,
} = props;
// states
const isScrolled = useRef(false);
// refs
const containerRef = useRef<HTMLTableElement | null>(null);
const portalRef = useRef<HTMLDivElement | null>(null);
@ -58,39 +56,6 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null;
const handleScroll = () => {
if (!containerRef.current) return;
const scrollLeft = containerRef.current.scrollLeft;
const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns
const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers
//The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly
if (scrollLeft > 0 !== isScrolled.current) {
const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child");
for (let i = 0; i < firtColumns.length; i++) {
const shadow = i === 0 ? headerShadow : columnShadow;
if (scrollLeft > 0) {
(firtColumns[i] as HTMLElement).style.boxShadow = shadow;
} else {
(firtColumns[i] as HTMLElement).style.boxShadow = "none";
}
}
isScrolled.current = scrollLeft > 0;
}
};
useEffect(() => {
const currentContainerRef = containerRef.current;
if (currentContainerRef) currentContainerRef.addEventListener("scroll", handleScroll);
return () => {
if (currentContainerRef) currentContainerRef.removeEventListener("scroll", handleScroll);
};
}, []);
if (!issueIds || issueIds.length === 0)
return (
<div className="grid h-full w-full place-items-center">
@ -112,6 +77,7 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
quickActions={quickActions}
handleIssues={handleIssues}
canEditProperties={canEditProperties}
containerRef={containerRef}
/>
</div>
<div className="border-t border-custom-border-100">

View File

@ -1,10 +1,10 @@
import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui";
import { ISSUE_PRIORITIES } from "constants/issue";
import { EIssueListRow, ISSUE_PRIORITIES } from "constants/issue";
import { renderEmoji } from "helpers/emoji.helper";
import { IMemberRootStore } from "store/member";
import { IProjectStore } from "store/project/project.store";
import { IStateStore } from "store/state.store";
import { GroupByColumnTypes, IGroupByColumn } from "@plane/types";
import { GroupByColumnTypes, IGroupByColumn, IIssueListRow, TGroupedIssues, TUnGroupedIssues } from "@plane/types";
import { STATE_GROUPS } from "constants/state";
import { ILabelStore } from "store/label.store";

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-custom-text-300">
<p className="text-custom-text-300 line-clamp-1">
{truncateText(
`${notification.data.issue.identifier}-${notification.data.issue.sequence_id} ${notification.data.issue.name}`,
50
@ -152,7 +308,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
</span>
</p>
) : (
<p className="flex-shrink-0 text-custom-text-300">{calculateTimeAgo(notification.created_at)}</p>
<p className="flex-shrink-0 text-custom-text-300 mt-auto">{calculateTimeAgo(notification.created_at)}</p>
)}
</div>
</div>
@ -164,6 +320,11 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
icon: <MessageSquare className="h-3.5 w-3.5 text-custom-text-300" />,
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 +342,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 +361,6 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
item.onClick();
}}
key={item.id}
@ -208,9 +373,6 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
<Tooltip tooltipContent="Snooze">
<CustomMenu
className="flex items-center"
menuButtonOnClick={(e: { stopPropagation: () => void }) => {
e.stopPropagation();
}}
customButton={
<div className="flex w-full items-center gap-x-2 rounded bg-custom-background-80 p-0.5 text-sm hover:bg-custom-background-100">
<Clock className="h-3.5 w-3.5 text-custom-text-300" />
@ -231,6 +393,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-custom-text-200">
<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-custom-border-300 px-5">

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";
@ -15,8 +16,12 @@ import emptyNotification from "public/empty-state/notification.svg";
import { getNumberCount } from "helpers/string.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,
@ -44,8 +49,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 (
<>
@ -54,141 +62,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-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
} ${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-custom-primary-300" />
) : (
<span className="ml-auto rounded-full bg-custom-primary-300 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-custom-border-300 bg-custom-background-100 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={`group relative flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
isActive
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
} ${isSidebarCollapsed ? "justify-center" : ""}`}
>
<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-custom-primary-300" />
) : (
<span className="ml-auto rounded-full bg-custom-primary-300 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-custom-border-300 bg-custom-background-100 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-custom-border-100">
{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-custom-text-200"
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-custom-primary-100"
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-custom-border-100">
{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-custom-text-200"
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-custom-primary-100"
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-custom-background-100 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-custom-background-100 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-custom-text-100">
@ -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-custom-text-400">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-custom-primary-100 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

@ -22,16 +22,15 @@ export const ProfileNavbar: React.FC<Props> = (props) => {
const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB;
return (
<div className="sticky -top-0.5 z-10 flex items-center justify-between gap-4 border-b border-custom-border-300 bg-custom-background-100 px-4 sm:px-5 md:static">
<div className="sticky -top-0.5 z-10 hidden md:flex items-center justify-between gap-4 border-b border-custom-border-300 bg-custom-background-100 px-4 sm:px-5 md:static">
<div className="flex items-center overflow-x-scroll">
{tabsList.map((tab) => (
<Link key={tab.route} href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}>
<span
className={`flex whitespace-nowrap border-b-2 p-4 text-sm font-medium outline-none ${
router.pathname === tab.selected
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent"
}`}
className={`flex whitespace-nowrap border-b-2 p-4 text-sm font-medium outline-none ${router.pathname === tab.selected
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent"
}`}
>
{tab.label}
</span>

View File

@ -1,7 +1,7 @@
import { FC } from "react";
import React, { FC } from "react";
import { Controller, useForm } from "react-hook-form";
// ui
import { Button } from "@plane/ui";
import { Button, Checkbox } from "@plane/ui";
// hooks
import useToast from "hooks/use-toast";
// services
@ -23,6 +23,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
// form data
const {
handleSubmit,
watch,
control,
setValue,
formState: { isSubmitting, isDirty, dirtyFields },
@ -78,12 +79,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="property_change"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
checked={value}
onChange={() => onChange(!value)}
className="w-3.5 h-3.5 mx-2 cursor-pointer !border-custom-border-100"
/>
<Checkbox checked={value} onChange={() => onChange(!value)} className="mx-2" />
)}
/>
</div>
@ -100,14 +96,14 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="state_change"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
<Checkbox
checked={value}
intermediate={!value && watch("issue_completed")}
onChange={() => {
setValue("issue_completed", !value);
onChange(!value);
}}
className="w-3.5 h-3.5 mx-2 cursor-pointer"
className="mx-2"
/>
)}
/>
@ -123,12 +119,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="issue_completed"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
checked={value}
onChange={() => onChange(!value)}
className="w-3.5 h-3.5 mx-2 cursor-pointer"
/>
<Checkbox checked={value} onChange={() => onChange(!value)} className="mx-2" />
)}
/>
</div>
@ -145,12 +136,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="comment"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
checked={value}
onChange={() => onChange(!value)}
className="w-3.5 h-3.5 mx-2 cursor-pointer"
/>
<Checkbox checked={value} onChange={() => onChange(!value)} className="mx-2" />
)}
/>
</div>
@ -167,12 +153,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="mention"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
checked={value}
onChange={() => onChange(!value)}
className="w-3.5 h-3.5 mx-2 cursor-pointer"
/>
<Checkbox checked={value} onChange={() => onChange(!value)} className="mx-2" />
)}
/>
</div>

View File

@ -4,7 +4,7 @@ import useSWR from "swr";
import { Disclosure, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
// hooks
import { useUser } from "hooks/store";
import { useApplication, useUser } from "hooks/store";
// services
import { UserService } from "services/user.service";
// components
@ -18,6 +18,8 @@ import { renderFormattedDate } from "helpers/date-time.helper";
import { renderEmoji } from "helpers/emoji.helper";
// fetch-keys
import { USER_PROFILE_PROJECT_SEGREGATION } from "constants/fetch-keys";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
import { useEffect, useRef } from "react";
// services
const userService = new UserService();
@ -28,6 +30,8 @@ export const ProfileSidebar = observer(() => {
const { workspaceSlug, userId } = router.query;
// store hooks
const { currentUser } = useUser();
const { theme: themeStore } = useApplication();
const ref = useRef<HTMLDivElement>(null);
const { data: userProjectsData } = useSWR(
workspaceSlug && userId ? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString()) : null,
@ -36,6 +40,14 @@ export const ProfileSidebar = observer(() => {
: null
);
useOutsideClickDetector(ref, () => {
if (themeStore.profileSidebarCollapsed === false) {
if (window.innerWidth < 768) {
themeStore.toggleProfileSidebar();
}
}
});
const userDetails = [
{
label: "Joined on",
@ -47,8 +59,26 @@ export const ProfileSidebar = observer(() => {
},
];
useEffect(() => {
const handleToggleProfileSidebar = () => {
if (window && window.innerWidth < 768) {
themeStore.toggleProfileSidebar(true);
}
if (window && themeStore.profileSidebarCollapsed && window.innerWidth >= 768) {
themeStore.toggleProfileSidebar(false);
}
};
window.addEventListener("resize", handleToggleProfileSidebar);
handleToggleProfileSidebar();
return () => window.removeEventListener("resize", handleToggleProfileSidebar);
}, [themeStore]);
return (
<div className="w-full flex-shrink-0 overflow-y-auto shadow-custom-shadow-sm md:h-full md:w-80 border-l border-custom-border-100">
<div
className={`flex-shrink-0 overflow-hidden overflow-y-auto shadow-custom-shadow-sm border-l border-custom-border-100 bg-custom-sidebar-background-100 h-full z-[5] fixed md:relative transition-all w-full md:w-[300px]`}
style={themeStore.profileSidebarCollapsed ? { marginLeft: `${window?.innerWidth || 0}px` } : {}}
>
{userProjectsData ? (
<>
<div className="relative h-32">
@ -132,13 +162,12 @@ export const ProfileSidebar = observer(() => {
{project.assigned_issues > 0 && (
<Tooltip tooltipContent="Completion percentage" position="left">
<div
className={`rounded px-1 py-0.5 text-xs font-medium ${
completedIssuePercentage <= 35
? "bg-red-500/10 text-red-500"
: completedIssuePercentage <= 70
className={`rounded px-1 py-0.5 text-xs font-medium ${completedIssuePercentage <= 35
? "bg-red-500/10 text-red-500"
: completedIssuePercentage <= 70
? "bg-yellow-500/10 text-yellow-500"
: "bg-green-500/10 text-green-500"
}`}
}`}
>
{completedIssuePercentage}%
</div>

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-custom-background-90">
<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);

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