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

View File

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

View File

@ -20,6 +20,7 @@ from django.core import serializers
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -312,6 +313,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"labels": label_distribution, "labels": label_distribution,
"completion_chart": {}, "completion_chart": {},
} }
if data[0]["start_date"] and data[0]["end_date"]: if data[0]["start_date"] and data[0]["end_date"]:
data[0]["distribution"][ data[0]["distribution"][
"completion_chart" "completion_chart"
@ -840,10 +842,230 @@ class TransferCycleIssueEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, 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 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 ( if (
new_cycle.end_date is not None new_cycle.end_date is not None
and new_cycle.end_date < timezone.now().date() and new_cycle.end_date < timezone.now().date()

View File

@ -296,7 +296,7 @@ class OauthEndpoint(BaseAPIView):
email=email, email=email,
user_agent=request.META.get("HTTP_USER_AGENT"), user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"), ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN", event_name="Sign in",
medium=medium.upper(), medium=medium.upper(),
first_time=False, first_time=False,
) )
@ -427,7 +427,7 @@ class OauthEndpoint(BaseAPIView):
email=email, email=email,
user_agent=request.META.get("HTTP_USER_AGENT"), user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"), ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN", event_name="Sign up",
medium=medium.upper(), medium=medium.upper(),
first_time=True, 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) sort_order = models.FloatField(default=65535)
external_source = models.CharField(max_length=255, null=True, blank=True) external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True) external_id = models.CharField(max_length=255, blank=True, null=True)
progress_snapshot = models.JSONField(default=dict)
class Meta: class Meta:
verbose_name = "Cycle" verbose_name = "Cycle"

View File

@ -31,6 +31,7 @@ export interface ICycle {
issue: string; issue: string;
name: string; name: string;
owned_by: string; owned_by: string;
progress_snapshot: TProgressSnapshot;
project: string; project: string;
project_detail: IProjectLite; project_detail: IProjectLite;
status: TCycleGroups; status: TCycleGroups;
@ -49,6 +50,23 @@ export interface ICycle {
workspace_detail: IWorkspaceLite; 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 = { export type TAssigneesDistribution = {
assignee_id: string | null; assignee_id: string | null;
avatar: string | null; avatar: string | null;

View File

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

View File

@ -221,3 +221,12 @@ export interface IGroupByColumn {
export interface IIssueMap { export interface IIssueMap {
[key: string]: TIssue; [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) => ( const Breadcrumbs = ({ children }: BreadcrumbsProps) => (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{React.Children.map(children, (child, index) => ( {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} {child}
{index !== React.Children.count(children) - 1 && ( {index !== React.Children.count(children) - 1 && (
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" /> <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); setIsOpen(false);
}; };
const handleOnChange = () => {
if (closeOnSelect) closeDropdown();
};
const selectActiveItem = () => { const selectActiveItem = () => {
const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector( const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector(
`[data-headlessui-state="active"] button` `[data-headlessui-state="active"] button`
@ -66,6 +62,11 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
}; };
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem); const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem);
const handleOnClick = () => {
if (closeOnSelect) closeDropdown();
};
useOutsideClickDetector(dropdownRef, closeDropdown); useOutsideClickDetector(dropdownRef, closeDropdown);
let menuItems = ( let menuItems = (
@ -101,7 +102,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
tabIndex={tabIndex} tabIndex={tabIndex}
className={cn("relative w-min text-left", className)} className={cn("relative w-min text-left", className)}
onKeyDownCapture={handleKeyDown} onKeyDownCapture={handleKeyDown}
onChange={handleOnChange} onClick={handleOnClick}
> >
{({ open }) => ( {({ open }) => (
<> <>
@ -110,7 +111,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
onClick={() => { onClick={(e) => {
e.stopPropagation();
openDropdown(); openDropdown();
if (menuButtonOnClick) menuButtonOnClick(); if (menuButtonOnClick) menuButtonOnClick();
}} }}
@ -127,7 +129,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
onClick={() => { onClick={(e) => {
e.stopPropagation();
openDropdown(); openDropdown();
if (menuButtonOnClick) menuButtonOnClick(); if (menuButtonOnClick) menuButtonOnClick();
}} }}
@ -152,7 +155,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
? "cursor-not-allowed text-custom-text-200" ? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`} } ${buttonClassName}`}
onClick={() => { onClick={(e) => {
e.stopPropagation();
openDropdown(); openDropdown();
if (menuButtonOnClick) menuButtonOnClick(); 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 "./input";
export * from "./textarea"; export * from "./textarea";
export * from "./input-color-picker"; 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"; import { AuthService } from "services/auth.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store";
// ui // ui
import { Button, Input } from "@plane/ui"; import { Button, Input } from "@plane/ui";
// helpers // helpers
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// icons // icons
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED } from "constants/event-tracker";
type Props = { type Props = {
email: string; email: string;
@ -34,6 +36,8 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
// states // states
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// form info // form info
@ -63,21 +67,34 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
title: "Success!", title: "Success!",
message: "Password created successfully.", message: "Password created successfully.",
}); });
captureEvent(PASSWORD_CREATE_SELECTED, {
state: "SUCCESS",
first_time: false,
});
await handleSignInRedirection(); await handleSignInRedirection();
}) })
.catch((err) => .catch((err) => {
captureEvent(PASSWORD_CREATE_SELECTED, {
state: "FAILED",
first_time: false,
});
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.", message: err?.error ?? "Something went wrong. Please try again.",
}) });
); });
}; };
const handleGoToWorkspace = async () => { const handleGoToWorkspace = async () => {
setIsGoingToWorkspace(true); setIsGoingToWorkspace(true);
await handleSignInRedirection().finally(() => {
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false)); captureEvent(PASSWORD_CREATE_SKIPPED, {
state: "SUCCESS",
first_time: false,
});
setIsGoingToWorkspace(false);
});
}; };
return ( return (

View File

@ -7,7 +7,7 @@ import { Eye, EyeOff, XCircle } from "lucide-react";
import { AuthService } from "services/auth.service"; import { AuthService } from "services/auth.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useApplication } from "hooks/store"; import { useApplication, useEventTracker } from "hooks/store";
// components // components
import { ESignInSteps, ForgotPasswordPopover } from "components/account"; import { ESignInSteps, ForgotPasswordPopover } from "components/account";
// ui // ui
@ -16,6 +16,8 @@ import { Button, Input } from "@plane/ui";
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// types // types
import { IPasswordSignInData } from "@plane/types"; import { IPasswordSignInData } from "@plane/types";
// constants
import { FORGOT_PASSWORD, SIGN_IN_WITH_PASSWORD } from "constants/event-tracker";
type Props = { type Props = {
email: string; email: string;
@ -46,6 +48,7 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
const { const {
config: { envConfig }, config: { envConfig },
} = useApplication(); } = useApplication();
const { captureEvent } = useEventTracker();
// derived values // derived values
const isSmtpConfigured = envConfig?.is_smtp_configured; const isSmtpConfigured = envConfig?.is_smtp_configured;
// form info // form info
@ -72,7 +75,13 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
await authService await authService
.passwordSignIn(payload) .passwordSignIn(payload)
.then(async () => await onSubmit()) .then(async () => {
captureEvent(SIGN_IN_WITH_PASSWORD, {
state: "SUCCESS",
first_time: false,
});
await onSubmit();
})
.catch((err) => .catch((err) =>
setToastAlert({ setToastAlert({
type: "error", type: "error",
@ -182,9 +191,10 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
</div> </div>
)} )}
/> />
<div className="w-full text-right mt-2 pb-3"> <div className="mt-2 w-full pb-3 text-right">
{isSmtpConfigured ? ( {isSmtpConfigured ? (
<Link <Link
onClick={() => captureEvent(FORGOT_PASSWORD)}
href={`/accounts/forgot-password?email=${email}`} href={`/accounts/forgot-password?email=${email}`}
className="text-xs font-medium text-custom-primary-100" 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 Link from "next/link";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
import { useApplication } from "hooks/store"; import { useApplication, useEventTracker } from "hooks/store";
import useSignInRedirection from "hooks/use-sign-in-redirection"; import useSignInRedirection from "hooks/use-sign-in-redirection";
// components // components
import { LatestFeatureBlock } from "components/common"; import { LatestFeatureBlock } from "components/common";
@ -13,6 +13,8 @@ import {
OAuthOptions, OAuthOptions,
SignInOptionalSetPasswordForm, SignInOptionalSetPasswordForm,
} from "components/account"; } from "components/account";
// constants
import { NAVIGATE_TO_SIGNUP } from "constants/event-tracker";
export enum ESignInSteps { export enum ESignInSteps {
EMAIL = "EMAIL", EMAIL = "EMAIL",
@ -32,6 +34,7 @@ export const SignInRoot = observer(() => {
const { const {
config: { envConfig }, config: { envConfig },
} = useApplication(); } = useApplication();
const { captureEvent } = useEventTracker();
// derived values // derived values
const isSmtpConfigured = envConfig?.is_smtp_configured; const isSmtpConfigured = envConfig?.is_smtp_configured;
@ -110,7 +113,11 @@ export const SignInRoot = observer(() => {
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_in" /> <OAuthOptions handleSignInRedirection={handleRedirection} type="sign_in" />
<p className="text-xs text-onboarding-text-300 text-center mt-6"> <p className="text-xs text-onboarding-text-300 text-center mt-6">
Don{"'"}t have an account?{" "} 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 Sign up
</Link> </Link>
</p> </p>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,9 +17,9 @@ export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((pro
const { getProjectById } = useProject(); const { getProjectById } = useProject();
return ( 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> <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) => { {projectIds.map((projectId) => {
const project = getProjectById(projectId); const project = getProjectById(projectId);

View File

@ -26,7 +26,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
<> <>
{projectId ? ( {projectId ? (
cycleDetails ? ( 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> <h4 className="break-words font-medium">Analytics for {cycleDetails.name}</h4>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
@ -52,7 +52,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
</div> </div>
</div> </div>
) : moduleDetails ? ( ) : 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> <h4 className="break-words font-medium">Analytics for {moduleDetails.name}</h4>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
@ -78,7 +78,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
</div> </div>
</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"> <div className="flex items-center gap-1">
{projectDetails?.emoji ? ( {projectDetails?.emoji ? (
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.emoji)}</div> <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 { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { mutate } from "swr"; import { mutate } from "swr";
@ -19,18 +19,18 @@ import { renderFormattedDate } from "helpers/date-time.helper";
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types"; import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types";
// fetch-keys // fetch-keys
import { ANALYTICS } from "constants/fetch-keys"; import { ANALYTICS } from "constants/fetch-keys";
import { cn } from "helpers/common.helper";
type Props = { type Props = {
analytics: IAnalyticsResponse | undefined; analytics: IAnalyticsResponse | undefined;
params: IAnalyticsParams; params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean; isProjectLevel: boolean;
}; };
const analyticsService = new AnalyticsService(); const analyticsService = new AnalyticsService();
export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => { export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
const { analytics, params, fullScreen, isProjectLevel = false } = props; const { analytics, params, isProjectLevel = false } = props;
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; 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; const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds;
return ( return (
<div <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" : "")}
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="flex flex-wrap items-center gap-2"> <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"> <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} /> <LayersIcon height={14} width={14} />
{analytics ? analytics.total : "..."} Issues {analytics ? analytics.total : "..."} <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
</div> </div>
{isProjectLevel && ( {isProjectLevel && (
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200"> <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 (cycleId
? cycleDetails?.created_at ? cycleDetails?.created_at
: moduleId : moduleId
? moduleDetails?.created_at ? moduleDetails?.created_at
: projectDetails?.created_at) ?? "" : projectDetails?.created_at) ?? ""
)} )}
</div> </div>
)} )}
</div> </div>
<div className="h-full w-full overflow-hidden">
{fullScreen ? ( <div className={cn("h-full w-full overflow-hidden", isProjectLevel ? "hidden" : "block")}>
<> <>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && ( {!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} /> <CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
)} )}
<CustomAnalyticsSidebarHeader /> <CustomAnalyticsSidebarHeader />
</> </>
) : null}
</div> </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 <Button
variant="neutral-primary" 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={() => { onClick={() => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params)); mutate(ANALYTICS(workspaceSlug.toString(), params));
}} }}
> >
Refresh <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Refresh</div>
</Button> </Button>
<Button variant="primary" prependIcon={<Download className="h-3.5 w-3.5" />} onClick={exportAnalytics}> <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> </Button>
</div> </div>
</div> </div>

View File

@ -20,16 +20,15 @@ export const ProjectAnalyticsModalMainContent: React.FC<Props> = observer((props
return ( return (
<Tab.Group as={React.Fragment}> <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) => ( {ANALYTICS_TABS.map((tab) => (
<Tab <Tab
key={tab.key} key={tab.key}
className={({ selected }) => className={({ selected }) =>
`rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover: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"
selected ? "bg-custom-background-80" : ""
}` }`
} }
onClick={() => {}} onClick={() => { }}
> >
{tab.title} {tab.title}
</Tab> </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 { useApplication } from "hooks/store";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
export const SidebarHamburgerToggle: FC = observer (() => { export const SidebarHamburgerToggle: FC = observer(() => {
const { theme: themStore } = useApplication(); const { theme: themStore } = useApplication();
return ( return (
<div <div

View File

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

View File

@ -18,6 +18,7 @@ import { CYCLE_STATUS } from "constants/cycle";
import { EUserWorkspaceRoles } from "constants/workspace"; import { EUserWorkspaceRoles } from "constants/workspace";
// types // types
import { TCycleGroups } from "@plane/types"; import { TCycleGroups } from "@plane/types";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
type TCyclesListItem = { type TCyclesListItem = {
cycleId: string; cycleId: string;
@ -37,7 +38,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
// router // router
const router = useRouter(); const router = useRouter();
// store hooks // store hooks
const { setTrackElement } = useEventTracker(); const { setTrackElement, captureEvent } = useEventTracker();
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
@ -63,26 +64,42 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
setToastAlert({ .then(() => {
type: "error", captureEvent(CYCLE_FAVORITED, {
title: "Error!", cycle_id: cycleId,
message: "Couldn't add the cycle to favorites. Please try again.", 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>) => { const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
setToastAlert({ .then(() => {
type: "error", captureEvent(CYCLE_UNFAVORITED, {
title: "Error!", cycle_id: cycleId,
message: "Couldn't add the cycle to favorites. Please try again.", 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>) => { const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
@ -159,9 +176,9 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
projectId={projectId} projectId={projectId}
/> />
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}> <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="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 w-full flex items-center justify-between gap-3 overflow-hidden"> <div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative w-full flex items-center gap-3 overflow-hidden"> <div className="relative flex w-full items-center gap-3 overflow-hidden">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<CircularProgressIndicator size={38} percentage={progress}> <CircularProgressIndicator size={38} percentage={progress}>
{isCompleted ? ( {isCompleted ? (
@ -181,20 +198,20 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
<div className="relative flex items-center gap-2.5 overflow-hidden"> <div className="relative flex items-center gap-2.5 overflow-hidden">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" /> <CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
<Tooltip tooltipContent={cycleDetails.name} position="top"> <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} {cycleDetails.name}
</span> </span>
</Tooltip> </Tooltip>
</div> </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" /> <Info className="h-4 w-4 text-custom-text-400" />
</button> </button>
</div> </div>
{currentCycle && ( {currentCycle && (
<div <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={{ style={{
color: currentCycle.color, color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`, backgroundColor: `${currentCycle.color}20`,
@ -206,12 +223,12 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
</div> </div>
)} )}
</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"> <div className="text-xs text-custom-text-300">
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
</div> </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`}> <Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}>
<div className="flex w-10 cursor-default items-center justify-center"> <div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignees.length > 0 ? ( {cycleDetails.assignees.length > 0 ? (

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Disclosure, Popover, Transition } from "@headlessui/react"; import { Disclosure, Popover, Transition } from "@headlessui/react";
import isEmpty from "lodash/isEmpty";
// services // services
import { CycleService } from "services/cycle.service"; import { CycleService } from "services/cycle.service";
// hooks // hooks
@ -38,6 +39,7 @@ import {
import { ICycle } from "@plane/types"; import { ICycle } from "@plane/types";
// constants // constants
import { EUserWorkspaceRoles } from "constants/workspace"; import { EUserWorkspaceRoles } from "constants/workspace";
import { CYCLE_UPDATED } from "constants/event-tracker";
// fetch-keys // fetch-keys
import { CYCLE_STATUS } from "constants/cycle"; import { CYCLE_STATUS } from "constants/cycle";
@ -66,7 +68,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query; const { workspaceSlug, projectId, peekCycle } = router.query;
// store hooks // store hooks
const { setTrackElement } = useEventTracker(); const { setTrackElement, captureCycleEvent } = useEventTracker();
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
@ -82,10 +84,32 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
defaultValues, defaultValues,
}); });
const submitChanges = (data: Partial<ICycle>) => { const submitChanges = (data: Partial<ICycle>, changedProperty: string) => {
if (!workspaceSlug || !projectId || !cycleId) return; 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 = () => { const handleCopyText = () => {
@ -145,10 +169,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
}); });
if (isDateValidForExistingCycle) { if (isDateValidForExistingCycle) {
submitChanges({ submitChanges(
start_date: renderFormattedPayloadDate(`${watch("start_date")}`), {
end_date: renderFormattedPayloadDate(`${watch("end_date")}`), start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
}); end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"start_date"
);
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
@ -173,10 +200,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
}); });
if (isDateValid) { if (isDateValid) {
submitChanges({ submitChanges(
start_date: renderFormattedPayloadDate(`${watch("start_date")}`), {
end_date: renderFormattedPayloadDate(`${watch("end_date")}`), start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
}); end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"start_date"
);
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
@ -218,10 +248,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
}); });
if (isDateValidForExistingCycle) { if (isDateValidForExistingCycle) {
submitChanges({ submitChanges(
start_date: renderFormattedPayloadDate(`${watch("start_date")}`), {
end_date: renderFormattedPayloadDate(`${watch("end_date")}`), start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
}); end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"end_date"
);
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
@ -245,10 +278,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
}); });
if (isDateValid) { if (isDateValid) {
submitChanges({ submitChanges(
start_date: renderFormattedPayloadDate(`${watch("start_date")}`), {
end_date: renderFormattedPayloadDate(`${watch("end_date")}`), start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
}); end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"end_date"
);
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "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 isEndValid = new Date(`${cycleDetails?.end_date}`) >= new Date(`${cycleDetails?.start_date}`);
const progressPercentage = cycleDetails 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; : null;
if (!cycleDetails) if (!cycleDetails)
@ -317,7 +357,14 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const issueCount = 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 daysLeft = findHowManyDaysLeft(cycleDetails.end_date);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
@ -568,49 +615,105 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<Transition show={open}> <Transition show={open}>
<Disclosure.Panel> <Disclosure.Panel>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{cycleDetails.distribution?.completion_chart && {isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? (
cycleDetails.start_date && <>
cycleDetails.end_date ? ( {cycleDetails.progress_snapshot.distribution?.completion_chart &&
<div className="h-full w-full pt-4"> cycleDetails.start_date &&
<div className="flex items-start gap-4 py-2 text-xs"> cycleDetails.end_date && (
<div className="flex items-center gap-3 text-custom-text-100"> <div className="h-full w-full pt-4">
<div className="flex items-center justify-center gap-1"> <div className="flex items-start gap-4 py-2 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" /> <div className="flex items-center gap-3 text-custom-text-100">
<span>Ideal</span> <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>
<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 && ( {/* stats */}
<div className="h-full w-full border-t border-custom-border-200 pt-5"> {isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? (
<SidebarProgressStats <>
distribution={cycleDetails.distribution} {cycleDetails.progress_snapshot.total_issues > 0 &&
groupedIssues={{ cycleDetails.progress_snapshot.distribution && (
backlog: cycleDetails.backlog_issues, <div className="h-full w-full border-t border-custom-border-200 pt-5">
unstarted: cycleDetails.unstarted_issues, <SidebarProgressStats
started: cycleDetails.started_issues, distribution={cycleDetails.progress_snapshot.distribution}
completed: cycleDetails.completed_issues, groupedIssues={{
cancelled: cycleDetails.cancelled_issues, backlog: cycleDetails.progress_snapshot.backlog_issues,
}} unstarted: cycleDetails.progress_snapshot.unstarted_issues,
totalIssues={cycleDetails.total_issues} started: cycleDetails.progress_snapshot.started_issues,
isPeekView={Boolean(peekCycle)} completed: cycleDetails.progress_snapshot.completed_issues,
/> cancelled: cycleDetails.progress_snapshot.cancelled_issues,
</div> }}
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> </div>
</Disclosure.Panel> </Disclosure.Panel>

View File

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

View File

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

View File

@ -16,42 +16,40 @@ export const TabsList: React.FC<Props> = observer((props) => {
const { durationFilter, selectedTab } = props; const { durationFilter, selectedTab } = props;
const tabsList = durationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; 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 ( return (
<Tab.List <Tab.List
as="div" 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={{ style={{
gridTemplateColumns: `repeat(${tabsList.length}, 1fr)`, gridTemplateColumns: `repeat(${tabsList.length}, 1fr)`,
}} }}
> >
<div <div
className={cn("absolute bg-custom-background-100 rounded transition-all duration-500 ease-in-out", { className={cn(
// right shadow "absolute top-1/2 left-[1px] bg-custom-background-100 rounded-[3px] transition-all duration-500 ease-in-out",
"shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1, {
// left shadow // right shadow
"shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0, "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={{ style={{
height: "calc(100% - 1px)", height: "calc(100% - 2px)",
width: `${100 / tabsList.length}%`, width: `calc(${100 / tabsList.length}% - 1px)`,
transform: `translateX(${selectedTabIndex * 100}%)`, transform: `translate(${selectedTabIndex * 100}%, -50%)`,
}} }}
/> />
{tabsList.map((tab) => ( {tabsList.map((tab) => (
<Tab <Tab
key={tab.key} key={tab.key}
className={cn( className={cn(
"relative z-[1] font-semibold text-xs rounded py-1.5 text-custom-text-400 focus:outline-none", "relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
"transition duration-500",
{ {
"text-custom-text-100 bg-custom-background-100": selectedTab === tab.key, "text-custom-text-100 bg-custom-background-100": selectedTab === tab.key,
"hover:text-custom-text-300": 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; const { dashboardId, workspaceSlug } = props;
// store hooks // store hooks
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard(); const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard();
// derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TIssuesByPriorityWidgetResponse[]>(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>) => { const handleUpdateFilters = async (filters: Partial<TIssuesByPriorityWidgetFilters>) => {
if (!widgetDetails) return; if (!widgetDetails) return;
@ -84,7 +86,7 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
filters, filters,
}); });
const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none"); const filterDates = getCustomDates(filters.duration ?? selectedDuration);
fetchWidgetStats(workspaceSlug, dashboardId, { fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY, widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
@ -92,7 +94,7 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
}; };
useEffect(() => { useEffect(() => {
const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none"); const filterDates = getCustomDates(selectedDuration);
fetchWidgetStats(workspaceSlug, dashboardId, { fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY, widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
@ -139,10 +141,10 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
Assigned by priority Assigned by priority
</Link> </Link>
<DurationFilterDropdown <DurationFilterDropdown
value={widgetDetails.widget_filters.target_date ?? "none"} value={selectedDuration}
onChange={(val) => onChange={(val) =>
handleUpdateFilters({ handleUpdateFilters({
target_date: val, duration: val,
}) })
} }
/> />

View File

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

View File

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

View File

@ -1,18 +1,78 @@
// ui // ui
import { Breadcrumbs } from "@plane/ui"; import { Breadcrumbs, CustomMenu } from "@plane/ui";
import { BreadcrumbLink } from "components/common"; import { BreadcrumbLink } from "components/common";
// components // components
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; 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 = () => ( type TUserProfileHeader = {
<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?: 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"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<SidebarHamburgerToggle /> <SidebarHamburgerToggle />
<div> <div className="flex justify-between w-full">
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink href="/profile" label="Activity Overview" />} /> <Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink href="/profile" label="Activity Overview" />} />
</Breadcrumbs> </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>
</div> </div>)
); });

View File

@ -1,13 +1,35 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { ArrowLeft, BarChart2 } from "lucide-react"; import { BarChart2, PanelRight } from "lucide-react";
// ui // ui
import { Breadcrumbs } from "@plane/ui"; import { Breadcrumbs } from "@plane/ui";
// components // components
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
import { BreadcrumbLink } from "components/common"; 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 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 ( return (
<> <>
@ -16,7 +38,7 @@ export const WorkspaceAnalyticsHeader = () => {
> >
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<SidebarHamburgerToggle /> <SidebarHamburgerToggle />
<div> <div className="flex items-center justify-between w-full">
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
@ -25,9 +47,14 @@ export const WorkspaceAnalyticsHeader = () => {
} }
/> />
</Breadcrumbs> </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> </div>
</div> </div>
</> </>
); );
}; });

View File

@ -4,13 +4,18 @@ import { useTheme } from "next-themes";
// images // images
import githubBlackImage from "/public/logos/github-black.png"; import githubBlackImage from "/public/logos/github-black.png";
import githubWhiteImage from "/public/logos/github-white.png"; import githubWhiteImage from "/public/logos/github-white.png";
// hooks
import { useEventTracker } from "hooks/store";
// components // components
import { BreadcrumbLink } from "components/common"; import { BreadcrumbLink } from "components/common";
import { Breadcrumbs } from "@plane/ui"; import { Breadcrumbs } from "@plane/ui";
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
// constants
import { CHANGELOG_REDIRECTED, GITHUB_REDIRECTED } from "constants/event-tracker";
export const WorkspaceDashboardHeader = () => { export const WorkspaceDashboardHeader = () => {
// hooks // hooks
const { captureEvent } = useEventTracker();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
return ( return (
@ -31,16 +36,26 @@ export const WorkspaceDashboardHeader = () => {
</div> </div>
<div className="flex items-center gap-3 px-3"> <div className="flex items-center gap-3 px-3">
<a <a
onClick={() =>
captureEvent(CHANGELOG_REDIRECTED, {
element: "navbar",
})
}
href="https://plane.so/changelog" href="https://plane.so/changelog"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5" 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))" /> <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>
<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" href="https://github.com/makeplane/plane"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@ -51,7 +66,7 @@ export const WorkspaceDashboardHeader = () => {
width={16} width={16}
alt="GitHub Logo" 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> </a>
</div> </div>
</div> </div>

View File

@ -20,6 +20,7 @@ import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle
// types // types
import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types"; import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types";
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { ISSUE_DELETED } from "constants/event-tracker";
type TInboxIssueActionsHeader = { type TInboxIssueActionsHeader = {
workspaceSlug: string; workspaceSlug: string;
@ -86,17 +87,12 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
throw new Error("Missing required parameters"); throw new Error("Missing required parameters");
await removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId); await removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId);
captureIssueEvent({ captureIssueEvent({
eventName: "Issue deleted", eventName: ISSUE_DELETED,
payload: { payload: {
id: inboxIssueId, id: inboxIssueId,
state: "SUCCESS", state: "SUCCESS",
element: "Inbox page", element: "Inbox page",
}, }
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
}); });
router.push({ router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, 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.", message: "Something went wrong while deleting inbox issue. Please try again.",
}); });
captureIssueEvent({ captureIssueEvent({
eventName: "Issue deleted", eventName: ISSUE_DELETED,
payload: { payload: {
id: inboxIssueId, id: inboxIssueId,
state: "FAILED", state: "FAILED",
element: "Inbox page", 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"; import { Button, Input, ToggleSwitch } from "@plane/ui";
// types // types
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
// constants
import { ISSUE_CREATED } from "constants/event-tracker";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -65,7 +67,6 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
config: { envConfig }, config: { envConfig },
} = useApplication(); } = useApplication();
const { captureIssueEvent } = useEventTracker(); const { captureIssueEvent } = useEventTracker();
const { currentWorkspace } = useWorkspace();
const { const {
control, control,
@ -94,34 +95,24 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
handleClose(); handleClose();
} else reset(defaultValues); } else reset(defaultValues);
captureIssueEvent({ captureIssueEvent({
eventName: "Issue created", eventName: ISSUE_CREATED,
payload: { payload: {
...formData, ...formData,
state: "SUCCESS", state: "SUCCESS",
element: "Inbox page", element: "Inbox page",
}, },
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
path: router.pathname, path: router.pathname,
}); });
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
captureIssueEvent({ captureIssueEvent({
eventName: "Issue created", eventName: ISSUE_CREATED,
payload: { payload: {
...formData, ...formData,
state: "FAILED", state: "FAILED",
element: "Inbox page", element: "Inbox page",
}, },
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
path: router.pathname, path: router.pathname,
}); });
}); });

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
// hooks // hooks
import { useGlobalView, useIssues, useLabel, useUser } from "hooks/store"; import { useEventTracker, useGlobalView, useIssues, useLabel, useUser } from "hooks/store";
//ui //ui
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// components // components
@ -11,6 +11,8 @@ import { AppliedFiltersList } from "components/issues";
import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types"; import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types";
import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "constants/workspace"; import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "constants/workspace";
// constants
import { GLOBAL_VIEW_UPDATED } from "constants/event-tracker";
type Props = { type Props = {
globalViewId: string; globalViewId: string;
@ -27,6 +29,7 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
} = useIssues(EIssuesStoreType.GLOBAL); } = useIssues(EIssuesStoreType.GLOBAL);
const { workspaceLabels } = useLabel(); const { workspaceLabels } = useLabel();
const { globalViewMap, updateGlobalView } = useGlobalView(); const { globalViewMap, updateGlobalView } = useGlobalView();
const { captureEvent } = useEventTracker();
const { const {
membership: { currentWorkspaceRole }, membership: { currentWorkspaceRole },
} = useUser(); } = useUser();
@ -91,6 +94,13 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
filters: { filters: {
...(appliedFilters ?? {}), ...(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"; import { createIssuePayload } from "helpers/issue.helper";
// types // types
import { IProject, TIssue } from "@plane/types"; import { IProject, TIssue } from "@plane/types";
// constants
import { ISSUE_CREATED } from "constants/event-tracker";
interface IInputProps { interface IInputProps {
formKey: string; formKey: string;
@ -111,7 +113,7 @@ export const GanttQuickAddIssueForm: React.FC<IGanttQuickAddIssueForm> = observe
quickAddCallback && quickAddCallback &&
(await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId).then((res) => { (await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId).then((res) => {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue created", eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: "Gantt quick add" }, payload: { ...res, state: "SUCCESS", element: "Gantt quick add" },
path: router.asPath, path: router.asPath,
}); });
@ -123,7 +125,7 @@ export const GanttQuickAddIssueForm: React.FC<IGanttQuickAddIssueForm> = observe
}); });
} catch (err: any) { } catch (err: any) {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue created", eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED", element: "Gantt quick add" }, payload: { ...payload, state: "FAILED", element: "Gantt quick add" },
path: router.asPath, 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 { DragDropContext, DragStart, DraggableLocation, DropResult, Droppable } from "@hello-pangea/dnd";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; 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 { IModuleIssues, IModuleIssuesFilter } from "store/issue/module";
import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views";
import { EIssueFilterType, TCreateModalStoreTypes } from "constants/issue"; import { EIssueFilterType, TCreateModalStoreTypes } from "constants/issue";
import { ISSUE_DELETED } from "constants/event-tracker";
export interface IBaseKanBanLayout { export interface IBaseKanBanLayout {
issues: IProjectIssues | ICycleIssues | IDraftIssues | IModuleIssues | IProjectViewIssues | IProfileIssues; 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 { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {};
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
// states // states
const [isDragStarted, setIsDragStarted] = useState<boolean>(false); const [isDragStarted, setIsDragStarted] = useState<boolean>(false);
const [dragState, setDragState] = useState<KanbanDragState>({}); const [dragState, setDragState] = useState<KanbanDragState>({});
@ -210,7 +213,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
setDeleteIssueModal(false); setDeleteIssueModal(false);
setDragState({}); setDragState({});
captureIssueEvent({ captureIssueEvent({
eventName: "Issue deleted", eventName: ISSUE_DELETED,
payload: { id: dragState.draggedIssueId!, state: "FAILED", element: "Kanban layout drag & drop" }, payload: { id: dragState.draggedIssueId!, state: "FAILED", element: "Kanban layout drag & drop" },
path: router.asPath, path: router.asPath,
}); });
@ -245,7 +248,10 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
</div> </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"> <div className="relative h-max w-max min-w-full bg-custom-background-90 px-2">
<DragDropContext onDragStart={onDragStart} onDragEnd={onDragEnd}> <DragDropContext onDragStart={onDragStart} onDragEnd={onDragEnd}>
{/* drag and delete component */} {/* drag and delete component */}
@ -289,6 +295,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
canEditProperties={canEditProperties} canEditProperties={canEditProperties}
storeType={storeType} storeType={storeType}
addIssuesToView={addIssuesToView} addIssuesToView={addIssuesToView}
scrollableContainerRef={scrollableContainerRef}
isDragStarted={isDragStarted}
/> />
</DragDropContext> </DragDropContext>
</div> </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 { Draggable, DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
@ -13,6 +13,7 @@ import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
import { EIssueActions } from "../types"; import { EIssueActions } from "../types";
// helper // helper
import { cn } from "helpers/common.helper"; import { cn } from "helpers/common.helper";
import RenderIfVisible from "components/core/render-if-visible-HOC";
interface IssueBlockProps { interface IssueBlockProps {
peekIssueId?: string; peekIssueId?: string;
@ -25,6 +26,9 @@ interface IssueBlockProps {
handleIssues: (issue: TIssue, action: EIssueActions) => void; handleIssues: (issue: TIssue, action: EIssueActions) => void;
quickActions: (issue: TIssue) => React.ReactNode; quickActions: (issue: TIssue) => React.ReactNode;
canEditProperties: (projectId: string | undefined) => boolean; canEditProperties: (projectId: string | undefined) => boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
isDragStarted?: boolean;
issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization
} }
interface IssueDetailsBlockProps { interface IssueDetailsBlockProps {
@ -107,6 +111,9 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
handleIssues, handleIssues,
quickActions, quickActions,
canEditProperties, canEditProperties,
scrollableContainerRef,
isDragStarted,
issueIds,
} = props; } = props;
const issue = issuesMap[issueId]; const issue = issuesMap[issueId];
@ -129,24 +136,31 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
{...provided.dragHandleProps} {...provided.dragHandleProps}
ref={provided.innerRef} 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 <div
className={cn( 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 }, { "hover:cursor-grab": !isDragDisabled },
{ "border-custom-primary-100": snapshot.isDragging }, { "border-custom-primary-100": snapshot.isDragging },
{ "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id } { "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id }
)} )}
> >
<KanbanIssueDetailsBlock <RenderIfVisible
issue={issue} classNames="space-y-2"
displayProperties={displayProperties} root={scrollableContainerRef}
handleIssues={handleIssues} defaultHeight="100px"
quickActions={quickActions} horizonatlOffset={50}
isReadOnly={!canEditIssueProperties} alwaysRender={snapshot.isDragging}
/> pauseHeightUpdateWhileRendering={isDragStarted}
changingReference={issueIds}
>
<KanbanIssueDetailsBlock
issue={issue}
displayProperties={displayProperties}
handleIssues={handleIssues}
quickActions={quickActions}
isReadOnly={!canEditIssueProperties}
/>
</RenderIfVisible>
</div> </div>
</div> </div>
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -48,64 +48,59 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
const projectDetails = getProjectById(issue.project_id); const projectDetails = getProjectById(issue.project_id);
return ( return (
<> <div
<div className={cn("min-h-12 relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm", {
className={cn( "border border-custom-primary-70 hover:border-custom-primary-70":
"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":
peekIssue && peekIssue.issueId === issue.id, peekIssue && peekIssue.issueId === issue.id,
"last:border-b-transparent": peekIssue?.issueId !== issue.id, "last:border-b-transparent": peekIssue?.issueId !== issue.id
} })}
)} >
> {displayProperties && displayProperties?.key && (
{displayProperties && displayProperties?.key && ( <div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300"> {projectDetails?.identifier}-{issue.sequence_id}
{projectDetails?.identifier}-{issue.sequence_id} </div>
</div> )}
)}
{issue?.tempId !== undefined && ( {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="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}> <Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
<span>{issue.name}</span> <span>{issue.name}</span>
</Tooltip> </Tooltip>
) : ( </ControlLink>
<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>
)}
<div className="ml-auto flex flex-shrink-0 items-center gap-2"> <div className="ml-auto flex flex-shrink-0 items-center gap-2">
{!issue?.tempId ? ( {!issue?.tempId ? (
<> <>
<IssueProperties <IssueProperties
className="relative flex items-center gap-2 whitespace-nowrap" className="relative flex items-center gap-2 whitespace-nowrap"
issue={issue} issue={issue}
isReadOnly={!canEditIssueProperties} isReadOnly={!canEditIssueProperties}
handleIssues={updateIssue} handleIssues={updateIssue}
displayProperties={displayProperties} displayProperties={displayProperties}
activeLayout="List" activeLayout="List"
/> />
{quickActions(issue)} {quickActions(issue)}
</> </>
) : ( ) : (
<div className="h-4 w-4"> <div className="h-4 w-4">
<Spinner className="h-4 w-4" /> <Spinner className="h-4 w-4" />
</div> </div>
)} )}
</div>
</div> </div>
</> </div>
); );
}); });

View File

@ -1,9 +1,10 @@
import { FC } from "react"; import { FC, MutableRefObject } from "react";
// components // components
import { IssueBlock } from "components/issues"; import { IssueBlock } from "components/issues";
// types // types
import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types"; import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types";
import { EIssueActions } from "../types"; import { EIssueActions } from "../types";
import RenderIfVisible from "components/core/render-if-visible-HOC";
interface Props { interface Props {
issueIds: TGroupedIssues | TUnGroupedIssues | any; issueIds: TGroupedIssues | TUnGroupedIssues | any;
@ -12,27 +13,34 @@ interface Props {
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>; handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
quickActions: (issue: TIssue) => React.ReactNode; quickActions: (issue: TIssue) => React.ReactNode;
displayProperties: IIssueDisplayProperties | undefined; displayProperties: IIssueDisplayProperties | undefined;
containerRef: MutableRefObject<HTMLDivElement | null>;
} }
export const IssueBlocksList: FC<Props> = (props) => { export const IssueBlocksList: FC<Props> = (props) => {
const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties } = props; const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties, containerRef } = props;
return ( return (
<div className="relative h-full w-full"> <div className="relative h-full w-full">
{issueIds && issueIds.length > 0 ? ( {issueIds && issueIds.length > 0 ? (
issueIds.map((issueId: string) => { issueIds.map((issueId: string) => {
if (!issueId) return null; if (!issueId) return null;
return ( return (
<IssueBlock <RenderIfVisible
key={issueId} key={`${issueId}`}
issueId={issueId} defaultHeight="3rem"
issuesMap={issuesMap} root={containerRef}
handleIssues={handleIssues} classNames={"relative border border-transparent border-b-custom-border-200 last:border-b-transparent"}
quickActions={quickActions} changingReference={issueIds}
canEditProperties={canEditProperties} >
displayProperties={displayProperties} <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 // components
import { IssueBlocksList, ListQuickAddIssueForm } from "components/issues"; import { IssueBlocksList, ListQuickAddIssueForm } from "components/issues";
import { HeaderGroupByCard } from "./headers/group-by-card";
// hooks // hooks
import { useLabel, useMember, useProject, useProjectState } from "hooks/store"; import { useLabel, useMember, useProject, useProjectState } from "hooks/store";
// types // types
@ -10,12 +12,12 @@ import {
IIssueDisplayProperties, IIssueDisplayProperties,
TIssueMap, TIssueMap,
TUnGroupedIssues, TUnGroupedIssues,
IGroupByColumn,
} from "@plane/types"; } from "@plane/types";
import { EIssueActions } from "../types"; import { EIssueActions } from "../types";
// constants // constants
import { HeaderGroupByCard } from "./headers/group-by-card";
import { getGroupByColumns } from "../utils";
import { TCreateModalStoreTypes } from "constants/issue"; import { TCreateModalStoreTypes } from "constants/issue";
import { getGroupByColumns } from "../utils";
export interface IGroupByList { export interface IGroupByList {
issueIds: TGroupedIssues | TUnGroupedIssues | any; issueIds: TGroupedIssues | TUnGroupedIssues | any;
@ -64,9 +66,11 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
const label = useLabel(); const label = useLabel();
const projectState = useProjectState(); 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 prePopulateQuickAddData = (groupByKey: string | null, value: any) => {
const defaultState = projectState.projectStates?.find((state) => state.default); const defaultState = projectState.projectStates?.find((state) => state.default);
@ -104,11 +108,11 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
const isGroupByCreatedBy = group_by === "created_by"; const isGroupByCreatedBy = group_by === "created_by";
return ( return (
<div className="relative h-full w-full"> <div ref={containerRef} className="relative overflow-auto h-full w-full">
{list && {groups &&
list.length > 0 && groups.length > 0 &&
list.map( groups.map(
(_list: any) => (_list: IGroupByColumn) =>
validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && ( validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
<div key={_list.id} className={`flex flex-shrink-0 flex-col`}> <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"> <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} quickActions={quickActions}
displayProperties={displayProperties} displayProperties={displayProperties}
canEditProperties={canEditProperties} canEditProperties={canEditProperties}
containerRef={containerRef}
/> />
)} )}

View File

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

View File

@ -18,6 +18,8 @@ import {
import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { renderFormattedPayloadDate } from "helpers/date-time.helper";
// types // types
import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types";
// constants
import { ISSUE_UPDATED } from "constants/event-tracker";
export interface IIssueProperties { export interface IIssueProperties {
issue: TIssue; issue: TIssue;
@ -40,7 +42,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const handleState = (stateId: string) => { const handleState = (stateId: string) => {
handleIssues({ ...issue, state_id: stateId }).then(() => { handleIssues({ ...issue, state_id: stateId }).then(() => {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout }, payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath, path: router.asPath,
updates: { updates: {
@ -54,7 +56,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const handlePriority = (value: TIssuePriorities) => { const handlePriority = (value: TIssuePriorities) => {
handleIssues({ ...issue, priority: value }).then(() => { handleIssues({ ...issue, priority: value }).then(() => {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout }, payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath, path: router.asPath,
updates: { updates: {
@ -68,7 +70,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const handleLabel = (ids: string[]) => { const handleLabel = (ids: string[]) => {
handleIssues({ ...issue, label_ids: ids }).then(() => { handleIssues({ ...issue, label_ids: ids }).then(() => {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout }, payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath, path: router.asPath,
updates: { updates: {
@ -82,7 +84,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const handleAssignee = (ids: string[]) => { const handleAssignee = (ids: string[]) => {
handleIssues({ ...issue, assignee_ids: ids }).then(() => { handleIssues({ ...issue, assignee_ids: ids }).then(() => {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout }, payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath, path: router.asPath,
updates: { updates: {
@ -96,7 +98,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const handleStartDate = (date: Date | null) => { const handleStartDate = (date: Date | null) => {
handleIssues({ ...issue, start_date: date ? renderFormattedPayloadDate(date) : null }).then(() => { handleIssues({ ...issue, start_date: date ? renderFormattedPayloadDate(date) : null }).then(() => {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout }, payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath, path: router.asPath,
updates: { updates: {
@ -110,7 +112,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const handleTargetDate = (date: Date | null) => { const handleTargetDate = (date: Date | null) => {
handleIssues({ ...issue, target_date: date ? renderFormattedPayloadDate(date) : null }).then(() => { handleIssues({ ...issue, target_date: date ? renderFormattedPayloadDate(date) : null }).then(() => {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout }, payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath, path: router.asPath,
updates: { updates: {
@ -124,7 +126,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const handleEstimate = (value: number | null) => { const handleEstimate = (value: number | null) => {
handleIssues({ ...issue, estimate_point: value }).then(() => { handleIssues({ ...issue, estimate_point: value }).then(() => {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout }, payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: router.asPath, path: router.asPath,
updates: { 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"> <div className="h-11 border-b-[0.5px] border-custom-border-200">
<DateDropdown <DateDropdown
value={issue.target_date} value={issue.target_date}
minDate={issue.start_date ? new Date(issue.start_date) : undefined}
onChange={(data) => { onChange={(data) => {
const targetDate = data ? renderFormattedPayloadDate(data) : null; const targetDate = data ? renderFormattedPayloadDate(data) : null;
onChange( 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"> <div className="h-11 border-b-[0.5px] border-custom-border-200">
<DateDropdown <DateDropdown
value={issue.start_date} value={issue.start_date}
maxDate={issue.target_date ? new Date(issue.target_date) : undefined}
onChange={(data) => { onChange={(data) => {
const startDate = data ? renderFormattedPayloadDate(data) : null; const startDate = data ? renderFormattedPayloadDate(data) : null;
onChange( 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 { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// icons // icons
@ -7,6 +7,7 @@ import { ChevronRight, MoreHorizontal } from "lucide-react";
import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
// components // components
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import RenderIfVisible from "components/core/render-if-visible-HOC";
import { IssueColumn } from "./issue-column"; import { IssueColumn } from "./issue-column";
// ui // ui
import { ControlLink, Tooltip } from "@plane/ui"; import { ControlLink, Tooltip } from "@plane/ui";
@ -32,6 +33,9 @@ interface Props {
portalElement: React.MutableRefObject<HTMLDivElement | null>; portalElement: React.MutableRefObject<HTMLDivElement | null>;
nestingLevel: number; nestingLevel: number;
issueId: string; issueId: string;
isScrolled: MutableRefObject<boolean>;
containerRef: MutableRefObject<HTMLTableElement | null>;
issueIds: string[];
} }
export const SpreadsheetIssueRow = observer((props: Props) => { export const SpreadsheetIssueRow = observer((props: Props) => {
@ -44,8 +48,96 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
handleIssues, handleIssues,
quickActions, quickActions,
canEditProperties, canEditProperties,
isScrolled,
containerRef,
issueIds,
} = props; } = 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 // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -54,8 +146,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
const { peekIssue, setPeekIssue } = useIssueDetail(); const { peekIssue, setPeekIssue } = useIssueDetail();
// states // states
const [isMenuActive, setIsMenuActive] = useState(false); const [isMenuActive, setIsMenuActive] = useState(false);
const [isExpanded, setExpanded] = useState<boolean>(false);
const menuActionRef = useRef<HTMLDivElement | null>(null); const menuActionRef = useRef<HTMLDivElement | null>(null);
const handleIssuePeekOverview = (issue: TIssue) => { const handleIssuePeekOverview = (issue: TIssue) => {
@ -66,7 +156,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
const { subIssues: subIssuesStore, issue } = useIssueDetail(); const { subIssues: subIssuesStore, issue } = useIssueDetail();
const issueDetail = issue.getIssueById(issueId); const issueDetail = issue.getIssueById(issueId);
const subIssues = subIssuesStore.subIssuesByIssueId(issueId);
const paddingLeft = `${nestingLevel * 54}px`; const paddingLeft = `${nestingLevel * 54}px`;
@ -91,81 +180,77 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
<MoreHorizontal className="h-3.5 w-3.5" /> <MoreHorizontal className="h-3.5 w-3.5" />
</div> </div>
); );
if (!issueDetail) return null; if (!issueDetail) return null;
const disableUserActions = !canEditProperties(issueDetail.project_id); const disableUserActions = !canEditProperties(issueDetail.project_id);
return ( return (
<> <>
<tr <td
className={cn({ className={cn(
"border border-custom-primary-70 hover:border-custom-primary-70": peekIssue?.issueId === issueDetail.id, "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 */} <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
<td <div
className={cn( className="flex min-w-min items-center gap-1.5 px-4 py-2.5 pr-0"
"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", style={issueDetail.parent_id && nestingLevel !== 0 ? { paddingLeft } : {}}
{ >
"border-b-[0.5px]": peekIssue?.issueId !== issueDetail.id, <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 ${
tabIndex={0} isMenuActive ? "opacity-0" : "opacity-100"
> }`}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key"> >
<div {getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id}
className="flex min-w-min items-center gap-1.5 px-4 py-2.5 pr-0" </span>
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) && ( {canEditProperties(issueDetail.project_id) && (
<div className={`absolute left-2.5 top-0 hidden group-hover:block ${isMenuActive ? "!block" : ""}`}> <div className={`absolute left-2.5 top-0 hidden group-hover:block ${isMenuActive ? "!block" : ""}`}>
{quickActions(issueDetail, customActionButton, portalElement.current)} {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>
</div> </div>
)} )}
</div> </div>
</WithDisplayPropertiesHOC>
<ControlLink {issueDetail.sub_issues_count > 0 && (
href={`/${workspaceSlug}/projects/${issueDetail.project_id}/issues/${issueId}`} <div className="flex h-6 w-6 items-center justify-center">
target="_blank" <button
onClick={() => handleIssuePeekOverview(issueDetail)} className="h-5 w-5 cursor-pointer rounded-sm hover:bg-custom-background-90 hover:text-custom-text-100"
className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" onClick={() => handleToggleExpand()}
>
<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} <ChevronRight className={`h-3.5 w-3.5 ${isExpanded ? "rotate-90" : ""}`} />
</div> </button>
</Tooltip> </div>
</div> )}
</ControlLink> </div>
</td> </WithDisplayPropertiesHOC>
{/* Rest of the columns */} <ControlLink
{SPREADSHEET_PROPERTY_LIST.map((property) => ( 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 <IssueColumn
displayProperties={displayProperties} displayProperties={displayProperties}
issueDetail={issueDetail} issueDetail={issueDetail}
@ -175,24 +260,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
isEstimateEnabled={isEstimateEnabled} 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"; import { createIssuePayload } from "helpers/issue.helper";
// types // types
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
// constants
import { ISSUE_CREATED } from "constants/event-tracker";
type Props = { type Props = {
formKey: keyof TIssue; 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( (await quickAddCallback(currentWorkspace.slug, currentProjectDetails.id, { ...payload } as TIssue, viewId).then(
(res) => { (res) => {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue created", eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: "Spreadsheet quick add" }, payload: { ...res, state: "SUCCESS", element: "Spreadsheet quick add" },
path: router.asPath, path: router.asPath,
}); });
@ -175,7 +177,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC<Props> = observer((props) =>
}); });
} catch (err: any) { } catch (err: any) {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue created", eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED", element: "Spreadsheet quick add" }, payload: { ...payload, state: "FAILED", element: "Spreadsheet quick add" },
path: router.asPath, path: router.asPath,
}); });

View File

@ -1,4 +1,5 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { MutableRefObject, useEffect, useRef } from "react";
//types //types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types";
import { EIssueActions } from "../types"; import { EIssueActions } from "../types";
@ -21,6 +22,7 @@ type Props = {
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>; handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
canEditProperties: (projectId: string | undefined) => boolean; canEditProperties: (projectId: string | undefined) => boolean;
portalElement: React.MutableRefObject<HTMLDivElement | null>; portalElement: React.MutableRefObject<HTMLDivElement | null>;
containerRef: MutableRefObject<HTMLTableElement | null>;
}; };
export const SpreadsheetTable = observer((props: Props) => { export const SpreadsheetTable = observer((props: Props) => {
@ -34,8 +36,45 @@ export const SpreadsheetTable = observer((props: Props) => {
quickActions, quickActions,
handleIssues, handleIssues,
canEditProperties, canEditProperties,
containerRef,
} = props; } = 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(); const handleKeyBoardNavigation = useTableKeyboardNavigation();
return ( return (
@ -58,6 +97,9 @@ export const SpreadsheetTable = observer((props: Props) => {
isEstimateEnabled={isEstimateEnabled} isEstimateEnabled={isEstimateEnabled}
handleIssues={handleIssues} handleIssues={handleIssues}
portalElement={portalElement} portalElement={portalElement}
containerRef={containerRef}
isScrolled={isScrolled}
issueIds={issueIds}
/> />
))} ))}
</tbody> </tbody>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef } from "react"; import React, { useRef } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// components // components
import { Spinner } from "@plane/ui"; import { Spinner } from "@plane/ui";
@ -48,8 +48,6 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
enableQuickCreateIssue, enableQuickCreateIssue,
disableIssueCreation, disableIssueCreation,
} = props; } = props;
// states
const isScrolled = useRef(false);
// refs // refs
const containerRef = useRef<HTMLTableElement | null>(null); const containerRef = useRef<HTMLTableElement | null>(null);
const portalRef = useRef<HTMLDivElement | 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 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) if (!issueIds || issueIds.length === 0)
return ( return (
<div className="grid h-full w-full place-items-center"> <div className="grid h-full w-full place-items-center">
@ -112,6 +77,7 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
quickActions={quickActions} quickActions={quickActions}
handleIssues={handleIssues} handleIssues={handleIssues}
canEditProperties={canEditProperties} canEditProperties={canEditProperties}
containerRef={containerRef}
/> />
</div> </div>
<div className="border-t border-custom-border-100"> <div className="border-t border-custom-border-100">

View File

@ -1,10 +1,10 @@
import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui"; 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 { renderEmoji } from "helpers/emoji.helper";
import { IMemberRootStore } from "store/member"; import { IMemberRootStore } from "store/member";
import { IProjectStore } from "store/project/project.store"; import { IProjectStore } from "store/project/project.store";
import { IStateStore } from "store/state.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 { STATE_GROUPS } from "constants/state";
import { ILabelStore } from "store/label.store"; import { ILabelStore } from "store/label.store";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,6 +34,7 @@ import { ILinkDetails, IModule, ModuleLink } from "@plane/types";
// constant // constant
import { MODULE_STATUS } from "constants/module"; import { MODULE_STATUS } from "constants/module";
import { EUserProjectRoles } from "constants/project"; 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> = { const defaultValues: Partial<IModule> = {
lead: "", lead: "",
@ -66,7 +67,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink } = useModule(); const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink } = useModule();
const { setTrackElement } = useEventTracker(); const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker();
const moduleDetails = getModuleById(moduleId); const moduleDetails = getModuleById(moduleId);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -77,7 +78,19 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
const submitChanges = (data: Partial<IModule>) => { const submitChanges = (data: Partial<IModule>) => {
if (!workspaceSlug || !projectId || !moduleId) return; 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) => { 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) createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload)
.then(() => { .then(() => {
captureEvent(MODULE_LINK_CREATED, {
module_id: moduleId,
state: "SUCCESS",
});
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Module link created", 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) updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload)
.then(() => { .then(() => {
captureEvent(MODULE_LINK_UPDATED, {
module_id: moduleId,
state: "SUCCESS",
});
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Module link updated", title: "Module link updated",
@ -129,6 +150,10 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
deleteModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId) deleteModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId)
.then(() => { .then(() => {
captureEvent(MODULE_LINK_DELETED, {
module_id: moduleId,
state: "SUCCESS",
});
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Module link deleted", 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") !== "") { if (watch("start_date") && watch("target_date") && watch("start_date") !== "" && watch("start_date") !== "") {
submitChanges({ submitChanges({
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
target_date: renderFormattedPayloadDate(`${watch("target_date")}`), target_date: renderFormattedPayloadDate(`${watch("target_date")}`),
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
}); });
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -294,7 +319,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<Controller <Controller
control={control} control={control}
name="status" name="status"
render={({ field: { value } }) => ( render={({ field: { value, onChange } }) => (
<CustomSelect <CustomSelect
customButton={ customButton={
<span <span

View File

@ -1,10 +1,12 @@
import React from "react"; import React, { useEffect, useRef } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { ArchiveRestore, Clock, MessageSquare, User2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Menu } from "@headlessui/react";
import { ArchiveRestore, Clock, MessageSquare, MoreVertical, User2 } from "lucide-react";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store";
// icons // icons
import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui"; import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui";
// constants // constants
@ -13,9 +15,12 @@ import { snoozeOptions } from "constants/notification";
import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "helpers/string.helper"; import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "helpers/string.helper";
import { calculateTimeAgo, renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper"; import { calculateTimeAgo, renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper";
// type // 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 = { type NotificationCardProps = {
selectedTab: NotificationType;
notification: IUserNotification; notification: IUserNotification;
isSnoozedTabOpen: boolean; isSnoozedTabOpen: boolean;
closePopover: () => void; closePopover: () => void;
@ -28,6 +33,7 @@ type NotificationCardProps = {
export const NotificationCard: React.FC<NotificationCardProps> = (props) => { export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
const { const {
selectedTab,
notification, notification,
isSnoozedTabOpen, isSnoozedTabOpen,
closePopover, closePopover,
@ -37,11 +43,78 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
setSelectedNotificationForSnooze, setSelectedNotificationForSnooze,
markSnoozeNotification, markSnoozeNotification,
} = props; } = props;
// store hooks
const { captureEvent } = useEventTracker();
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// states
const [showSnoozeOptions, setshowSnoozeOptions] = React.useState(false);
// toast alert
const { setToastAlert } = useToast(); 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; if (isSnoozedTabOpen && new Date(notification.snoozed_till!) < new Date()) return null;
@ -49,6 +122,10 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
<Link <Link
onClick={() => { onClick={() => {
markNotificationReadStatus(notification.id); markNotificationReadStatus(notification.id);
captureEvent(ISSUE_OPENED, {
issue_id: notification.data.issue.id,
element: "notification",
});
closePopover(); closePopover();
}} }}
href={`/${workspaceSlug}/projects/${notification.project}/${ href={`/${workspaceSlug}/projects/${notification.project}/${
@ -87,57 +164,136 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
)} )}
</div> </div>
<div className="w-full space-y-2.5 overflow-hidden"> <div className="w-full space-y-2.5 overflow-hidden">
{!notification.message ? ( <div className="flex items-start">
<div className="w-full break-words text-sm"> {!notification.message ? (
<span className="font-semibold"> <div className="w-full break-words text-sm">
{notification.triggered_by_details.is_bot <span className="font-semibold">
? notification.triggered_by_details.first_name {notification.triggered_by_details.is_bot
: notification.triggered_by_details.display_name}{" "} ? notification.triggered_by_details.first_name
</span> : notification.triggered_by_details.display_name}{" "}
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.verb}{" "} </span>
{notification.data.issue_activity.field === "comment" {notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.verb}{" "}
? "commented" {notification.data.issue_activity.field === "comment"
: notification.data.issue_activity.field === "None" ? "commented"
? null : notification.data.issue_activity.field === "None"
: replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "} ? null
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.field !== "None" : replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "}
? "to" {notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.field !== "None"
: ""} ? "to"
<span className="font-semibold"> : ""}
{" "} <span className="font-semibold">
{notification.data.issue_activity.field !== "None" ? ( {" "}
notification.data.issue_activity.field !== "comment" ? ( {notification.data.issue_activity.field !== "None" ? (
notification.data.issue_activity.field === "target_date" ? ( notification.data.issue_activity.field !== "comment" ? (
renderFormattedDate(notification.data.issue_activity.new_value) notification.data.issue_activity.field === "target_date" ? (
) : notification.data.issue_activity.field === "attachment" ? ( renderFormattedDate(notification.data.issue_activity.new_value)
"the issue" ) : notification.data.issue_activity.field === "attachment" ? (
) : notification.data.issue_activity.field === "description" ? ( "the issue"
stripAndTruncateHTML(notification.data.issue_activity.new_value, 55) ) : 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> "the issue and assigned it to you."
{`"`} )}
{notification.data.issue_activity.new_value.length > 55 </span>
? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..." </div>
: notification.data.issue_activity.issue_comment} ) : (
{`"`} <div className="w-full break-words text-sm">
</span> <span className="semi-bold">{notification.message}</span>
) </div>
) : ( )}
"the issue and assigned it to you." <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>
) : ( </div>
<div className="w-full break-words text-sm">
<span className="semi-bold">{notification.message}</span>
</div>
)}
<div className="flex justify-between gap-2 text-xs"> <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( {truncateText(
`${notification.data.issue.identifier}-${notification.data.issue.sequence_id} ${notification.data.issue.name}`, `${notification.data.issue.identifier}-${notification.data.issue.sequence_id} ${notification.data.issue.name}`,
50 50
@ -152,7 +308,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
</span> </span>
</p> </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>
</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" />, icon: <MessageSquare className="h-3.5 w-3.5 text-custom-text-300" />,
onClick: () => { onClick: () => {
markNotificationReadStatusToggle(notification.id).then(() => { markNotificationReadStatusToggle(notification.id).then(() => {
captureEvent(NOTIFICATIONS_READ, {
issue_id: notification.data.issue.id,
tab: selectedTab,
state: "SUCCESS",
});
setToastAlert({ setToastAlert({
title: notification.read_at ? "Notification marked as read" : "Notification marked as unread", title: notification.read_at ? "Notification marked as read" : "Notification marked as unread",
type: "success", type: "success",
@ -181,6 +342,11 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
), ),
onClick: () => { onClick: () => {
markNotificationArchivedStatus(notification.id).then(() => { markNotificationArchivedStatus(notification.id).then(() => {
captureEvent(NOTIFICATION_ARCHIVED, {
issue_id: notification.data.issue.id,
tab: selectedTab,
state: "SUCCESS",
});
setToastAlert({ setToastAlert({
title: notification.archived_at ? "Notification un-archived" : "Notification archived", title: notification.archived_at ? "Notification un-archived" : "Notification archived",
type: "success", type: "success",
@ -195,7 +361,6 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
item.onClick(); item.onClick();
}} }}
key={item.id} key={item.id}
@ -208,9 +373,6 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
<Tooltip tooltipContent="Snooze"> <Tooltip tooltipContent="Snooze">
<CustomMenu <CustomMenu
className="flex items-center" className="flex items-center"
menuButtonOnClick={(e: { stopPropagation: () => void }) => {
e.stopPropagation();
}}
customButton={ 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"> <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" /> <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(() => { markSnoozeNotification(notification.id, item.value).then(() => {
captureEvent(NOTIFICATION_SNOOZED, {
issue_id: notification.data.issue.id,
tab: selectedTab,
state: "SUCCESS",
});
setToastAlert({ setToastAlert({
title: `Notification snoozed till ${renderFormattedDate(item.value)}`, title: `Notification snoozed till ${renderFormattedDate(item.value)}`,
type: "success", type: "success",

View File

@ -1,11 +1,22 @@
import React from "react"; import React from "react";
import { ArrowLeft, CheckCheck, Clock, ListFilter, MoreVertical, RefreshCw, X } from "lucide-react"; import { ArrowLeft, CheckCheck, Clock, ListFilter, MoreVertical, RefreshCw, X } from "lucide-react";
// components
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
// ui // ui
import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui"; import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui";
// hooks
import { useEventTracker } from "hooks/store";
// helpers // helpers
import { getNumberCount } from "helpers/string.helper"; import { getNumberCount } from "helpers/string.helper";
// type // type
import type { NotificationType, NotificationCount } from "@plane/types"; import type { NotificationType, NotificationCount } from "@plane/types";
// constants
import {
ARCHIVED_NOTIFICATIONS,
NOTIFICATIONS_READ,
SNOOZED_NOTIFICATIONS,
UNREAD_NOTIFICATIONS,
} from "constants/event-tracker";
type NotificationHeaderProps = { type NotificationHeaderProps = {
notificationCount?: NotificationCount | null; notificationCount?: NotificationCount | null;
@ -39,6 +50,8 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
setSelectedTab, setSelectedTab,
markAllNotificationsAsRead, markAllNotificationsAsRead,
} = props; } = props;
// store hooks
const { captureEvent } = useEventTracker();
const notificationTabs: Array<{ const notificationTabs: Array<{
label: string; label: string;
@ -65,7 +78,11 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
return ( return (
<> <>
<div className="flex items-center justify-between px-5 pt-5"> <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"> <div className="flex items-center justify-center gap-x-4 text-custom-text-200">
<Tooltip tooltipContent="Refresh"> <Tooltip tooltipContent="Refresh">
<button <button
@ -84,6 +101,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
setSnoozed(false); setSnoozed(false);
setArchived(false); setArchived(false);
setReadNotification((prev) => !prev); setReadNotification((prev) => !prev);
captureEvent(UNREAD_NOTIFICATIONS);
}} }}
> >
<ListFilter className="h-3.5 w-3.5" /> <ListFilter className="h-3.5 w-3.5" />
@ -97,7 +115,12 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
} }
closeOnSelect closeOnSelect
> >
<CustomMenu.MenuItem onClick={markAllNotificationsAsRead}> <CustomMenu.MenuItem
onClick={() => {
markAllNotificationsAsRead();
captureEvent(NOTIFICATIONS_READ);
}}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCheck className="h-3.5 w-3.5" /> <CheckCheck className="h-3.5 w-3.5" />
Mark all as read Mark all as read
@ -108,6 +131,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
setArchived(false); setArchived(false);
setReadNotification(false); setReadNotification(false);
setSnoozed((prev) => !prev); setSnoozed((prev) => !prev);
captureEvent(SNOOZED_NOTIFICATIONS);
}} }}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -120,6 +144,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
setSnoozed(false); setSnoozed(false);
setReadNotification(false); setReadNotification(false);
setArchived((prev) => !prev); setArchived((prev) => !prev);
captureEvent(ARCHIVED_NOTIFICATIONS);
}} }}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -128,11 +153,13 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
</CustomMenu> </CustomMenu>
<Tooltip tooltipContent="Close"> <div className="hidden md:block">
<button type="button" onClick={() => closePopover()}> <Tooltip tooltipContent="Close">
<X className="h-3.5 w-3.5" /> <button type="button" onClick={() => closePopover()}>
</button> <X className="h-3.5 w-3.5" />
</Tooltip> </button>
</Tooltip>
</div>
</div> </div>
</div> </div>
<div className="mt-5 w-full border-b border-custom-border-300 px-5"> <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 // hooks
import { useApplication } from "hooks/store"; import { useApplication } from "hooks/store";
import useUserNotification from "hooks/use-user-notifications"; import useUserNotification from "hooks/use-user-notifications";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components // components
import { EmptyState } from "components/common"; import { EmptyState } from "components/common";
import { SnoozeNotificationModal, NotificationCard, NotificationHeader } from "components/notifications"; 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"; import { getNumberCount } from "helpers/string.helper";
export const NotificationPopover = observer(() => { export const NotificationPopover = observer(() => {
// states
const [isActive, setIsActive] = React.useState(false);
// store hooks // store hooks
const { theme: themeStore } = useApplication(); const { theme: themeStore } = useApplication();
// refs
const notificationPopoverRef = React.useRef<HTMLDivElement | null>(null);
const { const {
notifications, notifications,
@ -44,8 +49,11 @@ export const NotificationPopover = observer(() => {
setFetchNotifications, setFetchNotifications,
markAllNotificationsAsRead, markAllNotificationsAsRead,
} = useUserNotification(); } = useUserNotification();
const isSidebarCollapsed = themeStore.sidebarCollapsed; const isSidebarCollapsed = themeStore.sidebarCollapsed;
useOutsideClickDetector(notificationPopoverRef, () => {
// if snooze modal is open, then don't close the popover
if (selectedNotificationForSnooze === null) setIsActive(false);
});
return ( return (
<> <>
@ -54,141 +62,143 @@ export const NotificationPopover = observer(() => {
onClose={() => setSelectedNotificationForSnooze(null)} onClose={() => setSelectedNotificationForSnooze(null)}
onSubmit={markSnoozeNotification} onSubmit={markSnoozeNotification}
notification={notifications?.find((notification) => notification.id === selectedNotificationForSnooze) || null} notification={notifications?.find((notification) => notification.id === selectedNotificationForSnooze) || null}
onSuccess={() => { onSuccess={() => setSelectedNotificationForSnooze(null)}
setSelectedNotificationForSnooze(null);
}}
/> />
<Popover className="relative w-full"> <Popover ref={notificationPopoverRef} className="md:relative w-full">
{({ open: isActive, close: closePopover }) => { <>
if (isActive) setFetchNotifications(true); <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 ( {notifications ? (
<> notifications.length > 0 ? (
<Tooltip tooltipContent="Notifications" position="right" className="ml-2" disabled={!isSidebarCollapsed}> <div className="h-full overflow-y-auto">
<Popover.Button <div className="divide-y divide-custom-border-100">
className={`group relative flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${ {notifications.map((notification) => (
isActive <NotificationCard
? "bg-custom-primary-100/10 text-custom-primary-100" selectedTab={selectedTab}
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80" key={notification.id}
} ${isSidebarCollapsed ? "justify-center" : ""}`} isSnoozedTabOpen={snoozed}
> closePopover={() => setIsActive(false)}
<Bell className="h-4 w-4" /> notification={notification}
{isSidebarCollapsed ? null : <span>Notifications</span>} markNotificationArchivedStatus={markNotificationArchivedStatus}
{totalNotificationCount && totalNotificationCount > 0 ? ( markNotificationReadStatus={markNotificationAsRead}
isSidebarCollapsed ? ( markNotificationReadStatusToggle={markNotificationReadStatus}
<span className="absolute right-3.5 top-2 h-2 w-2 rounded-full bg-custom-primary-300" /> setSelectedNotificationForSnooze={setSelectedNotificationForSnooze}
) : ( markSnoozeNotification={markSnoozeNotification}
<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}
/> />
))}
</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> </div>
) )}
) : ( {hasMore && !isLoadingMore && (
<Loader className="space-y-4 overflow-y-auto p-5"> <button
<Loader.Item height="50px" /> type="button"
<Loader.Item height="50px" /> className="my-6 flex w-full items-center justify-center text-sm font-medium text-custom-primary-100"
<Loader.Item height="50px" /> disabled={isLoadingMore}
<Loader.Item height="50px" /> onClick={() => {
<Loader.Item height="50px" /> setSize((prev) => prev + 1);
</Loader> }}
)} >
</Popover.Panel> Load More
</Transition> </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> </Popover>
</> </>
); );

View File

@ -109,7 +109,12 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
}; };
const handleClose = () => { 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(() => { const timeout = setTimeout(() => {
reset({ ...defaultValues }); reset({ ...defaultValues });
clearTimeout(timeout); clearTimeout(timeout);
@ -142,7 +147,7 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" 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)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100"> <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> </div>
<div className="mt-5 flex items-center gap-3"> <div className="mt-5 flex flex-col md:!flex-row md:items-center gap-3">
<div className="flex-1"> <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> <h6 className="mb-2 block text-sm font-medium text-custom-text-400">Pick a date</h6>
<Controller <Controller
name="date" name="date"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,12 +4,14 @@ import { observer } from "mobx-react-lite";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
// hooks // hooks
import { usePage } from "hooks/store"; import { useEventTracker, usePage } from "hooks/store";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// ui // ui
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// types // types
import { useProjectPages } from "hooks/store/use-project-page"; import { useProjectPages } from "hooks/store/use-project-page";
// constants
import { PAGE_DELETED } from "constants/event-tracker";
type TConfirmPageDeletionProps = { type TConfirmPageDeletionProps = {
pageId: string; pageId: string;
@ -27,6 +29,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
// store hooks // store hooks
const { deletePage } = useProjectPages(); const { deletePage } = useProjectPages();
const { capturePageEvent } = useEventTracker();
const pageStore = usePage(pageId); const pageStore = usePage(pageId);
// toast alert // 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 // 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) await deletePage(workspaceSlug.toString(), projectId as string, pageId)
.then(() => { .then(() => {
capturePageEvent({
eventName: PAGE_DELETED,
payload: {
...pageStore,
state: "SUCCESS",
},
});
handleClose(); handleClose();
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -57,6 +67,13 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
}); });
}) })
.catch(() => { .catch(() => {
capturePageEvent({
eventName: PAGE_DELETED,
payload: {
...pageStore,
state: "FAILED",
},
});
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "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; const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB;
return ( 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"> <div className="flex items-center overflow-x-scroll">
{tabsList.map((tab) => ( {tabsList.map((tab) => (
<Link key={tab.route} href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}> <Link key={tab.route} href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}>
<span <span
className={`flex whitespace-nowrap border-b-2 p-4 text-sm font-medium outline-none ${ className={`flex whitespace-nowrap border-b-2 p-4 text-sm font-medium outline-none ${router.pathname === tab.selected
router.pathname === tab.selected ? "border-custom-primary-100 text-custom-primary-100"
? "border-custom-primary-100 text-custom-primary-100" : "border-transparent"
: "border-transparent" }`}
}`}
> >
{tab.label} {tab.label}
</span> </span>

View File

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

View File

@ -4,7 +4,7 @@ import useSWR from "swr";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
import { useUser } from "hooks/store"; import { useApplication, useUser } from "hooks/store";
// services // services
import { UserService } from "services/user.service"; import { UserService } from "services/user.service";
// components // components
@ -18,6 +18,8 @@ import { renderFormattedDate } from "helpers/date-time.helper";
import { renderEmoji } from "helpers/emoji.helper"; import { renderEmoji } from "helpers/emoji.helper";
// fetch-keys // fetch-keys
import { USER_PROFILE_PROJECT_SEGREGATION } from "constants/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 // services
const userService = new UserService(); const userService = new UserService();
@ -28,6 +30,8 @@ export const ProfileSidebar = observer(() => {
const { workspaceSlug, userId } = router.query; const { workspaceSlug, userId } = router.query;
// store hooks // store hooks
const { currentUser } = useUser(); const { currentUser } = useUser();
const { theme: themeStore } = useApplication();
const ref = useRef<HTMLDivElement>(null);
const { data: userProjectsData } = useSWR( const { data: userProjectsData } = useSWR(
workspaceSlug && userId ? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString()) : null, workspaceSlug && userId ? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString()) : null,
@ -36,6 +40,14 @@ export const ProfileSidebar = observer(() => {
: null : null
); );
useOutsideClickDetector(ref, () => {
if (themeStore.profileSidebarCollapsed === false) {
if (window.innerWidth < 768) {
themeStore.toggleProfileSidebar();
}
}
});
const userDetails = [ const userDetails = [
{ {
label: "Joined on", 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 ( 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 ? ( {userProjectsData ? (
<> <>
<div className="relative h-32"> <div className="relative h-32">
@ -132,13 +162,12 @@ export const ProfileSidebar = observer(() => {
{project.assigned_issues > 0 && ( {project.assigned_issues > 0 && (
<Tooltip tooltipContent="Completion percentage" position="left"> <Tooltip tooltipContent="Completion percentage" position="left">
<div <div
className={`rounded px-1 py-0.5 text-xs font-medium ${ className={`rounded px-1 py-0.5 text-xs font-medium ${completedIssuePercentage <= 35
completedIssuePercentage <= 35 ? "bg-red-500/10 text-red-500"
? "bg-red-500/10 text-red-500" : completedIssuePercentage <= 70
: completedIssuePercentage <= 70
? "bg-yellow-500/10 text-yellow-500" ? "bg-yellow-500/10 text-yellow-500"
: "bg-green-500/10 text-green-500" : "bg-green-500/10 text-green-500"
}`} }`}
> >
{completedIssuePercentage}% {completedIssuePercentage}%
</div> </div>

View File

@ -18,6 +18,7 @@ import { getRandomEmoji, renderEmoji } from "helpers/emoji.helper";
import { NETWORK_CHOICES, PROJECT_UNSPLASH_COVERS } from "constants/project"; import { NETWORK_CHOICES, PROJECT_UNSPLASH_COVERS } from "constants/project";
// constants // constants
import { EUserWorkspaceRoles } from "constants/workspace"; import { EUserWorkspaceRoles } from "constants/workspace";
import { PROJECT_CREATED } from "constants/event-tracker";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -134,13 +135,8 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
state: "SUCCESS", state: "SUCCESS",
}; };
captureProjectEvent({ captureProjectEvent({
eventName: "Project created", eventName: PROJECT_CREATED,
payload: newPayload, payload: newPayload,
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: res.workspace,
},
}); });
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -160,16 +156,11 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
message: err.data[key], message: err.data[key],
}); });
captureProjectEvent({ captureProjectEvent({
eventName: "Project created", eventName: PROJECT_CREATED,
payload: { payload: {
...payload, ...payload,
state: "FAILED", 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"; import { Button, Input } from "@plane/ui";
// types // types
import type { IProject } from "@plane/types"; import type { IProject } from "@plane/types";
// constants
import { PROJECT_DELETED } from "constants/event-tracker";
type DeleteProjectModal = { type DeleteProjectModal = {
isOpen: boolean; isOpen: boolean;
@ -62,13 +64,8 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => {
handleClose(); handleClose();
captureProjectEvent({ captureProjectEvent({
eventName: "Project deleted", eventName: PROJECT_DELETED,
payload: { ...project, state: "SUCCESS", element: "Project general settings" }, payload: { ...project, state: "SUCCESS", element: "Project general settings" },
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
}); });
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -78,13 +75,8 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => {
}) })
.catch(() => { .catch(() => {
captureProjectEvent({ captureProjectEvent({
eventName: "Project deleted", eventName: PROJECT_DELETED,
payload: { ...project, state: "FAILED", element: "Project general settings" }, payload: { ...project, state: "FAILED", element: "Project general settings" },
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
}); });
setToastAlert({ setToastAlert({
type: "error", type: "error",

View File

@ -18,6 +18,7 @@ import { renderFormattedDate } from "helpers/date-time.helper";
import { NETWORK_CHOICES } from "constants/project"; import { NETWORK_CHOICES } from "constants/project";
// services // services
import { ProjectService } from "services/project"; import { ProjectService } from "services/project";
import { PROJECT_UPDATED } from "constants/event-tracker";
export interface IProjectDetailsForm { export interface IProjectDetailsForm {
project: IProject; project: IProject;
@ -45,7 +46,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
setValue, setValue,
setError, setError,
reset, reset,
formState: { errors }, formState: { errors, dirtyFields },
} = useForm<IProject>({ } = useForm<IProject>({
defaultValues: { defaultValues: {
...project, ...project,
@ -77,13 +78,15 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
return updateProject(workspaceSlug.toString(), project.id, payload) return updateProject(workspaceSlug.toString(), project.id, payload)
.then((res) => { .then((res) => {
const changed_properties = Object.keys(dirtyFields);
console.log(dirtyFields);
captureProjectEvent({ captureProjectEvent({
eventName: "Project updated", eventName: PROJECT_UPDATED,
payload: { ...res, state: "SUCCESS", element: "Project general settings" }, payload: {
group: { ...res,
isGrouping: true, changed_properties: changed_properties,
groupType: "Workspace_metrics", state: "SUCCESS",
groupId: res.workspace, element: "Project general settings",
}, },
}); });
setToastAlert({ setToastAlert({
@ -94,13 +97,8 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
}) })
.catch((error) => { .catch((error) => {
captureProjectEvent({ captureProjectEvent({
eventName: "Project updated", eventName: PROJECT_UPDATED,
payload: { ...payload, state: "FAILED", element: "Project general settings" }, payload: { ...payload, state: "FAILED", element: "Project general settings" },
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id,
},
}); });
setToastAlert({ setToastAlert({
type: "error", 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" /> <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" /> <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 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="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"> <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"; import { Button, Input } from "@plane/ui";
// types // types
import { IProject } from "@plane/types"; import { IProject } from "@plane/types";
// constants
import { PROJECT_MEMBER_LEAVE } from "constants/event-tracker";
type FormData = { type FormData = {
projectName: string; projectName: string;
@ -63,8 +65,9 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => {
.then(() => { .then(() => {
handleClose(); handleClose();
router.push(`/${workspaceSlug}/projects`); router.push(`/${workspaceSlug}/projects`);
captureEvent("Project member leave", { captureEvent(PROJECT_MEMBER_LEAVE, {
state: "SUCCESS", state: "SUCCESS",
element: "Project settings members page",
}); });
}) })
.catch(() => { .catch(() => {
@ -73,8 +76,9 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => {
title: "Error!", title: "Error!",
message: "Something went wrong please try again later.", message: "Something went wrong please try again later.",
}); });
captureEvent("Project member leave", { captureEvent(PROJECT_MEMBER_LEAVE, {
state: "FAILED", state: "FAILED",
element: "Project settings members page",
}); });
}); });
} else { } else {

View File

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

View File

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

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