mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of github.com:makeplane/plane into update-file-uploads
This commit is contained in:
commit
c25ac3b3a6
@ -401,8 +401,8 @@ class EmailCheckEndpoint(BaseAPIView):
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
medium="MAGIC_LINK",
|
||||
event_name="Sign up",
|
||||
medium="Magic link",
|
||||
first_time=True,
|
||||
)
|
||||
key, token, current_attempt = generate_magic_token(email=email)
|
||||
@ -438,8 +438,8 @@ class EmailCheckEndpoint(BaseAPIView):
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
medium="MAGIC_LINK",
|
||||
event_name="Sign in",
|
||||
medium="Magic link",
|
||||
first_time=False,
|
||||
)
|
||||
|
||||
@ -468,8 +468,8 @@ class EmailCheckEndpoint(BaseAPIView):
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
medium="EMAIL",
|
||||
event_name="Sign in",
|
||||
medium="Email",
|
||||
first_time=False,
|
||||
)
|
||||
|
||||
|
@ -274,8 +274,8 @@ class SignInEndpoint(BaseAPIView):
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
medium="EMAIL",
|
||||
event_name="Sign in",
|
||||
medium="Email",
|
||||
first_time=False,
|
||||
)
|
||||
|
||||
@ -349,8 +349,8 @@ class MagicSignInEndpoint(BaseAPIView):
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
medium="MAGIC_LINK",
|
||||
event_name="Sign in",
|
||||
medium="Magic link",
|
||||
first_time=False,
|
||||
)
|
||||
|
||||
|
@ -20,6 +20,7 @@ from django.core import serializers
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
@ -312,6 +313,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
if data[0]["start_date"] and data[0]["end_date"]:
|
||||
data[0]["distribution"][
|
||||
"completion_chart"
|
||||
@ -840,10 +842,230 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
new_cycle = Cycle.objects.get(
|
||||
new_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
|
||||
).first()
|
||||
|
||||
old_cycle = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
total_estimates=Sum("issue_cycle__issue__estimate_point")
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Pass the new_cycle queryset to burndown_plot
|
||||
completion_chart = burndown_plot(
|
||||
queryset=old_cycle.first(),
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
assignee_distribution_data = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None,
|
||||
"avatar": item["avatar"],
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in assignee_distribution
|
||||
]
|
||||
|
||||
label_distribution_data = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": str(item["label_id"]) if item["label_id"] else None,
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in label_distribution
|
||||
]
|
||||
|
||||
current_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
).first()
|
||||
|
||||
current_cycle.progress_snapshot = {
|
||||
"total_issues": old_cycle.first().total_issues,
|
||||
"completed_issues": old_cycle.first().completed_issues,
|
||||
"cancelled_issues": old_cycle.first().cancelled_issues,
|
||||
"started_issues": old_cycle.first().started_issues,
|
||||
"unstarted_issues": old_cycle.first().unstarted_issues,
|
||||
"backlog_issues": old_cycle.first().backlog_issues,
|
||||
"total_estimates": old_cycle.first().total_estimates,
|
||||
"completed_estimates": old_cycle.first().completed_estimates,
|
||||
"started_estimates": old_cycle.first().started_estimates,
|
||||
"distribution":{
|
||||
"labels": label_distribution_data,
|
||||
"assignees": assignee_distribution_data,
|
||||
"completion_chart": completion_chart,
|
||||
},
|
||||
}
|
||||
current_cycle.save(update_fields=["progress_snapshot"])
|
||||
|
||||
if (
|
||||
new_cycle.end_date is not None
|
||||
and new_cycle.end_date < timezone.now().date()
|
||||
|
@ -296,7 +296,7 @@ class OauthEndpoint(BaseAPIView):
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
event_name="Sign in",
|
||||
medium=medium.upper(),
|
||||
first_time=False,
|
||||
)
|
||||
@ -427,7 +427,7 @@ class OauthEndpoint(BaseAPIView):
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
event_name="Sign up",
|
||||
medium=medium.upper(),
|
||||
first_time=True,
|
||||
)
|
||||
|
33
apiserver/plane/db/migrations/0059_auto_20240208_0957.py
Normal file
33
apiserver/plane/db/migrations/0059_auto_20240208_0957.py
Normal 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)
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
@ -68,6 +68,7 @@ class Cycle(ProjectBaseModel):
|
||||
sort_order = models.FloatField(default=65535)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
progress_snapshot = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Cycle"
|
||||
|
18
packages/types/src/cycles.d.ts
vendored
18
packages/types/src/cycles.d.ts
vendored
@ -31,6 +31,7 @@ export interface ICycle {
|
||||
issue: string;
|
||||
name: string;
|
||||
owned_by: string;
|
||||
progress_snapshot: TProgressSnapshot;
|
||||
project: string;
|
||||
project_detail: IProjectLite;
|
||||
status: TCycleGroups;
|
||||
@ -49,6 +50,23 @@ export interface ICycle {
|
||||
workspace_detail: IWorkspaceLite;
|
||||
}
|
||||
|
||||
export type TProgressSnapshot = {
|
||||
backlog_issues: number;
|
||||
cancelled_issues: number;
|
||||
completed_estimates: number | null;
|
||||
completed_issues: number;
|
||||
distribution?: {
|
||||
assignees: TAssigneesDistribution[];
|
||||
completion_chart: TCompletionChartDistribution;
|
||||
labels: TLabelsDistribution[];
|
||||
};
|
||||
started_estimates: number | null;
|
||||
started_issues: number;
|
||||
total_estimates: number | null;
|
||||
total_issues: number;
|
||||
unstarted_issues: number;
|
||||
};
|
||||
|
||||
export type TAssigneesDistribution = {
|
||||
assignee_id: string | null;
|
||||
avatar: string | null;
|
||||
|
8
packages/types/src/dashboard.d.ts
vendored
8
packages/types/src/dashboard.d.ts
vendored
@ -24,21 +24,21 @@ export type TDurationFilterOptions =
|
||||
|
||||
// widget filters
|
||||
export type TAssignedIssuesWidgetFilters = {
|
||||
target_date?: TDurationFilterOptions;
|
||||
duration?: TDurationFilterOptions;
|
||||
tab?: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export type TCreatedIssuesWidgetFilters = {
|
||||
target_date?: TDurationFilterOptions;
|
||||
duration?: TDurationFilterOptions;
|
||||
tab?: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export type TIssuesByStateGroupsWidgetFilters = {
|
||||
target_date?: TDurationFilterOptions;
|
||||
duration?: TDurationFilterOptions;
|
||||
};
|
||||
|
||||
export type TIssuesByPriorityWidgetFilters = {
|
||||
target_date?: TDurationFilterOptions;
|
||||
duration?: TDurationFilterOptions;
|
||||
};
|
||||
|
||||
export type TWidgetFiltersFormData =
|
||||
|
9
packages/types/src/issues.d.ts
vendored
9
packages/types/src/issues.d.ts
vendored
@ -221,3 +221,12 @@ export interface IGroupByColumn {
|
||||
export interface IIssueMap {
|
||||
[key: string]: TIssue;
|
||||
}
|
||||
|
||||
export interface IIssueListRow {
|
||||
id: string;
|
||||
groupId: string;
|
||||
type: "HEADER" | "NO_ISSUES" | "QUICK_ADD" | "ISSUE";
|
||||
name?: string;
|
||||
icon?: ReactElement | undefined;
|
||||
payload?: Partial<TIssue>;
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ type BreadcrumbsProps = {
|
||||
const Breadcrumbs = ({ children }: BreadcrumbsProps) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
{React.Children.map(children, (child, index) => (
|
||||
<div key={index} className="flex flex-wrap items-center gap-2.5">
|
||||
<div key={index} className="flex items-center gap-2.5">
|
||||
{child}
|
||||
{index !== React.Children.count(children) - 1 && (
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
|
||||
|
@ -54,10 +54,6 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleOnChange = () => {
|
||||
if (closeOnSelect) closeDropdown();
|
||||
};
|
||||
|
||||
const selectActiveItem = () => {
|
||||
const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector(
|
||||
`[data-headlessui-state="active"] button`
|
||||
@ -66,6 +62,11 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem);
|
||||
|
||||
const handleOnClick = () => {
|
||||
if (closeOnSelect) closeDropdown();
|
||||
};
|
||||
|
||||
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||
|
||||
let menuItems = (
|
||||
@ -101,7 +102,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
tabIndex={tabIndex}
|
||||
className={cn("relative w-min text-left", className)}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
onChange={handleOnChange}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
@ -110,7 +111,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openDropdown();
|
||||
if (menuButtonOnClick) menuButtonOnClick();
|
||||
}}
|
||||
@ -127,7 +129,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openDropdown();
|
||||
if (menuButtonOnClick) menuButtonOnClick();
|
||||
}}
|
||||
@ -152,7 +155,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openDropdown();
|
||||
if (menuButtonOnClick) menuButtonOnClick();
|
||||
}}
|
||||
|
67
packages/ui/src/form-fields/checkbox.tsx
Normal file
67
packages/ui/src/form-fields/checkbox.tsx
Normal 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 };
|
@ -1,3 +1,4 @@
|
||||
export * from "./input";
|
||||
export * from "./textarea";
|
||||
export * from "./input-color-picker";
|
||||
export * from "./checkbox";
|
||||
|
@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { AuthService } from "services/auth.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useEventTracker } from "hooks/store";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
@ -34,6 +36,8 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
// states
|
||||
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
// store hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// form info
|
||||
@ -63,21 +67,34 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
title: "Success!",
|
||||
message: "Password created successfully.",
|
||||
});
|
||||
captureEvent(PASSWORD_CREATE_SELECTED, {
|
||||
state: "SUCCESS",
|
||||
first_time: false,
|
||||
});
|
||||
await handleSignInRedirection();
|
||||
})
|
||||
.catch((err) =>
|
||||
.catch((err) => {
|
||||
captureEvent(PASSWORD_CREATE_SELECTED, {
|
||||
state: "FAILED",
|
||||
first_time: false,
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoToWorkspace = async () => {
|
||||
setIsGoingToWorkspace(true);
|
||||
|
||||
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
|
||||
await handleSignInRedirection().finally(() => {
|
||||
captureEvent(PASSWORD_CREATE_SKIPPED, {
|
||||
state: "SUCCESS",
|
||||
first_time: false,
|
||||
});
|
||||
setIsGoingToWorkspace(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -7,7 +7,7 @@ import { Eye, EyeOff, XCircle } from "lucide-react";
|
||||
import { AuthService } from "services/auth.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useApplication } from "hooks/store";
|
||||
import { useApplication, useEventTracker } from "hooks/store";
|
||||
// components
|
||||
import { ESignInSteps, ForgotPasswordPopover } from "components/account";
|
||||
// ui
|
||||
@ -16,6 +16,8 @@ import { Button, Input } from "@plane/ui";
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// types
|
||||
import { IPasswordSignInData } from "@plane/types";
|
||||
// constants
|
||||
import { FORGOT_PASSWORD, SIGN_IN_WITH_PASSWORD } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
@ -46,6 +48,7 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
config: { envConfig },
|
||||
} = useApplication();
|
||||
const { captureEvent } = useEventTracker();
|
||||
// derived values
|
||||
const isSmtpConfigured = envConfig?.is_smtp_configured;
|
||||
// form info
|
||||
@ -72,7 +75,13 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
|
||||
|
||||
await authService
|
||||
.passwordSignIn(payload)
|
||||
.then(async () => await onSubmit())
|
||||
.then(async () => {
|
||||
captureEvent(SIGN_IN_WITH_PASSWORD, {
|
||||
state: "SUCCESS",
|
||||
first_time: false,
|
||||
});
|
||||
await onSubmit();
|
||||
})
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
@ -182,9 +191,10 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="w-full text-right mt-2 pb-3">
|
||||
<div className="mt-2 w-full pb-3 text-right">
|
||||
{isSmtpConfigured ? (
|
||||
<Link
|
||||
onClick={() => captureEvent(FORGOT_PASSWORD)}
|
||||
href={`/accounts/forgot-password?email=${email}`}
|
||||
className="text-xs font-medium text-custom-primary-100"
|
||||
>
|
||||
|
@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useApplication } from "hooks/store";
|
||||
import { useApplication, useEventTracker } from "hooks/store";
|
||||
import useSignInRedirection from "hooks/use-sign-in-redirection";
|
||||
// components
|
||||
import { LatestFeatureBlock } from "components/common";
|
||||
@ -13,6 +13,8 @@ import {
|
||||
OAuthOptions,
|
||||
SignInOptionalSetPasswordForm,
|
||||
} from "components/account";
|
||||
// constants
|
||||
import { NAVIGATE_TO_SIGNUP } from "constants/event-tracker";
|
||||
|
||||
export enum ESignInSteps {
|
||||
EMAIL = "EMAIL",
|
||||
@ -32,6 +34,7 @@ export const SignInRoot = observer(() => {
|
||||
const {
|
||||
config: { envConfig },
|
||||
} = useApplication();
|
||||
const { captureEvent } = useEventTracker();
|
||||
// derived values
|
||||
const isSmtpConfigured = envConfig?.is_smtp_configured;
|
||||
|
||||
@ -110,7 +113,11 @@ export const SignInRoot = observer(() => {
|
||||
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_in" />
|
||||
<p className="text-xs text-onboarding-text-300 text-center mt-6">
|
||||
Don{"'"}t have an account?{" "}
|
||||
<Link href="/accounts/sign-up" className="text-custom-primary-100 font-medium underline">
|
||||
<Link
|
||||
href="/accounts/sign-up"
|
||||
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
|
||||
className="text-custom-primary-100 font-medium underline"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
|
@ -7,12 +7,15 @@ import { UserService } from "services/user.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useTimer from "hooks/use-timer";
|
||||
import { useEventTracker } from "hooks/store";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// types
|
||||
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
|
||||
// constants
|
||||
import { CODE_VERIFIED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
@ -41,6 +44,8 @@ export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
|
||||
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// store hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
// timer
|
||||
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30);
|
||||
// form info
|
||||
@ -69,17 +74,22 @@ export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
|
||||
await authService
|
||||
.magicSignIn(payload)
|
||||
.then(async () => {
|
||||
captureEvent(CODE_VERIFIED, {
|
||||
state: "SUCCESS",
|
||||
});
|
||||
const currentUser = await userService.currentUser();
|
||||
|
||||
await onSubmit(currentUser.is_password_autoset);
|
||||
})
|
||||
.catch((err) =>
|
||||
.catch((err) => {
|
||||
captureEvent(CODE_VERIFIED, {
|
||||
state: "FAILED",
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
|
||||
|
@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { AuthService } from "services/auth.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useEventTracker } from "hooks/store";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// constants
|
||||
import { ESignUpSteps } from "components/account";
|
||||
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED, SETUP_PASSWORD } from "constants/event-tracker";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
@ -37,6 +39,8 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
// states
|
||||
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
// store hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// form info
|
||||
@ -66,21 +70,34 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
title: "Success!",
|
||||
message: "Password created successfully.",
|
||||
});
|
||||
captureEvent(SETUP_PASSWORD, {
|
||||
state: "SUCCESS",
|
||||
first_time: true,
|
||||
});
|
||||
await handleSignInRedirection();
|
||||
})
|
||||
.catch((err) =>
|
||||
.catch((err) => {
|
||||
captureEvent(SETUP_PASSWORD, {
|
||||
state: "FAILED",
|
||||
first_time: true,
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoToWorkspace = async () => {
|
||||
setIsGoingToWorkspace(true);
|
||||
|
||||
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
|
||||
await handleSignInRedirection().finally(() => {
|
||||
captureEvent(PASSWORD_CREATE_SKIPPED, {
|
||||
state: "SUCCESS",
|
||||
first_time: true,
|
||||
});
|
||||
setIsGoingToWorkspace(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useApplication } from "hooks/store";
|
||||
import { useApplication, useEventTracker } from "hooks/store";
|
||||
import useSignInRedirection from "hooks/use-sign-in-redirection";
|
||||
// components
|
||||
import {
|
||||
@ -12,6 +12,8 @@ import {
|
||||
SignUpUniqueCodeForm,
|
||||
} from "components/account";
|
||||
import Link from "next/link";
|
||||
// constants
|
||||
import { NAVIGATE_TO_SIGNIN } from "constants/event-tracker";
|
||||
|
||||
export enum ESignUpSteps {
|
||||
EMAIL = "EMAIL",
|
||||
@ -32,6 +34,7 @@ export const SignUpRoot = observer(() => {
|
||||
const {
|
||||
config: { envConfig },
|
||||
} = useApplication();
|
||||
const { captureEvent } = useEventTracker();
|
||||
|
||||
// step 1 submit handler- email verification
|
||||
const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE);
|
||||
@ -86,7 +89,11 @@ export const SignUpRoot = observer(() => {
|
||||
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_up" />
|
||||
<p className="text-xs text-onboarding-text-300 text-center mt-6">
|
||||
Already using Plane?{" "}
|
||||
<Link href="/" className="text-custom-primary-100 font-medium underline">
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => captureEvent(NAVIGATE_TO_SIGNIN, {})}
|
||||
className="text-custom-primary-100 font-medium underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
|
@ -8,12 +8,15 @@ import { UserService } from "services/user.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useTimer from "hooks/use-timer";
|
||||
import { useEventTracker } from "hooks/store";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// types
|
||||
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
|
||||
// constants
|
||||
import { CODE_VERIFIED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
@ -39,6 +42,8 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
|
||||
const { email, handleEmailClear, onSubmit } = props;
|
||||
// states
|
||||
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
||||
// store hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// timer
|
||||
@ -69,17 +74,22 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
|
||||
await authService
|
||||
.magicSignIn(payload)
|
||||
.then(async () => {
|
||||
captureEvent(CODE_VERIFIED, {
|
||||
state: "SUCCESS",
|
||||
});
|
||||
const currentUser = await userService.currentUser();
|
||||
|
||||
await onSubmit(currentUser.is_password_autoset);
|
||||
})
|
||||
.catch((err) =>
|
||||
.catch((err) => {
|
||||
captureEvent(CODE_VERIFIED, {
|
||||
state: "FAILED",
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
|
||||
@ -96,7 +106,6 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
|
||||
title: "Success!",
|
||||
message: "A new unique code has been sent to your email.",
|
||||
});
|
||||
|
||||
reset({
|
||||
email: formData.email,
|
||||
token: "",
|
||||
|
@ -10,6 +10,8 @@ import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSi
|
||||
import { IAnalyticsParams } from "@plane/types";
|
||||
// fetch-keys
|
||||
import { ANALYTICS } from "constants/fetch-keys";
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { useApplication } from "hooks/store";
|
||||
|
||||
type Props = {
|
||||
additionalParams?: Partial<IAnalyticsParams>;
|
||||
@ -46,11 +48,13 @@ export const CustomAnalytics: React.FC<Props> = observer((props) => {
|
||||
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
|
||||
);
|
||||
|
||||
const { theme: themeStore } = useApplication();
|
||||
|
||||
const isProjectLevel = projectId ? true : false;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col-reverse overflow-hidden ${fullScreen ? "md:grid md:h-full md:grid-cols-4" : ""}`}>
|
||||
<div className="col-span-3 flex h-full flex-col overflow-hidden">
|
||||
<div className={cn("relative w-full h-full flex overflow-hidden", isProjectLevel ? "flex-col-reverse" : "")}>
|
||||
<div className="w-full flex h-full flex-col overflow-hidden">
|
||||
<CustomAnalyticsSelectBar
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
@ -61,16 +65,22 @@ export const CustomAnalytics: React.FC<Props> = observer((props) => {
|
||||
<CustomAnalyticsMainContent
|
||||
analytics={analytics}
|
||||
error={analyticsError}
|
||||
fullScreen={fullScreen}
|
||||
params={params}
|
||||
fullScreen={fullScreen}
|
||||
/>
|
||||
</div>
|
||||
<CustomAnalyticsSidebar
|
||||
analytics={analytics}
|
||||
params={params}
|
||||
fullScreen={fullScreen}
|
||||
isProjectLevel={isProjectLevel}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"border-l border-custom-border-200 transition-all",
|
||||
!isProjectLevel
|
||||
? "absolute right-0 top-0 bottom-0 md:relative flex-shrink-0 h-full max-w-[250px] sm:max-w-full"
|
||||
: ""
|
||||
)}
|
||||
style={themeStore.workspaceAnalyticsSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}}
|
||||
>
|
||||
<CustomAnalyticsSidebar analytics={analytics} params={params} isProjectLevel={isProjectLevel} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -22,9 +22,8 @@ export const CustomAnalyticsSelectBar: React.FC<Props> = observer((props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-3" : "grid-cols-2"} ${
|
||||
fullScreen ? "md:py-5 lg:grid-cols-4" : ""
|
||||
}`}
|
||||
className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-1 sm:grid-cols-3" : "grid-cols-2"} ${fullScreen ? "md:py-5 lg:grid-cols-4" : ""
|
||||
}`}
|
||||
>
|
||||
{!isProjectLevel && (
|
||||
<div>
|
||||
|
@ -17,9 +17,9 @@ export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((pro
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
return (
|
||||
<div className="hidden h-full overflow-hidden md:flex md:flex-col">
|
||||
<div className="relative flex flex-col gap-4 h-full">
|
||||
<h4 className="font-medium">Selected Projects</h4>
|
||||
<div className="mt-4 h-full space-y-6 overflow-y-auto">
|
||||
<div className="relative space-y-6 overflow-hidden overflow-y-auto">
|
||||
{projectIds.map((projectId) => {
|
||||
const project = getProjectById(projectId);
|
||||
|
||||
|
@ -26,7 +26,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
|
||||
<>
|
||||
{projectId ? (
|
||||
cycleDetails ? (
|
||||
<div className="hidden h-full overflow-y-auto md:block">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<h4 className="break-words font-medium">Analytics for {cycleDetails.name}</h4>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
@ -52,7 +52,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
|
||||
</div>
|
||||
</div>
|
||||
) : moduleDetails ? (
|
||||
<div className="hidden h-full overflow-y-auto md:block">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<h4 className="break-words font-medium">Analytics for {moduleDetails.name}</h4>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
@ -78,7 +78,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden h-full overflow-y-auto md:flex md:flex-col">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="flex items-center gap-1">
|
||||
{projectDetails?.emoji ? (
|
||||
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.emoji)}</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { mutate } from "swr";
|
||||
@ -19,18 +19,18 @@ import { renderFormattedDate } from "helpers/date-time.helper";
|
||||
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types";
|
||||
// fetch-keys
|
||||
import { ANALYTICS } from "constants/fetch-keys";
|
||||
import { cn } from "helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse | undefined;
|
||||
params: IAnalyticsParams;
|
||||
fullScreen: boolean;
|
||||
isProjectLevel: boolean;
|
||||
};
|
||||
|
||||
const analyticsService = new AnalyticsService();
|
||||
|
||||
export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||
const { analytics, params, fullScreen, isProjectLevel = false } = props;
|
||||
const { analytics, params, isProjectLevel = false } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
@ -138,18 +138,14 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||
|
||||
const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds;
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between space-y-2 px-5 py-2.5 ${
|
||||
fullScreen
|
||||
? "overflow-hidden border-l border-custom-border-200 md:h-full md:flex-col md:items-start md:space-y-4 md:border-l md:border-custom-border-200 md:py-5"
|
||||
: ""
|
||||
}`}
|
||||
<div className={cn("relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100", !isProjectLevel ? "flex-col" : "")}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
|
||||
<LayersIcon height={14} width={14} />
|
||||
{analytics ? analytics.total : "..."} Issues
|
||||
{analytics ? analytics.total : "..."} <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
|
||||
</div>
|
||||
{isProjectLevel && (
|
||||
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
|
||||
@ -158,36 +154,36 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||
(cycleId
|
||||
? cycleDetails?.created_at
|
||||
: moduleId
|
||||
? moduleDetails?.created_at
|
||||
: projectDetails?.created_at) ?? ""
|
||||
? moduleDetails?.created_at
|
||||
: projectDetails?.created_at) ?? ""
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
{fullScreen ? (
|
||||
<>
|
||||
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
|
||||
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
|
||||
)}
|
||||
<CustomAnalyticsSidebarHeader />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className={cn("h-full w-full overflow-hidden", isProjectLevel ? "hidden" : "block")}>
|
||||
<>
|
||||
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
|
||||
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
|
||||
)}
|
||||
<CustomAnalyticsSidebarHeader />
|
||||
</>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 justify-self-end">
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 justify-end">
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
prependIcon={<RefreshCw className="h-3.5 w-3.5" />}
|
||||
prependIcon={<RefreshCw className="h-3 md:h-3.5 w-3 md:w-3.5" />}
|
||||
onClick={() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutate(ANALYTICS(workspaceSlug.toString(), params));
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Refresh</div>
|
||||
</Button>
|
||||
<Button variant="primary" prependIcon={<Download className="h-3.5 w-3.5" />} onClick={exportAnalytics}>
|
||||
Export as CSV
|
||||
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Export as CSV</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -20,16 +20,15 @@ export const ProjectAnalyticsModalMainContent: React.FC<Props> = observer((props
|
||||
|
||||
return (
|
||||
<Tab.Group as={React.Fragment}>
|
||||
<Tab.List as="div" className="space-x-2 border-b border-custom-border-200 p-5 pt-0">
|
||||
<Tab.List as="div" className="flex space-x-2 border-b border-custom-border-200 px-0 md:px-5 py-0 md:py-3">
|
||||
{ANALYTICS_TABS.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
className={({ selected }) =>
|
||||
`rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${
|
||||
selected ? "bg-custom-background-80" : ""
|
||||
`rounded-0 w-full md:w-max md:rounded-3xl border-b md:border border-custom-border-200 focus:outline-none px-0 md:px-4 py-2 text-xs hover:bg-custom-background-80 ${selected ? "border-custom-primary-100 text-custom-primary-100 md:bg-custom-background-80 md:text-custom-text-200 md:border-custom-border-200" : "border-transparent"
|
||||
}`
|
||||
}
|
||||
onClick={() => {}}
|
||||
onClick={() => { }}
|
||||
>
|
||||
{tab.title}
|
||||
</Tab>
|
||||
|
80
web/components/core/render-if-visible-HOC.tsx
Normal file
80
web/components/core/render-if-visible-HOC.tsx
Normal 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;
|
@ -3,7 +3,7 @@ import { Menu } from "lucide-react";
|
||||
import { useApplication } from "hooks/store";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
export const SidebarHamburgerToggle: FC = observer (() => {
|
||||
export const SidebarHamburgerToggle: FC = observer(() => {
|
||||
const { theme: themStore } = useApplication();
|
||||
return (
|
||||
<div
|
||||
|
@ -16,6 +16,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// constants
|
||||
import { CYCLE_STATUS } from "constants/cycle";
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
|
||||
//.types
|
||||
import { TCycleGroups } from "@plane/types";
|
||||
|
||||
@ -33,7 +34,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { setTrackElement, captureEvent } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
@ -90,39 +91,55 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
|
||||
.then(() => {
|
||||
captureEvent(CYCLE_FAVORITED, {
|
||||
cycle_id: cycleId,
|
||||
element: "Grid layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
|
||||
.then(() => {
|
||||
captureEvent(CYCLE_UNFAVORITED, {
|
||||
cycle_id: cycleId,
|
||||
element: "Grid layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTrackElement("Cycles page board layout");
|
||||
setTrackElement("Cycles page grid layout");
|
||||
setUpdateModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTrackElement("Cycles page board layout");
|
||||
setTrackElement("Cycles page grid layout");
|
||||
setDeleteModal(true);
|
||||
};
|
||||
|
||||
|
@ -18,6 +18,7 @@ import { CYCLE_STATUS } from "constants/cycle";
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
// types
|
||||
import { TCycleGroups } from "@plane/types";
|
||||
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
|
||||
|
||||
type TCyclesListItem = {
|
||||
cycleId: string;
|
||||
@ -37,7 +38,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { setTrackElement, captureEvent } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
@ -63,26 +64,42 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
|
||||
.then(() => {
|
||||
captureEvent(CYCLE_FAVORITED, {
|
||||
cycle_id: cycleId,
|
||||
element: "List layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
|
||||
.then(() => {
|
||||
captureEvent(CYCLE_UNFAVORITED, {
|
||||
cycle_id: cycleId,
|
||||
element: "List layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
@ -159,9 +176,9 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
projectId={projectId}
|
||||
/>
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
||||
<div className="group flex flex-col md:flex-row w-full items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90">
|
||||
<div className="relative w-full flex items-center justify-between gap-3 overflow-hidden">
|
||||
<div className="relative w-full flex items-center gap-3 overflow-hidden">
|
||||
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 md:flex-row">
|
||||
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
||||
<div className="relative flex w-full items-center gap-3 overflow-hidden">
|
||||
<div className="flex-shrink-0">
|
||||
<CircularProgressIndicator size={38} percentage={progress}>
|
||||
{isCompleted ? (
|
||||
@ -181,20 +198,20 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
<div className="relative flex items-center gap-2.5 overflow-hidden">
|
||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<Tooltip tooltipContent={cycleDetails.name} position="top">
|
||||
<span className="truncate line-clamp-1 inline-block overflow-hidden text-base font-medium">
|
||||
<span className="line-clamp-1 inline-block overflow-hidden truncate text-base font-medium">
|
||||
{cycleDetails.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<button onClick={openCycleOverview} className="flex-shrink-0 z-10 invisible group-hover:visible">
|
||||
<button onClick={openCycleOverview} className="invisible z-10 flex-shrink-0 group-hover:visible">
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{currentCycle && (
|
||||
<div
|
||||
className="flex-shrink-0 relative flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
|
||||
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: currentCycle.color,
|
||||
backgroundColor: `${currentCycle.color}20`,
|
||||
@ -206,12 +223,12 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 relative overflow-hidden flex w-full items-center justify-between md:justify-end gap-2.5 md:w-auto md:flex-shrink-0 ">
|
||||
<div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 overflow-hidden md:w-auto md:flex-shrink-0 md:justify-end ">
|
||||
<div className="text-xs text-custom-text-300">
|
||||
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 relative flex items-center gap-3">
|
||||
<div className="relative flex flex-shrink-0 items-center gap-3">
|
||||
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}>
|
||||
<div className="flex w-10 cursor-default items-center justify-center">
|
||||
{cycleDetails.assignees.length > 0 ? (
|
||||
|
@ -10,6 +10,8 @@ import useToast from "hooks/use-toast";
|
||||
import { Button } from "@plane/ui";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
// constants
|
||||
import { CYCLE_DELETED } from "constants/event-tracker";
|
||||
|
||||
interface ICycleDelete {
|
||||
cycle: ICycle;
|
||||
@ -45,13 +47,13 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
|
||||
message: "Cycle deleted successfully.",
|
||||
});
|
||||
captureCycleEvent({
|
||||
eventName: "Cycle deleted",
|
||||
eventName: CYCLE_DELETED,
|
||||
payload: { ...cycle, state: "SUCCESS" },
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
captureCycleEvent({
|
||||
eventName: "Cycle deleted",
|
||||
eventName: CYCLE_DELETED,
|
||||
payload: { ...cycle, state: "FAILED" },
|
||||
});
|
||||
});
|
||||
|
@ -10,7 +10,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
import { ICycle } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
|
||||
handleFormSubmit: (values: Partial<ICycle>, dirtyFields: any) => Promise<void>;
|
||||
handleClose: () => void;
|
||||
status: boolean;
|
||||
projectId: string;
|
||||
@ -29,7 +29,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
||||
const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props;
|
||||
// form data
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors, isSubmitting, dirtyFields },
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
@ -61,7 +61,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
||||
maxDate?.setDate(maxDate.getDate() - 1);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<form onSubmit={handleSubmit((formData)=>handleFormSubmit(formData,dirtyFields))}>
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-x-3">
|
||||
{!status && (
|
||||
|
@ -10,6 +10,8 @@ import useLocalStorage from "hooks/use-local-storage";
|
||||
import { CycleForm } from "components/cycles";
|
||||
// types
|
||||
import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types";
|
||||
// constants
|
||||
import { CYCLE_CREATED, CYCLE_UPDATED } from "constants/event-tracker";
|
||||
|
||||
type CycleModalProps = {
|
||||
isOpen: boolean;
|
||||
@ -47,7 +49,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
message: "Cycle created successfully.",
|
||||
});
|
||||
captureCycleEvent({
|
||||
eventName: "Cycle created",
|
||||
eventName: CYCLE_CREATED,
|
||||
payload: { ...res, state: "SUCCESS" },
|
||||
});
|
||||
})
|
||||
@ -58,18 +60,23 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
message: err.detail ?? "Error in creating cycle. Please try again.",
|
||||
});
|
||||
captureCycleEvent({
|
||||
eventName: "Cycle created",
|
||||
eventName: CYCLE_CREATED,
|
||||
payload: { ...payload, state: "FAILED" },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateCycle = async (cycleId: string, payload: Partial<ICycle>) => {
|
||||
const handleUpdateCycle = async (cycleId: string, payload: Partial<ICycle>, dirtyFields: any) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const selectedProjectId = payload.project ?? projectId.toString();
|
||||
await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload)
|
||||
.then(() => {
|
||||
.then((res) => {
|
||||
const changed_properties = Object.keys(dirtyFields);
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_UPDATED,
|
||||
payload: { ...res, changed_properties: changed_properties, state: "SUCCESS" },
|
||||
});
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
@ -77,6 +84,10 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_UPDATED,
|
||||
payload: { ...payload, state: "FAILED" },
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
@ -95,7 +106,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
return status;
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: Partial<ICycle>) => {
|
||||
const handleFormSubmit = async (formData: Partial<ICycle>, dirtyFields: any) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const payload: Partial<ICycle> = {
|
||||
@ -119,7 +130,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
}
|
||||
|
||||
if (isDateValid) {
|
||||
if (data) await handleUpdateCycle(data.id, payload);
|
||||
if (data) await handleUpdateCycle(data.id, payload, dirtyFields);
|
||||
else {
|
||||
await handleCreateCycle(payload).then(() => {
|
||||
setCycleTab("all");
|
||||
|
@ -3,6 +3,7 @@ import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Disclosure, Popover, Transition } from "@headlessui/react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
// services
|
||||
import { CycleService } from "services/cycle.service";
|
||||
// hooks
|
||||
@ -38,6 +39,7 @@ import {
|
||||
import { ICycle } from "@plane/types";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
import { CYCLE_UPDATED } from "constants/event-tracker";
|
||||
// fetch-keys
|
||||
import { CYCLE_STATUS } from "constants/cycle";
|
||||
|
||||
@ -66,7 +68,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, peekCycle } = router.query;
|
||||
// store hooks
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { setTrackElement, captureCycleEvent } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
@ -82,10 +84,32 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const submitChanges = (data: Partial<ICycle>) => {
|
||||
const submitChanges = (data: Partial<ICycle>, changedProperty: string) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
|
||||
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data);
|
||||
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data)
|
||||
.then((res) => {
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_UPDATED,
|
||||
payload: {
|
||||
...res,
|
||||
changed_properties: [changedProperty],
|
||||
element: "Right side-peek",
|
||||
state: "SUCCESS",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
.catch((_) => {
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_UPDATED,
|
||||
payload: {
|
||||
...data,
|
||||
element: "Right side-peek",
|
||||
state: "FAILED",
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyText = () => {
|
||||
@ -145,10 +169,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
|
||||
if (isDateValidForExistingCycle) {
|
||||
submitChanges({
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
});
|
||||
submitChanges(
|
||||
{
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
},
|
||||
"start_date"
|
||||
);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
@ -173,10 +200,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
|
||||
if (isDateValid) {
|
||||
submitChanges({
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
});
|
||||
submitChanges(
|
||||
{
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
},
|
||||
"start_date"
|
||||
);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
@ -218,10 +248,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
|
||||
if (isDateValidForExistingCycle) {
|
||||
submitChanges({
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
});
|
||||
submitChanges(
|
||||
{
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
},
|
||||
"end_date"
|
||||
);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
@ -245,10 +278,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
|
||||
if (isDateValid) {
|
||||
submitChanges({
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
});
|
||||
submitChanges(
|
||||
{
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
},
|
||||
"end_date"
|
||||
);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
@ -293,7 +329,11 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const isEndValid = new Date(`${cycleDetails?.end_date}`) >= new Date(`${cycleDetails?.start_date}`);
|
||||
|
||||
const progressPercentage = cycleDetails
|
||||
? Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100)
|
||||
? isCompleted
|
||||
? Math.round(
|
||||
(cycleDetails.progress_snapshot.completed_issues / cycleDetails.progress_snapshot.total_issues) * 100
|
||||
)
|
||||
: Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100)
|
||||
: null;
|
||||
|
||||
if (!cycleDetails)
|
||||
@ -317,7 +357,14 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
|
||||
|
||||
const issueCount =
|
||||
cycleDetails.total_issues === 0 ? "0 Issue" : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
|
||||
isCompleted && !isEmpty(cycleDetails.progress_snapshot)
|
||||
? cycleDetails.progress_snapshot.total_issues === 0
|
||||
? "0 Issue"
|
||||
: `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}`
|
||||
: cycleDetails.total_issues === 0
|
||||
? "0 Issue"
|
||||
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
|
||||
|
||||
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date);
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||
@ -568,49 +615,105 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<Transition show={open}>
|
||||
<Disclosure.Panel>
|
||||
<div className="flex flex-col gap-3">
|
||||
{cycleDetails.distribution?.completion_chart &&
|
||||
cycleDetails.start_date &&
|
||||
cycleDetails.end_date ? (
|
||||
<div className="h-full w-full pt-4">
|
||||
<div className="flex items-start gap-4 py-2 text-xs">
|
||||
<div className="flex items-center gap-3 text-custom-text-100">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
|
||||
<span>Ideal</span>
|
||||
{isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? (
|
||||
<>
|
||||
{cycleDetails.progress_snapshot.distribution?.completion_chart &&
|
||||
cycleDetails.start_date &&
|
||||
cycleDetails.end_date && (
|
||||
<div className="h-full w-full pt-4">
|
||||
<div className="flex items-start gap-4 py-2 text-xs">
|
||||
<div className="flex items-center gap-3 text-custom-text-100">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
|
||||
<span>Ideal</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
|
||||
<span>Current</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-40 w-80">
|
||||
<ProgressChart
|
||||
distribution={cycleDetails.progress_snapshot.distribution?.completion_chart}
|
||||
startDate={cycleDetails.start_date}
|
||||
endDate={cycleDetails.end_date}
|
||||
totalIssues={cycleDetails.progress_snapshot.total_issues}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
|
||||
<span>Current</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-40 w-80">
|
||||
<ProgressChart
|
||||
distribution={cycleDetails.distribution?.completion_chart}
|
||||
startDate={cycleDetails.start_date}
|
||||
endDate={cycleDetails.end_date}
|
||||
totalIssues={cycleDetails.total_issues}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
<>
|
||||
{cycleDetails.distribution?.completion_chart &&
|
||||
cycleDetails.start_date &&
|
||||
cycleDetails.end_date && (
|
||||
<div className="h-full w-full pt-4">
|
||||
<div className="flex items-start gap-4 py-2 text-xs">
|
||||
<div className="flex items-center gap-3 text-custom-text-100">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
|
||||
<span>Ideal</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
|
||||
<span>Current</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-40 w-80">
|
||||
<ProgressChart
|
||||
distribution={cycleDetails.distribution?.completion_chart}
|
||||
startDate={cycleDetails.start_date}
|
||||
endDate={cycleDetails.end_date}
|
||||
totalIssues={cycleDetails.total_issues}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{cycleDetails.total_issues > 0 && cycleDetails.distribution && (
|
||||
<div className="h-full w-full border-t border-custom-border-200 pt-5">
|
||||
<SidebarProgressStats
|
||||
distribution={cycleDetails.distribution}
|
||||
groupedIssues={{
|
||||
backlog: cycleDetails.backlog_issues,
|
||||
unstarted: cycleDetails.unstarted_issues,
|
||||
started: cycleDetails.started_issues,
|
||||
completed: cycleDetails.completed_issues,
|
||||
cancelled: cycleDetails.cancelled_issues,
|
||||
}}
|
||||
totalIssues={cycleDetails.total_issues}
|
||||
isPeekView={Boolean(peekCycle)}
|
||||
/>
|
||||
</div>
|
||||
{/* stats */}
|
||||
{isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? (
|
||||
<>
|
||||
{cycleDetails.progress_snapshot.total_issues > 0 &&
|
||||
cycleDetails.progress_snapshot.distribution && (
|
||||
<div className="h-full w-full border-t border-custom-border-200 pt-5">
|
||||
<SidebarProgressStats
|
||||
distribution={cycleDetails.progress_snapshot.distribution}
|
||||
groupedIssues={{
|
||||
backlog: cycleDetails.progress_snapshot.backlog_issues,
|
||||
unstarted: cycleDetails.progress_snapshot.unstarted_issues,
|
||||
started: cycleDetails.progress_snapshot.started_issues,
|
||||
completed: cycleDetails.progress_snapshot.completed_issues,
|
||||
cancelled: cycleDetails.progress_snapshot.cancelled_issues,
|
||||
}}
|
||||
totalIssues={cycleDetails.progress_snapshot.total_issues}
|
||||
isPeekView={Boolean(peekCycle)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{cycleDetails.total_issues > 0 && cycleDetails.distribution && (
|
||||
<div className="h-full w-full border-t border-custom-border-200 pt-5">
|
||||
<SidebarProgressStats
|
||||
distribution={cycleDetails.distribution}
|
||||
groupedIssues={{
|
||||
backlog: cycleDetails.backlog_issues,
|
||||
unstarted: cycleDetails.unstarted_issues,
|
||||
started: cycleDetails.started_issues,
|
||||
completed: cycleDetails.completed_issues,
|
||||
cancelled: cycleDetails.cancelled_issues,
|
||||
}}
|
||||
totalIssues={cycleDetails.total_issues}
|
||||
isPeekView={Boolean(peekCycle)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
WidgetProps,
|
||||
} from "components/dashboard/widgets";
|
||||
// helpers
|
||||
import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper";
|
||||
import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper";
|
||||
// types
|
||||
import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
|
||||
// constants
|
||||
@ -30,8 +30,8 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
// derived values
|
||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const selectedTab = widgetDetails?.widget_filters.tab ?? "pending";
|
||||
const selectedDurationFilter = widgetDetails?.widget_filters.target_date ?? "none";
|
||||
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? "none";
|
||||
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
@ -43,7 +43,7 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
filters,
|
||||
});
|
||||
|
||||
const filterDates = getCustomDates(filters.target_date ?? selectedDurationFilter);
|
||||
const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: filters.tab ?? selectedTab,
|
||||
@ -86,19 +86,19 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
|
||||
// switch to pending tab if target date is changed to none
|
||||
if (val === "none" && selectedTab !== "completed") {
|
||||
handleUpdateFilters({ target_date: val, tab: "pending" });
|
||||
handleUpdateFilters({ duration: val, tab: "pending" });
|
||||
return;
|
||||
}
|
||||
// switch to upcoming tab if target date is changed to other than none
|
||||
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") {
|
||||
handleUpdateFilters({
|
||||
target_date: val,
|
||||
duration: val,
|
||||
tab: "upcoming",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
handleUpdateFilters({ target_date: val });
|
||||
handleUpdateFilters({ duration: val });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
WidgetProps,
|
||||
} from "components/dashboard/widgets";
|
||||
// helpers
|
||||
import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper";
|
||||
import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper";
|
||||
// types
|
||||
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
|
||||
// constants
|
||||
@ -30,8 +30,8 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
// derived values
|
||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const selectedTab = widgetDetails?.widget_filters.tab ?? "pending";
|
||||
const selectedDurationFilter = widgetDetails?.widget_filters.target_date ?? "none";
|
||||
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? "none";
|
||||
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
@ -43,7 +43,7 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
filters,
|
||||
});
|
||||
|
||||
const filterDates = getCustomDates(filters.target_date ?? selectedDurationFilter);
|
||||
const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: filters.tab ?? selectedTab,
|
||||
@ -83,19 +83,19 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
|
||||
// switch to pending tab if target date is changed to none
|
||||
if (val === "none" && selectedTab !== "completed") {
|
||||
handleUpdateFilters({ target_date: val, tab: "pending" });
|
||||
handleUpdateFilters({ duration: val, tab: "pending" });
|
||||
return;
|
||||
}
|
||||
// switch to upcoming tab if target date is changed to other than none
|
||||
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") {
|
||||
handleUpdateFilters({
|
||||
target_date: val,
|
||||
duration: val,
|
||||
tab: "upcoming",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
handleUpdateFilters({ target_date: val });
|
||||
handleUpdateFilters({ duration: val });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -16,42 +16,40 @@ export const TabsList: React.FC<Props> = observer((props) => {
|
||||
const { durationFilter, selectedTab } = props;
|
||||
|
||||
const tabsList = durationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
||||
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === (selectedTab ?? "pending"));
|
||||
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
|
||||
|
||||
return (
|
||||
<Tab.List
|
||||
as="div"
|
||||
className="relative border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 grid"
|
||||
className="relative border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 p-[1px] grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${tabsList.length}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("absolute bg-custom-background-100 rounded transition-all duration-500 ease-in-out", {
|
||||
// right shadow
|
||||
"shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1,
|
||||
// left shadow
|
||||
"shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0,
|
||||
})}
|
||||
className={cn(
|
||||
"absolute top-1/2 left-[1px] bg-custom-background-100 rounded-[3px] transition-all duration-500 ease-in-out",
|
||||
{
|
||||
// right shadow
|
||||
"shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1,
|
||||
// left shadow
|
||||
"shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
height: "calc(100% - 1px)",
|
||||
width: `${100 / tabsList.length}%`,
|
||||
transform: `translateX(${selectedTabIndex * 100}%)`,
|
||||
height: "calc(100% - 2px)",
|
||||
width: `calc(${100 / tabsList.length}% - 1px)`,
|
||||
transform: `translate(${selectedTabIndex * 100}%, -50%)`,
|
||||
}}
|
||||
/>
|
||||
{tabsList.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
className={cn(
|
||||
"relative z-[1] font-semibold text-xs rounded py-1.5 text-custom-text-400 focus:outline-none",
|
||||
"transition duration-500",
|
||||
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
|
||||
{
|
||||
"text-custom-text-100 bg-custom-background-100": selectedTab === tab.key,
|
||||
"hover:text-custom-text-300": selectedTab !== tab.key,
|
||||
// // right shadow
|
||||
// "shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1,
|
||||
// // left shadow
|
||||
// "shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0,
|
||||
}
|
||||
)}
|
||||
>
|
||||
|
@ -73,8 +73,10 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard();
|
||||
// derived values
|
||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const widgetStats = getWidgetStats<TIssuesByPriorityWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const selectedDuration = widgetDetails?.widget_filters.duration ?? "none";
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TIssuesByPriorityWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
@ -84,7 +86,7 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
|
||||
filters,
|
||||
});
|
||||
|
||||
const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none");
|
||||
const filterDates = getCustomDates(filters.duration ?? selectedDuration);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
@ -92,7 +94,7 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none");
|
||||
const filterDates = getCustomDates(selectedDuration);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
@ -139,10 +141,10 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
|
||||
Assigned by priority
|
||||
</Link>
|
||||
<DurationFilterDropdown
|
||||
value={widgetDetails.widget_filters.target_date ?? "none"}
|
||||
value={selectedDuration}
|
||||
onChange={(val) =>
|
||||
handleUpdateFilters({
|
||||
target_date: val,
|
||||
duration: val,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
@ -34,6 +34,7 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
|
||||
// derived values
|
||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const widgetStats = getWidgetStats<TIssuesByStateGroupsWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const selectedDuration = widgetDetails?.widget_filters.duration ?? "none";
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TIssuesByStateGroupsWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
@ -43,7 +44,7 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
|
||||
filters,
|
||||
});
|
||||
|
||||
const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none");
|
||||
const filterDates = getCustomDates(filters.duration ?? selectedDuration);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
@ -52,7 +53,7 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
|
||||
|
||||
// fetch widget stats
|
||||
useEffect(() => {
|
||||
const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none");
|
||||
const filterDates = getCustomDates(selectedDuration);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
@ -138,10 +139,10 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
|
||||
Assigned by state
|
||||
</Link>
|
||||
<DurationFilterDropdown
|
||||
value={widgetDetails.widget_filters.target_date ?? "none"}
|
||||
value={selectedDuration}
|
||||
onChange={(val) =>
|
||||
handleUpdateFilters({
|
||||
target_date: val,
|
||||
duration: val,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
@ -2,7 +2,7 @@ import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { FileText, Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useProject, useUser } from "hooks/store";
|
||||
import { useApplication, useEventTracker, useProject, useUser } from "hooks/store";
|
||||
// ui
|
||||
import { Breadcrumbs, Button } from "@plane/ui";
|
||||
// helpers
|
||||
@ -25,6 +25,7 @@ export const PagesHeader = observer(() => {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
|
||||
const canUserCreatePage =
|
||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||
@ -64,7 +65,15 @@ export const PagesHeader = observer(() => {
|
||||
</div>
|
||||
{canUserCreatePage && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" prependIcon={<Plus />} size="sm" onClick={() => toggleCreatePageModal(true)}>
|
||||
<Button
|
||||
variant="primary"
|
||||
prependIcon={<Plus />}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTrackElement("Project pages page");
|
||||
toggleCreatePageModal(true);
|
||||
}}
|
||||
>
|
||||
Create Page
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -1,18 +1,78 @@
|
||||
// ui
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
import { Breadcrumbs, CustomMenu } from "@plane/ui";
|
||||
import { BreadcrumbLink } from "components/common";
|
||||
// components
|
||||
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { FC } from "react";
|
||||
import { useApplication, useUser } from "hooks/store";
|
||||
import { ChevronDown, PanelRight } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { PROFILE_ADMINS_TAB, PROFILE_VIEWER_TAB } from "constants/profile";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export const UserProfileHeader = () => (
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
type TUserProfileHeader = {
|
||||
type?: string | undefined
|
||||
}
|
||||
|
||||
export const UserProfileHeader: FC<TUserProfileHeader> = observer((props) => {
|
||||
const { type = undefined } = props
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, userId } = router.query;
|
||||
|
||||
const AUTHORIZED_ROLES = [20, 15, 10];
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
|
||||
if (!currentWorkspaceRole) return null;
|
||||
|
||||
const isAuthorized = AUTHORIZED_ROLES.includes(currentWorkspaceRole);
|
||||
const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB;
|
||||
|
||||
const { theme: themStore } = useApplication();
|
||||
|
||||
return (<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<SidebarHamburgerToggle />
|
||||
<div>
|
||||
<div className="flex justify-between w-full">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink href="/profile" label="Activity Overview" />} />
|
||||
</Breadcrumbs>
|
||||
<div className="flex gap-4 md:hidden">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
placement="bottom-start"
|
||||
customButton={
|
||||
<div className="flex gap-2 items-center px-2 py-1.5 border border-custom-border-400 rounded-md">
|
||||
<span className="flex flex-grow justify-center text-custom-text-200 text-sm">{type}</span>
|
||||
<ChevronDown className="w-4 h-4 text-custom-text-400" />
|
||||
</div>
|
||||
}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
<></>
|
||||
{tabsList.map((tab) => (
|
||||
<CustomMenu.MenuItem
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Link key={tab.route} href={`/${workspaceSlug}/profile/${userId}/${tab.route}`} className="text-custom-text-300 w-full">{tab.label}</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<button className="transition-all block md:hidden" onClick={() => { themStore.toggleProfileSidebar() }}>
|
||||
<PanelRight className={
|
||||
cn("w-4 h-4 block md:hidden", !themStore.profileSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")
|
||||
} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>)
|
||||
});
|
||||
|
||||
|
||||
|
@ -1,13 +1,35 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { ArrowLeft, BarChart2 } from "lucide-react";
|
||||
import { BarChart2, PanelRight } from "lucide-react";
|
||||
// ui
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||
import { BreadcrumbLink } from "components/common";
|
||||
import { useApplication } from "hooks/store";
|
||||
import { observer } from "mobx-react";
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const WorkspaceAnalyticsHeader = () => {
|
||||
export const WorkspaceAnalyticsHeader = observer(() => {
|
||||
const router = useRouter();
|
||||
const { analytics_tab } = router.query;
|
||||
|
||||
const { theme: themeStore } = useApplication();
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggleWorkspaceAnalyticsSidebar = () => {
|
||||
if (window && window.innerWidth < 768) {
|
||||
themeStore.toggleWorkspaceAnalyticsSidebar(true);
|
||||
}
|
||||
if (window && themeStore.workspaceAnalyticsSidebarCollapsed && window.innerWidth >= 768) {
|
||||
themeStore.toggleWorkspaceAnalyticsSidebar(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleToggleWorkspaceAnalyticsSidebar);
|
||||
handleToggleWorkspaceAnalyticsSidebar();
|
||||
return () => window.removeEventListener("resize", handleToggleWorkspaceAnalyticsSidebar);
|
||||
}, [themeStore]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -16,7 +38,7 @@ export const WorkspaceAnalyticsHeader = () => {
|
||||
>
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<SidebarHamburgerToggle />
|
||||
<div>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
@ -25,9 +47,14 @@ export const WorkspaceAnalyticsHeader = () => {
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
{analytics_tab === 'custom' &&
|
||||
<button className="block md:hidden" onClick={() => { themeStore.toggleWorkspaceAnalyticsSidebar() }}>
|
||||
<PanelRight className={cn("w-4 h-4 block md:hidden", !themeStore.workspaceAnalyticsSidebarCollapsed ? "text-custom-primary-100" : "text-custom-text-200")} />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -4,13 +4,18 @@ import { useTheme } from "next-themes";
|
||||
// images
|
||||
import githubBlackImage from "/public/logos/github-black.png";
|
||||
import githubWhiteImage from "/public/logos/github-white.png";
|
||||
// hooks
|
||||
import { useEventTracker } from "hooks/store";
|
||||
// components
|
||||
import { BreadcrumbLink } from "components/common";
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||
// constants
|
||||
import { CHANGELOG_REDIRECTED, GITHUB_REDIRECTED } from "constants/event-tracker";
|
||||
|
||||
export const WorkspaceDashboardHeader = () => {
|
||||
// hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
@ -31,16 +36,26 @@ export const WorkspaceDashboardHeader = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-3">
|
||||
<a
|
||||
onClick={() =>
|
||||
captureEvent(CHANGELOG_REDIRECTED, {
|
||||
element: "navbar",
|
||||
})
|
||||
}
|
||||
href="https://plane.so/changelog"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5"
|
||||
>
|
||||
<Zap size={14} strokeWidth={2} fill="rgb(var(--color-text-100))" />
|
||||
<span className="text-xs hidden sm:hidden md:block font-medium">{"What's new?"}</span>
|
||||
<span className="hidden text-xs font-medium sm:hidden md:block">{"What's new?"}</span>
|
||||
</a>
|
||||
<a
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5 "
|
||||
onClick={() =>
|
||||
captureEvent(GITHUB_REDIRECTED, {
|
||||
element: "navbar",
|
||||
})
|
||||
}
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5"
|
||||
href="https://github.com/makeplane/plane"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@ -51,7 +66,7 @@ export const WorkspaceDashboardHeader = () => {
|
||||
width={16}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
<span className="text-xs font-medium hidden sm:hidden md:block">Star us on GitHub</span>
|
||||
<span className="hidden text-xs font-medium sm:hidden md:block">Star us on GitHub</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -20,6 +20,7 @@ import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle
|
||||
// types
|
||||
import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types";
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { ISSUE_DELETED } from "constants/event-tracker";
|
||||
|
||||
type TInboxIssueActionsHeader = {
|
||||
workspaceSlug: string;
|
||||
@ -86,17 +87,12 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
||||
throw new Error("Missing required parameters");
|
||||
await removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId);
|
||||
captureIssueEvent({
|
||||
eventName: "Issue deleted",
|
||||
eventName: ISSUE_DELETED,
|
||||
payload: {
|
||||
id: inboxIssueId,
|
||||
state: "SUCCESS",
|
||||
element: "Inbox page",
|
||||
},
|
||||
group: {
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
},
|
||||
}
|
||||
});
|
||||
router.push({
|
||||
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
|
||||
@ -108,17 +104,12 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
||||
message: "Something went wrong while deleting inbox issue. Please try again.",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue deleted",
|
||||
eventName: ISSUE_DELETED,
|
||||
payload: {
|
||||
id: inboxIssueId,
|
||||
state: "FAILED",
|
||||
element: "Inbox page",
|
||||
},
|
||||
group: {
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
@ -18,6 +18,8 @@ import { GptAssistantPopover } from "components/core";
|
||||
import { Button, Input, ToggleSwitch } from "@plane/ui";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// constants
|
||||
import { ISSUE_CREATED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
@ -65,7 +67,6 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
|
||||
config: { envConfig },
|
||||
} = useApplication();
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const {
|
||||
control,
|
||||
@ -94,34 +95,24 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
|
||||
handleClose();
|
||||
} else reset(defaultValues);
|
||||
captureIssueEvent({
|
||||
eventName: "Issue created",
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: {
|
||||
...formData,
|
||||
state: "SUCCESS",
|
||||
element: "Inbox page",
|
||||
},
|
||||
group: {
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
},
|
||||
path: router.pathname,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
captureIssueEvent({
|
||||
eventName: "Issue created",
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: {
|
||||
...formData,
|
||||
state: "FAILED",
|
||||
element: "Inbox page",
|
||||
},
|
||||
group: {
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
},
|
||||
path: router.pathname,
|
||||
});
|
||||
});
|
||||
|
@ -38,7 +38,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
|
||||
title: "Attachment uploaded",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: "Issue attachment added",
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "attachment",
|
||||
@ -47,7 +47,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: "Issue attachment added",
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
|
||||
});
|
||||
setToastAlert({
|
||||
@ -67,7 +67,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
|
||||
title: "Attachment removed",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: "Issue attachment deleted",
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "attachment",
|
||||
@ -76,7 +76,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: "Issue attachment deleted",
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "attachment",
|
||||
|
@ -16,6 +16,7 @@ import { TIssue } from "@plane/types";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker";
|
||||
|
||||
export type TIssueOperations = {
|
||||
fetch: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
@ -102,7 +103,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
|
||||
});
|
||||
}
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
@ -112,7 +113,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
@ -138,7 +139,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
|
||||
message: "Issue deleted successfully",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue deleted",
|
||||
eventName: ISSUE_DELETED,
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
path: router.asPath,
|
||||
});
|
||||
@ -149,7 +150,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
|
||||
message: "Issue delete failed",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue deleted",
|
||||
eventName: ISSUE_DELETED,
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
|
||||
path: router.asPath,
|
||||
});
|
||||
@ -164,7 +165,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
|
||||
message: "Issue added to issue successfully",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "cycle_id",
|
||||
@ -174,7 +175,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "cycle_id",
|
||||
@ -198,7 +199,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
|
||||
message: "Cycle removed from issue successfully",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "cycle_id",
|
||||
@ -208,7 +209,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "cycle_id",
|
||||
@ -232,7 +233,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
|
||||
message: "Module added to issue successfully",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "module_id",
|
||||
@ -242,7 +243,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "module_id",
|
||||
@ -266,7 +267,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
|
||||
message: "Module removed from issue successfully",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "module_id",
|
||||
@ -276,7 +277,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "module_id",
|
||||
|
@ -13,6 +13,8 @@ import { createIssuePayload } from "helpers/issue.helper";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// constants
|
||||
import { ISSUE_CREATED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
formKey: keyof TIssue;
|
||||
@ -129,7 +131,7 @@ export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
|
||||
viewId
|
||||
).then((res) => {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue created",
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...res, state: "SUCCESS", element: "Calendar quick add" },
|
||||
path: router.asPath,
|
||||
});
|
||||
@ -142,7 +144,7 @@ export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
captureIssueEvent({
|
||||
eventName: "Issue created",
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...payload, state: "FAILED", element: "Calendar quick add" },
|
||||
path: router.asPath,
|
||||
});
|
||||
|
@ -2,7 +2,7 @@ import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import isEqual from "lodash/isEqual";
|
||||
// hooks
|
||||
import { useGlobalView, useIssues, useLabel, useUser } from "hooks/store";
|
||||
import { useEventTracker, useGlobalView, useIssues, useLabel, useUser } from "hooks/store";
|
||||
//ui
|
||||
import { Button } from "@plane/ui";
|
||||
// components
|
||||
@ -11,6 +11,8 @@ import { AppliedFiltersList } from "components/issues";
|
||||
import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
|
||||
import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "constants/workspace";
|
||||
// constants
|
||||
import { GLOBAL_VIEW_UPDATED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
globalViewId: string;
|
||||
@ -27,6 +29,7 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
|
||||
} = useIssues(EIssuesStoreType.GLOBAL);
|
||||
const { workspaceLabels } = useLabel();
|
||||
const { globalViewMap, updateGlobalView } = useGlobalView();
|
||||
const { captureEvent } = useEventTracker();
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
@ -91,6 +94,13 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
|
||||
filters: {
|
||||
...(appliedFilters ?? {}),
|
||||
},
|
||||
}).then((res) => {
|
||||
captureEvent(GLOBAL_VIEW_UPDATED, {
|
||||
view_id: res.id,
|
||||
applied_filters: res.filters,
|
||||
state: "SUCCESS",
|
||||
element: "Spreadsheet view",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -13,6 +13,8 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
import { createIssuePayload } from "helpers/issue.helper";
|
||||
// types
|
||||
import { IProject, TIssue } from "@plane/types";
|
||||
// constants
|
||||
import { ISSUE_CREATED } from "constants/event-tracker";
|
||||
|
||||
interface IInputProps {
|
||||
formKey: string;
|
||||
@ -111,7 +113,7 @@ export const GanttQuickAddIssueForm: React.FC<IGanttQuickAddIssueForm> = observe
|
||||
quickAddCallback &&
|
||||
(await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId).then((res) => {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue created",
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...res, state: "SUCCESS", element: "Gantt quick add" },
|
||||
path: router.asPath,
|
||||
});
|
||||
@ -123,7 +125,7 @@ export const GanttQuickAddIssueForm: React.FC<IGanttQuickAddIssueForm> = observe
|
||||
});
|
||||
} catch (err: any) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue created",
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...payload, state: "FAILED", element: "Gantt quick add" },
|
||||
path: router.asPath,
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { FC, useCallback, useState } from "react";
|
||||
import { FC, useCallback, useRef, useState } from "react";
|
||||
import { DragDropContext, DragStart, DraggableLocation, DropResult, Droppable } from "@hello-pangea/dnd";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
@ -25,6 +25,7 @@ import { IProfileIssues, IProfileIssuesFilter } from "store/issue/profile";
|
||||
import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module";
|
||||
import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views";
|
||||
import { EIssueFilterType, TCreateModalStoreTypes } from "constants/issue";
|
||||
import { ISSUE_DELETED } from "constants/event-tracker";
|
||||
|
||||
export interface IBaseKanBanLayout {
|
||||
issues: IProjectIssues | ICycleIssues | IDraftIssues | IModuleIssues | IProjectViewIssues | IProfileIssues;
|
||||
@ -94,6 +95,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
|
||||
const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {};
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// states
|
||||
const [isDragStarted, setIsDragStarted] = useState<boolean>(false);
|
||||
const [dragState, setDragState] = useState<KanbanDragState>({});
|
||||
@ -210,7 +213,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
setDeleteIssueModal(false);
|
||||
setDragState({});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue deleted",
|
||||
eventName: ISSUE_DELETED,
|
||||
payload: { id: dragState.draggedIssueId!, state: "FAILED", element: "Kanban layout drag & drop" },
|
||||
path: router.asPath,
|
||||
});
|
||||
@ -245,7 +248,10 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="horizontal-scroll-enable relative h-full w-full overflow-auto bg-custom-background-90">
|
||||
<div
|
||||
className="flex horizontal-scroll-enable relative h-full w-full overflow-auto bg-custom-background-90"
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
<div className="relative h-max w-max min-w-full bg-custom-background-90 px-2">
|
||||
<DragDropContext onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||
{/* drag and delete component */}
|
||||
@ -289,6 +295,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
canEditProperties={canEditProperties}
|
||||
storeType={storeType}
|
||||
addIssuesToView={addIssuesToView}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
isDragStarted={isDragStarted}
|
||||
/>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { memo } from "react";
|
||||
import { MutableRefObject, memo } from "react";
|
||||
import { Draggable, DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
@ -13,6 +13,7 @@ import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
// helper
|
||||
import { cn } from "helpers/common.helper";
|
||||
import RenderIfVisible from "components/core/render-if-visible-HOC";
|
||||
|
||||
interface IssueBlockProps {
|
||||
peekIssueId?: string;
|
||||
@ -25,6 +26,9 @@ interface IssueBlockProps {
|
||||
handleIssues: (issue: TIssue, action: EIssueActions) => void;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
isDragStarted?: boolean;
|
||||
issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization
|
||||
}
|
||||
|
||||
interface IssueDetailsBlockProps {
|
||||
@ -107,6 +111,9 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
||||
handleIssues,
|
||||
quickActions,
|
||||
canEditProperties,
|
||||
scrollableContainerRef,
|
||||
isDragStarted,
|
||||
issueIds,
|
||||
} = props;
|
||||
|
||||
const issue = issuesMap[issueId];
|
||||
@ -129,24 +136,31 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
||||
{...provided.dragHandleProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{issue.tempId !== undefined && (
|
||||
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-2 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm transition-all hover:border-custom-border-400",
|
||||
"rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm transition-all hover:border-custom-border-400",
|
||||
{ "hover:cursor-grab": !isDragDisabled },
|
||||
{ "border-custom-primary-100": snapshot.isDragging },
|
||||
{ "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id }
|
||||
)}
|
||||
>
|
||||
<KanbanIssueDetailsBlock
|
||||
issue={issue}
|
||||
displayProperties={displayProperties}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={quickActions}
|
||||
isReadOnly={!canEditIssueProperties}
|
||||
/>
|
||||
<RenderIfVisible
|
||||
classNames="space-y-2"
|
||||
root={scrollableContainerRef}
|
||||
defaultHeight="100px"
|
||||
horizonatlOffset={50}
|
||||
alwaysRender={snapshot.isDragging}
|
||||
pauseHeightUpdateWhileRendering={isDragStarted}
|
||||
changingReference={issueIds}
|
||||
>
|
||||
<KanbanIssueDetailsBlock
|
||||
issue={issue}
|
||||
displayProperties={displayProperties}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={quickActions}
|
||||
isReadOnly={!canEditIssueProperties}
|
||||
/>
|
||||
</RenderIfVisible>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { memo } from "react";
|
||||
import { MutableRefObject, memo } from "react";
|
||||
//types
|
||||
import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
@ -16,6 +16,8 @@ interface IssueBlocksListProps {
|
||||
handleIssues: (issue: TIssue, action: EIssueActions) => void;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
isDragStarted?: boolean;
|
||||
}
|
||||
|
||||
const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
|
||||
@ -30,6 +32,8 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
|
||||
handleIssues,
|
||||
quickActions,
|
||||
canEditProperties,
|
||||
scrollableContainerRef,
|
||||
isDragStarted,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@ -56,6 +60,9 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
|
||||
index={index}
|
||||
isDragDisabled={isDragDisabled}
|
||||
canEditProperties={canEditProperties}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
isDragStarted={isDragStarted}
|
||||
issueIds={issueIds} //passing to force render for virtualization whenever parent rerenders
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
@ -20,6 +20,7 @@ import {
|
||||
import { EIssueActions } from "../types";
|
||||
import { getGroupByColumns } from "../utils";
|
||||
import { TCreateModalStoreTypes } from "constants/issue";
|
||||
import { MutableRefObject } from "react";
|
||||
|
||||
export interface IGroupByKanBan {
|
||||
issuesMap: IIssueMap;
|
||||
@ -45,6 +46,8 @@ export interface IGroupByKanBan {
|
||||
storeType?: TCreateModalStoreTypes;
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
isDragStarted?: boolean;
|
||||
}
|
||||
|
||||
const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
@ -67,6 +70,8 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
storeType,
|
||||
addIssuesToView,
|
||||
canEditProperties,
|
||||
scrollableContainerRef,
|
||||
isDragStarted,
|
||||
} = props;
|
||||
|
||||
const member = useMember();
|
||||
@ -92,11 +97,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
const groupByVisibilityToggle = visibilityGroupBy(_list);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-shrink-0 flex-col h-full group ${
|
||||
groupByVisibilityToggle ? `` : `w-[340px]`
|
||||
}`}
|
||||
>
|
||||
<div className={`relative flex flex-shrink-0 flex-col group ${groupByVisibilityToggle ? `` : `w-[340px]`}`}>
|
||||
{sub_group_by === null && (
|
||||
<div className="flex-shrink-0 sticky top-0 z-[2] w-full bg-custom-background-90 py-1">
|
||||
<HeaderGroupByCard
|
||||
@ -135,6 +136,8 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
disableIssueCreation={disableIssueCreation}
|
||||
canEditProperties={canEditProperties}
|
||||
groupByVisibilityToggle={groupByVisibilityToggle}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
isDragStarted={isDragStarted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -168,6 +171,8 @@ export interface IKanBan {
|
||||
storeType?: TCreateModalStoreTypes;
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
isDragStarted?: boolean;
|
||||
}
|
||||
|
||||
export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
@ -189,6 +194,8 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
storeType,
|
||||
addIssuesToView,
|
||||
canEditProperties,
|
||||
scrollableContainerRef,
|
||||
isDragStarted,
|
||||
} = props;
|
||||
|
||||
const issueKanBanView = useKanbanView();
|
||||
@ -213,6 +220,8 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
storeType={storeType}
|
||||
addIssuesToView={addIssuesToView}
|
||||
canEditProperties={canEditProperties}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
isDragStarted={isDragStarted}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { MutableRefObject } from "react";
|
||||
import { Droppable } from "@hello-pangea/dnd";
|
||||
// hooks
|
||||
import { useProjectState } from "hooks/store";
|
||||
@ -37,6 +38,8 @@ interface IKanbanGroup {
|
||||
disableIssueCreation?: boolean;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
groupByVisibilityToggle: boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
isDragStarted?: boolean;
|
||||
}
|
||||
|
||||
export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
@ -57,6 +60,8 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
disableIssueCreation,
|
||||
quickAddCallback,
|
||||
viewId,
|
||||
scrollableContainerRef,
|
||||
isDragStarted,
|
||||
} = props;
|
||||
// hooks
|
||||
const projectState = useProjectState();
|
||||
@ -127,6 +132,8 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
handleIssues={handleIssues}
|
||||
quickActions={quickActions}
|
||||
canEditProperties={canEditProperties}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
isDragStarted={isDragStarted}
|
||||
/>
|
||||
|
||||
{provided.placeholder}
|
||||
|
@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
import { createIssuePayload } from "helpers/issue.helper";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// constants
|
||||
import { ISSUE_CREATED } from "constants/event-tracker";
|
||||
|
||||
const Inputs = (props: any) => {
|
||||
const { register, setFocus, projectDetail } = props;
|
||||
@ -106,7 +108,7 @@ export const KanBanQuickAddIssueForm: React.FC<IKanBanQuickAddIssueForm> = obser
|
||||
viewId
|
||||
).then((res) => {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue created",
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...res, state: "SUCCESS", element: "Kanban quick add" },
|
||||
path: router.asPath,
|
||||
});
|
||||
@ -118,7 +120,7 @@ export const KanBanQuickAddIssueForm: React.FC<IKanBanQuickAddIssueForm> = obser
|
||||
});
|
||||
} catch (err: any) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue created",
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...payload, state: "FAILED", element: "Kanban quick add" },
|
||||
path: router.asPath,
|
||||
});
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { MutableRefObject } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { KanBan } from "./default";
|
||||
@ -80,6 +81,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
|
||||
viewId?: string
|
||||
) => Promise<TIssue | undefined>;
|
||||
viewId?: string;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
const {
|
||||
@ -99,6 +101,8 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
addIssuesToView,
|
||||
quickAddCallback,
|
||||
viewId,
|
||||
scrollableContainerRef,
|
||||
isDragStarted,
|
||||
} = props;
|
||||
|
||||
const calculateIssueCount = (column_id: string) => {
|
||||
@ -150,6 +154,8 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
addIssuesToView={addIssuesToView}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
isDragStarted={isDragStarted}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -183,6 +189,7 @@ export interface IKanBanSwimLanes {
|
||||
) => Promise<TIssue | undefined>;
|
||||
viewId?: string;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
@ -204,6 +211,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
addIssuesToView,
|
||||
quickAddCallback,
|
||||
viewId,
|
||||
scrollableContainerRef,
|
||||
} = props;
|
||||
|
||||
const member = useMember();
|
||||
@ -249,6 +257,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
canEditProperties={canEditProperties}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -122,26 +122,24 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`relative h-full w-full bg-custom-background-90`}>
|
||||
<List
|
||||
issuesMap={issueMap}
|
||||
displayProperties={displayProperties}
|
||||
group_by={group_by}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={renderQuickActions}
|
||||
issueIds={issueIds}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
viewId={viewId}
|
||||
quickAddCallback={issues?.quickAddIssue}
|
||||
enableIssueQuickAdd={!!enableQuickAdd}
|
||||
canEditProperties={canEditProperties}
|
||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||
storeType={storeType}
|
||||
addIssuesToView={addIssuesToView}
|
||||
isCompletedCycle={isCompletedCycle}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<div className={`relative h-full w-full bg-custom-background-90`}>
|
||||
<List
|
||||
issuesMap={issueMap}
|
||||
displayProperties={displayProperties}
|
||||
group_by={group_by}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={renderQuickActions}
|
||||
issueIds={issueIds}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
viewId={viewId}
|
||||
quickAddCallback={issues?.quickAddIssue}
|
||||
enableIssueQuickAdd={!!enableQuickAdd}
|
||||
canEditProperties={canEditProperties}
|
||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||
storeType={storeType}
|
||||
addIssuesToView={addIssuesToView}
|
||||
isCompletedCycle={isCompletedCycle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -48,64 +48,59 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm border border-transparent border-b-custom-border-200",
|
||||
{
|
||||
"border border-custom-primary-70 hover:border-custom-primary-70":
|
||||
<div
|
||||
className={cn("min-h-12 relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm", {
|
||||
"border border-custom-primary-70 hover:border-custom-primary-70":
|
||||
peekIssue && peekIssue.issueId === issue.id,
|
||||
"last:border-b-transparent": peekIssue?.issueId !== issue.id,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{displayProperties && displayProperties?.key && (
|
||||
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
||||
{projectDetails?.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
"last:border-b-transparent": peekIssue?.issueId !== issue.id
|
||||
})}
|
||||
>
|
||||
{displayProperties && displayProperties?.key && (
|
||||
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
||||
{projectDetails?.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{issue?.tempId !== undefined && (
|
||||
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
|
||||
)}
|
||||
{issue?.tempId !== undefined && (
|
||||
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
|
||||
)}
|
||||
|
||||
{issue?.is_draft ? (
|
||||
{issue?.is_draft ? (
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span>{issue.name}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`}
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span>{issue.name}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`}
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span>{issue.name}</span>
|
||||
</Tooltip>
|
||||
</ControlLink>
|
||||
)}
|
||||
</ControlLink>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
|
||||
{!issue?.tempId ? (
|
||||
<>
|
||||
<IssueProperties
|
||||
className="relative flex items-center gap-2 whitespace-nowrap"
|
||||
issue={issue}
|
||||
isReadOnly={!canEditIssueProperties}
|
||||
handleIssues={updateIssue}
|
||||
displayProperties={displayProperties}
|
||||
activeLayout="List"
|
||||
/>
|
||||
{quickActions(issue)}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-4 w-4">
|
||||
<Spinner className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
|
||||
{!issue?.tempId ? (
|
||||
<>
|
||||
<IssueProperties
|
||||
className="relative flex items-center gap-2 whitespace-nowrap"
|
||||
issue={issue}
|
||||
isReadOnly={!canEditIssueProperties}
|
||||
handleIssues={updateIssue}
|
||||
displayProperties={displayProperties}
|
||||
activeLayout="List"
|
||||
/>
|
||||
{quickActions(issue)}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-4 w-4">
|
||||
<Spinner className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { FC } from "react";
|
||||
import { FC, MutableRefObject } from "react";
|
||||
// components
|
||||
import { IssueBlock } from "components/issues";
|
||||
// types
|
||||
import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
import RenderIfVisible from "components/core/render-if-visible-HOC";
|
||||
|
||||
interface Props {
|
||||
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
||||
@ -12,27 +13,34 @@ interface Props {
|
||||
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const IssueBlocksList: FC<Props> = (props) => {
|
||||
const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties } = props;
|
||||
const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties, containerRef } = props;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{issueIds && issueIds.length > 0 ? (
|
||||
issueIds.map((issueId: string) => {
|
||||
if (!issueId) return null;
|
||||
|
||||
return (
|
||||
<IssueBlock
|
||||
key={issueId}
|
||||
issueId={issueId}
|
||||
issuesMap={issuesMap}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={quickActions}
|
||||
canEditProperties={canEditProperties}
|
||||
displayProperties={displayProperties}
|
||||
/>
|
||||
<RenderIfVisible
|
||||
key={`${issueId}`}
|
||||
defaultHeight="3rem"
|
||||
root={containerRef}
|
||||
classNames={"relative border border-transparent border-b-custom-border-200 last:border-b-transparent"}
|
||||
changingReference={issueIds}
|
||||
>
|
||||
<IssueBlock
|
||||
issueId={issueId}
|
||||
issuesMap={issuesMap}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={quickActions}
|
||||
canEditProperties={canEditProperties}
|
||||
displayProperties={displayProperties}
|
||||
/>
|
||||
</RenderIfVisible>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { useRef } from "react";
|
||||
// components
|
||||
import { IssueBlocksList, ListQuickAddIssueForm } from "components/issues";
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
// hooks
|
||||
import { useLabel, useMember, useProject, useProjectState } from "hooks/store";
|
||||
// types
|
||||
@ -10,12 +12,12 @@ import {
|
||||
IIssueDisplayProperties,
|
||||
TIssueMap,
|
||||
TUnGroupedIssues,
|
||||
IGroupByColumn,
|
||||
} from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
// constants
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
import { getGroupByColumns } from "../utils";
|
||||
import { TCreateModalStoreTypes } from "constants/issue";
|
||||
import { getGroupByColumns } from "../utils";
|
||||
|
||||
export interface IGroupByList {
|
||||
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
||||
@ -64,9 +66,11 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
const label = useLabel();
|
||||
const projectState = useProjectState();
|
||||
|
||||
const list = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
if (!list) return null;
|
||||
const groups = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true);
|
||||
|
||||
if (!groups) return null;
|
||||
|
||||
const prePopulateQuickAddData = (groupByKey: string | null, value: any) => {
|
||||
const defaultState = projectState.projectStates?.find((state) => state.default);
|
||||
@ -104,11 +108,11 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
const isGroupByCreatedBy = group_by === "created_by";
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{list &&
|
||||
list.length > 0 &&
|
||||
list.map(
|
||||
(_list: any) =>
|
||||
<div ref={containerRef} className="relative overflow-auto h-full w-full">
|
||||
{groups &&
|
||||
groups.length > 0 &&
|
||||
groups.map(
|
||||
(_list: IGroupByColumn) =>
|
||||
validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
|
||||
<div key={_list.id} className={`flex flex-shrink-0 flex-col`}>
|
||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 px-3 py-1">
|
||||
@ -131,6 +135,7 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
quickActions={quickActions}
|
||||
displayProperties={displayProperties}
|
||||
canEditProperties={canEditProperties}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
import { TIssue, IProject } from "@plane/types";
|
||||
// types
|
||||
import { createIssuePayload } from "helpers/issue.helper";
|
||||
// constants
|
||||
import { ISSUE_CREATED } from "constants/event-tracker";
|
||||
|
||||
interface IInputProps {
|
||||
formKey: string;
|
||||
@ -103,7 +105,7 @@ export const ListQuickAddIssueForm: FC<IListQuickAddIssueForm> = observer((props
|
||||
quickAddCallback &&
|
||||
(await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId).then((res) => {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue created",
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...res, state: "SUCCESS", element: "List quick add" },
|
||||
path: router.asPath,
|
||||
});
|
||||
@ -115,7 +117,7 @@ export const ListQuickAddIssueForm: FC<IListQuickAddIssueForm> = observer((props
|
||||
});
|
||||
} catch (err: any) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue created",
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...payload, state: "FAILED", element: "List quick add" },
|
||||
path: router.asPath,
|
||||
});
|
||||
|
@ -18,6 +18,8 @@ import {
|
||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types";
|
||||
// constants
|
||||
import { ISSUE_UPDATED } from "constants/event-tracker";
|
||||
|
||||
export interface IIssueProperties {
|
||||
issue: TIssue;
|
||||
@ -40,7 +42,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const handleState = (stateId: string) => {
|
||||
handleIssues({ ...issue, state_id: stateId }).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: router.asPath,
|
||||
updates: {
|
||||
@ -54,7 +56,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const handlePriority = (value: TIssuePriorities) => {
|
||||
handleIssues({ ...issue, priority: value }).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: router.asPath,
|
||||
updates: {
|
||||
@ -68,7 +70,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const handleLabel = (ids: string[]) => {
|
||||
handleIssues({ ...issue, label_ids: ids }).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: router.asPath,
|
||||
updates: {
|
||||
@ -82,7 +84,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const handleAssignee = (ids: string[]) => {
|
||||
handleIssues({ ...issue, assignee_ids: ids }).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: router.asPath,
|
||||
updates: {
|
||||
@ -96,7 +98,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const handleStartDate = (date: Date | null) => {
|
||||
handleIssues({ ...issue, start_date: date ? renderFormattedPayloadDate(date) : null }).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: router.asPath,
|
||||
updates: {
|
||||
@ -110,7 +112,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const handleTargetDate = (date: Date | null) => {
|
||||
handleIssues({ ...issue, target_date: date ? renderFormattedPayloadDate(date) : null }).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: router.asPath,
|
||||
updates: {
|
||||
@ -124,7 +126,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const handleEstimate = (value: number | null) => {
|
||||
handleIssues({ ...issue, estimate_point: value }).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: router.asPath,
|
||||
updates: {
|
||||
|
@ -21,6 +21,7 @@ export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props)
|
||||
<div className="h-11 border-b-[0.5px] border-custom-border-200">
|
||||
<DateDropdown
|
||||
value={issue.target_date}
|
||||
minDate={issue.start_date ? new Date(issue.start_date) : undefined}
|
||||
onChange={(data) => {
|
||||
const targetDate = data ? renderFormattedPayloadDate(data) : null;
|
||||
onChange(
|
||||
|
@ -21,6 +21,7 @@ export const SpreadsheetStartDateColumn: React.FC<Props> = observer((props: Prop
|
||||
<div className="h-11 border-b-[0.5px] border-custom-border-200">
|
||||
<DateDropdown
|
||||
value={issue.start_date}
|
||||
maxDate={issue.target_date ? new Date(issue.target_date) : undefined}
|
||||
onChange={(data) => {
|
||||
const startDate = data ? renderFormattedPayloadDate(data) : null;
|
||||
onChange(
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Dispatch, MutableRefObject, SetStateAction, useRef, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// icons
|
||||
@ -7,6 +7,7 @@ import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
|
||||
// components
|
||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||
import RenderIfVisible from "components/core/render-if-visible-HOC";
|
||||
import { IssueColumn } from "./issue-column";
|
||||
// ui
|
||||
import { ControlLink, Tooltip } from "@plane/ui";
|
||||
@ -32,6 +33,9 @@ interface Props {
|
||||
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
||||
nestingLevel: number;
|
||||
issueId: string;
|
||||
isScrolled: MutableRefObject<boolean>;
|
||||
containerRef: MutableRefObject<HTMLTableElement | null>;
|
||||
issueIds: string[];
|
||||
}
|
||||
|
||||
export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
@ -44,8 +48,96 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
handleIssues,
|
||||
quickActions,
|
||||
canEditProperties,
|
||||
isScrolled,
|
||||
containerRef,
|
||||
issueIds,
|
||||
} = props;
|
||||
|
||||
const [isExpanded, setExpanded] = useState<boolean>(false);
|
||||
const { subIssues: subIssuesStore } = useIssueDetail();
|
||||
|
||||
const subIssues = subIssuesStore.subIssuesByIssueId(issueId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* first column/ issue name and key column */}
|
||||
<RenderIfVisible
|
||||
as="tr"
|
||||
defaultHeight="calc(2.75rem - 1px)"
|
||||
root={containerRef}
|
||||
placeholderChildren={<td colSpan={100} className="border-b-[0.5px]" />}
|
||||
changingReference={issueIds}
|
||||
>
|
||||
<IssueRowDetails
|
||||
issueId={issueId}
|
||||
displayProperties={displayProperties}
|
||||
quickActions={quickActions}
|
||||
canEditProperties={canEditProperties}
|
||||
nestingLevel={nestingLevel}
|
||||
isEstimateEnabled={isEstimateEnabled}
|
||||
handleIssues={handleIssues}
|
||||
portalElement={portalElement}
|
||||
isScrolled={isScrolled}
|
||||
isExpanded={isExpanded}
|
||||
setExpanded={setExpanded}
|
||||
/>
|
||||
</RenderIfVisible>
|
||||
|
||||
{isExpanded &&
|
||||
subIssues &&
|
||||
subIssues.length > 0 &&
|
||||
subIssues.map((subIssueId: string) => (
|
||||
<SpreadsheetIssueRow
|
||||
key={subIssueId}
|
||||
issueId={subIssueId}
|
||||
displayProperties={displayProperties}
|
||||
quickActions={quickActions}
|
||||
canEditProperties={canEditProperties}
|
||||
nestingLevel={nestingLevel + 1}
|
||||
isEstimateEnabled={isEstimateEnabled}
|
||||
handleIssues={handleIssues}
|
||||
portalElement={portalElement}
|
||||
isScrolled={isScrolled}
|
||||
containerRef={containerRef}
|
||||
issueIds={issueIds}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface IssueRowDetailsProps {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
isEstimateEnabled: boolean;
|
||||
quickActions: (
|
||||
issue: TIssue,
|
||||
customActionButton?: React.ReactElement,
|
||||
portalElement?: HTMLDivElement | null
|
||||
) => React.ReactNode;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
|
||||
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
||||
nestingLevel: number;
|
||||
issueId: string;
|
||||
isScrolled: MutableRefObject<boolean>;
|
||||
isExpanded: boolean;
|
||||
setExpanded: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
|
||||
const {
|
||||
displayProperties,
|
||||
issueId,
|
||||
isEstimateEnabled,
|
||||
nestingLevel,
|
||||
portalElement,
|
||||
handleIssues,
|
||||
quickActions,
|
||||
canEditProperties,
|
||||
isScrolled,
|
||||
isExpanded,
|
||||
setExpanded,
|
||||
} = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
@ -54,8 +146,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
const { peekIssue, setPeekIssue } = useIssueDetail();
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
const [isExpanded, setExpanded] = useState<boolean>(false);
|
||||
|
||||
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleIssuePeekOverview = (issue: TIssue) => {
|
||||
@ -66,7 +156,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
const { subIssues: subIssuesStore, issue } = useIssueDetail();
|
||||
|
||||
const issueDetail = issue.getIssueById(issueId);
|
||||
const subIssues = subIssuesStore.subIssuesByIssueId(issueId);
|
||||
|
||||
const paddingLeft = `${nestingLevel * 54}px`;
|
||||
|
||||
@ -91,81 +180,77 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!issueDetail) return null;
|
||||
|
||||
const disableUserActions = !canEditProperties(issueDetail.project_id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={cn({
|
||||
"border border-custom-primary-70 hover:border-custom-primary-70": peekIssue?.issueId === issueDetail.id,
|
||||
})}
|
||||
<td
|
||||
className={cn(
|
||||
"sticky group left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] border-custom-border-200",
|
||||
{
|
||||
"border-b-[0.5px]": peekIssue?.issueId !== issueDetail.id,
|
||||
},
|
||||
{
|
||||
"border border-custom-primary-70 hover:border-custom-primary-70": peekIssue?.issueId === issueDetail.id,
|
||||
},
|
||||
{
|
||||
"shadow-[8px_22px_22px_10px_rgba(0,0,0,0.05)]": isScrolled.current,
|
||||
}
|
||||
)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* first column/ issue name and key column */}
|
||||
<td
|
||||
className={cn(
|
||||
"sticky group left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] border-custom-border-200 focus:border-custom-primary-70",
|
||||
{
|
||||
"border-b-[0.5px]": peekIssue?.issueId !== issueDetail.id,
|
||||
}
|
||||
)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
|
||||
<div
|
||||
className="flex min-w-min items-center gap-1.5 px-4 py-2.5 pr-0"
|
||||
style={issueDetail.parent_id && nestingLevel !== 0 ? { paddingLeft } : {}}
|
||||
>
|
||||
<div className="relative flex cursor-pointer items-center text-center text-xs hover:text-custom-text-100">
|
||||
<span
|
||||
className={`flex items-center justify-center font-medium group-hover:opacity-0 ${
|
||||
isMenuActive ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
{getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id}
|
||||
</span>
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
|
||||
<div
|
||||
className="flex min-w-min items-center gap-1.5 px-4 py-2.5 pr-0"
|
||||
style={issueDetail.parent_id && nestingLevel !== 0 ? { paddingLeft } : {}}
|
||||
>
|
||||
<div className="relative flex cursor-pointer items-center text-center text-xs hover:text-custom-text-100">
|
||||
<span
|
||||
className={`flex items-center justify-center font-medium group-hover:opacity-0 ${
|
||||
isMenuActive ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
{getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id}
|
||||
</span>
|
||||
|
||||
{canEditProperties(issueDetail.project_id) && (
|
||||
<div className={`absolute left-2.5 top-0 hidden group-hover:block ${isMenuActive ? "!block" : ""}`}>
|
||||
{quickActions(issueDetail, customActionButton, portalElement.current)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{issueDetail.sub_issues_count > 0 && (
|
||||
<div className="flex h-6 w-6 items-center justify-center">
|
||||
<button
|
||||
className="h-5 w-5 cursor-pointer rounded-sm hover:bg-custom-background-90 hover:text-custom-text-100"
|
||||
onClick={() => handleToggleExpand()}
|
||||
>
|
||||
<ChevronRight className={`h-3.5 w-3.5 ${isExpanded ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
{canEditProperties(issueDetail.project_id) && (
|
||||
<div className={`absolute left-2.5 top-0 hidden group-hover:block ${isMenuActive ? "!block" : ""}`}>
|
||||
{quickActions(issueDetail, customActionButton, portalElement.current)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issueDetail.project_id}/issues/${issueId}`}
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issueDetail)}
|
||||
className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<div className="w-full overflow-hidden">
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issueDetail.name}>
|
||||
<div
|
||||
className="h-full w-full cursor-pointer truncate px-4 py-2.5 text-left text-[0.825rem] text-custom-text-100"
|
||||
tabIndex={-1}
|
||||
|
||||
{issueDetail.sub_issues_count > 0 && (
|
||||
<div className="flex h-6 w-6 items-center justify-center">
|
||||
<button
|
||||
className="h-5 w-5 cursor-pointer rounded-sm hover:bg-custom-background-90 hover:text-custom-text-100"
|
||||
onClick={() => handleToggleExpand()}
|
||||
>
|
||||
{issueDetail.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ControlLink>
|
||||
</td>
|
||||
{/* Rest of the columns */}
|
||||
{SPREADSHEET_PROPERTY_LIST.map((property) => (
|
||||
<ChevronRight className={`h-3.5 w-3.5 ${isExpanded ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issueDetail.project_id}/issues/${issueId}`}
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issueDetail)}
|
||||
className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<div className="w-full overflow-hidden">
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issueDetail.name}>
|
||||
<div className="h-full w-full cursor-pointer truncate px-4 py-2.5 text-left text-[0.825rem] text-custom-text-100" tabIndex={-1}>
|
||||
{issueDetail.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ControlLink>
|
||||
</td>
|
||||
{/* Rest of the columns */}
|
||||
{SPREADSHEET_PROPERTY_LIST.map((property) => (
|
||||
<IssueColumn
|
||||
displayProperties={displayProperties}
|
||||
issueDetail={issueDetail}
|
||||
@ -175,24 +260,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
isEstimateEnabled={isEstimateEnabled}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{isExpanded &&
|
||||
subIssues &&
|
||||
subIssues.length > 0 &&
|
||||
subIssues.map((subIssueId: string) => (
|
||||
<SpreadsheetIssueRow
|
||||
key={subIssueId}
|
||||
issueId={subIssueId}
|
||||
displayProperties={displayProperties}
|
||||
quickActions={quickActions}
|
||||
canEditProperties={canEditProperties}
|
||||
nestingLevel={nestingLevel + 1}
|
||||
isEstimateEnabled={isEstimateEnabled}
|
||||
handleIssues={handleIssues}
|
||||
portalElement={portalElement}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
import { createIssuePayload } from "helpers/issue.helper";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// constants
|
||||
import { ISSUE_CREATED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
formKey: keyof TIssue;
|
||||
@ -162,7 +164,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC<Props> = observer((props) =>
|
||||
(await quickAddCallback(currentWorkspace.slug, currentProjectDetails.id, { ...payload } as TIssue, viewId).then(
|
||||
(res) => {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue created",
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...res, state: "SUCCESS", element: "Spreadsheet quick add" },
|
||||
path: router.asPath,
|
||||
});
|
||||
@ -175,7 +177,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC<Props> = observer((props) =>
|
||||
});
|
||||
} catch (err: any) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue created",
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...payload, state: "FAILED", element: "Spreadsheet quick add" },
|
||||
path: router.asPath,
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { MutableRefObject, useEffect, useRef } from "react";
|
||||
//types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
@ -21,6 +22,7 @@ type Props = {
|
||||
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
||||
containerRef: MutableRefObject<HTMLTableElement | null>;
|
||||
};
|
||||
|
||||
export const SpreadsheetTable = observer((props: Props) => {
|
||||
@ -34,8 +36,45 @@ export const SpreadsheetTable = observer((props: Props) => {
|
||||
quickActions,
|
||||
handleIssues,
|
||||
canEditProperties,
|
||||
containerRef,
|
||||
} = props;
|
||||
|
||||
// states
|
||||
const isScrolled = useRef(false);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!containerRef.current) return;
|
||||
const scrollLeft = containerRef.current.scrollLeft;
|
||||
|
||||
const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns
|
||||
const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers
|
||||
|
||||
//The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly
|
||||
if (scrollLeft > 0 !== isScrolled.current) {
|
||||
const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child");
|
||||
|
||||
for (let i = 0; i < firtColumns.length; i++) {
|
||||
const shadow = i === 0 ? headerShadow : columnShadow;
|
||||
if (scrollLeft > 0) {
|
||||
(firtColumns[i] as HTMLElement).style.boxShadow = shadow;
|
||||
} else {
|
||||
(firtColumns[i] as HTMLElement).style.boxShadow = "none";
|
||||
}
|
||||
}
|
||||
isScrolled.current = scrollLeft > 0;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const currentContainerRef = containerRef.current;
|
||||
|
||||
if (currentContainerRef) currentContainerRef.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => {
|
||||
if (currentContainerRef) currentContainerRef.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleKeyBoardNavigation = useTableKeyboardNavigation();
|
||||
|
||||
return (
|
||||
@ -58,6 +97,9 @@ export const SpreadsheetTable = observer((props: Props) => {
|
||||
isEstimateEnabled={isEstimateEnabled}
|
||||
handleIssues={handleIssues}
|
||||
portalElement={portalElement}
|
||||
containerRef={containerRef}
|
||||
isScrolled={isScrolled}
|
||||
issueIds={issueIds}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { Spinner } from "@plane/ui";
|
||||
@ -48,8 +48,6 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
|
||||
enableQuickCreateIssue,
|
||||
disableIssueCreation,
|
||||
} = props;
|
||||
// states
|
||||
const isScrolled = useRef(false);
|
||||
// refs
|
||||
const containerRef = useRef<HTMLTableElement | null>(null);
|
||||
const portalRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -58,39 +56,6 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
|
||||
|
||||
const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!containerRef.current) return;
|
||||
const scrollLeft = containerRef.current.scrollLeft;
|
||||
|
||||
const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns
|
||||
const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers
|
||||
|
||||
//The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly
|
||||
if (scrollLeft > 0 !== isScrolled.current) {
|
||||
const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child");
|
||||
|
||||
for (let i = 0; i < firtColumns.length; i++) {
|
||||
const shadow = i === 0 ? headerShadow : columnShadow;
|
||||
if (scrollLeft > 0) {
|
||||
(firtColumns[i] as HTMLElement).style.boxShadow = shadow;
|
||||
} else {
|
||||
(firtColumns[i] as HTMLElement).style.boxShadow = "none";
|
||||
}
|
||||
}
|
||||
isScrolled.current = scrollLeft > 0;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const currentContainerRef = containerRef.current;
|
||||
|
||||
if (currentContainerRef) currentContainerRef.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => {
|
||||
if (currentContainerRef) currentContainerRef.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!issueIds || issueIds.length === 0)
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
@ -112,6 +77,7 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
|
||||
quickActions={quickActions}
|
||||
handleIssues={handleIssues}
|
||||
canEditProperties={canEditProperties}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-custom-border-100">
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui";
|
||||
import { ISSUE_PRIORITIES } from "constants/issue";
|
||||
import { EIssueListRow, ISSUE_PRIORITIES } from "constants/issue";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
import { IMemberRootStore } from "store/member";
|
||||
import { IProjectStore } from "store/project/project.store";
|
||||
import { IStateStore } from "store/state.store";
|
||||
import { GroupByColumnTypes, IGroupByColumn } from "@plane/types";
|
||||
import { GroupByColumnTypes, IGroupByColumn, IIssueListRow, TGroupedIssues, TUnGroupedIssues } from "@plane/types";
|
||||
import { STATE_GROUPS } from "constants/state";
|
||||
import { ILabelStore } from "store/label.store";
|
||||
|
||||
|
@ -13,6 +13,8 @@ import { IssueFormRoot } from "./form";
|
||||
import type { TIssue } from "@plane/types";
|
||||
// constants
|
||||
import { EIssuesStoreType, TCreateModalStoreTypes } from "constants/issue";
|
||||
import { ISSUE_CREATED, ISSUE_UPDATED } from "constants/event-tracker";
|
||||
|
||||
export interface IssuesModalProps {
|
||||
data?: Partial<TIssue>;
|
||||
isOpen: boolean;
|
||||
@ -157,14 +159,9 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
message: "Issue created successfully.",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue created",
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...response, state: "SUCCESS" },
|
||||
path: router.asPath,
|
||||
group: {
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
},
|
||||
});
|
||||
!createMore && handleClose();
|
||||
return response;
|
||||
@ -175,14 +172,9 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
message: "Issue could not be created. Please try again.",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue created",
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...payload, state: "FAILED" },
|
||||
path: router.asPath,
|
||||
group: {
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -198,14 +190,9 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
message: "Issue updated successfully.",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...response, state: "SUCCESS" },
|
||||
path: router.asPath,
|
||||
group: {
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
},
|
||||
});
|
||||
handleClose();
|
||||
return response;
|
||||
@ -216,14 +203,9 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
message: "Issue could not be created. Please try again.",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...payload, state: "FAILED" },
|
||||
path: router.asPath,
|
||||
group: {
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -11,6 +11,7 @@ import { TIssue } from "@plane/types";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker";
|
||||
|
||||
interface IIssuePeekOverview {
|
||||
is_archived?: boolean;
|
||||
@ -103,7 +104,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
message: "Issue updated successfully",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
@ -113,7 +114,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { state: "FAILED", element: "Issue peek-overview" },
|
||||
path: router.asPath,
|
||||
});
|
||||
@ -135,7 +136,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
message: "Issue deleted successfully",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue deleted",
|
||||
eventName: ISSUE_DELETED,
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" },
|
||||
path: router.asPath,
|
||||
});
|
||||
@ -146,7 +147,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
message: "Issue delete failed",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue deleted",
|
||||
eventName: ISSUE_DELETED,
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" },
|
||||
path: router.asPath,
|
||||
});
|
||||
@ -161,7 +162,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
message: "Issue added to issue successfully",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" },
|
||||
updates: {
|
||||
changed_property: "cycle_id",
|
||||
@ -171,7 +172,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { state: "FAILED", element: "Issue peek-overview" },
|
||||
updates: {
|
||||
changed_property: "cycle_id",
|
||||
@ -195,7 +196,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
message: "Cycle removed from issue successfully",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" },
|
||||
updates: {
|
||||
changed_property: "cycle_id",
|
||||
@ -210,7 +211,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
message: "Cycle remove from issue failed",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { state: "FAILED", element: "Issue peek-overview" },
|
||||
updates: {
|
||||
changed_property: "cycle_id",
|
||||
@ -229,7 +230,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
message: "Module added to issue successfully",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" },
|
||||
updates: {
|
||||
changed_property: "module_id",
|
||||
@ -239,7 +240,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" },
|
||||
updates: {
|
||||
changed_property: "module_id",
|
||||
@ -263,7 +264,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
message: "Module removed from issue successfully",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" },
|
||||
updates: {
|
||||
changed_property: "module_id",
|
||||
@ -273,7 +274,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: "Issue updated",
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" },
|
||||
updates: {
|
||||
changed_property: "module_id",
|
||||
|
@ -11,6 +11,8 @@ import { Button } from "@plane/ui";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
// types
|
||||
import type { IModule } from "@plane/types";
|
||||
// constants
|
||||
import { MODULE_DELETED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
data: IModule;
|
||||
@ -51,7 +53,7 @@ export const DeleteModuleModal: React.FC<Props> = observer((props) => {
|
||||
message: "Module deleted successfully.",
|
||||
});
|
||||
captureModuleEvent({
|
||||
eventName: "Module deleted",
|
||||
eventName: MODULE_DELETED,
|
||||
payload: { ...data, state: "SUCCESS" },
|
||||
});
|
||||
})
|
||||
@ -62,7 +64,7 @@ export const DeleteModuleModal: React.FC<Props> = observer((props) => {
|
||||
message: "Module could not be deleted. Please try again.",
|
||||
});
|
||||
captureModuleEvent({
|
||||
eventName: "Module deleted",
|
||||
eventName: MODULE_DELETED,
|
||||
payload: { ...data, state: "FAILED" },
|
||||
});
|
||||
})
|
||||
|
@ -11,7 +11,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
import { IModule } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
handleFormSubmit: (values: Partial<IModule>) => Promise<void>;
|
||||
handleFormSubmit: (values: Partial<IModule>, dirtyFields: any) => Promise<void>;
|
||||
handleClose: () => void;
|
||||
status: boolean;
|
||||
projectId: string;
|
||||
@ -36,7 +36,7 @@ export const ModuleForm: React.FC<Props> = ({
|
||||
data,
|
||||
}) => {
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors, isSubmitting, dirtyFields },
|
||||
handleSubmit,
|
||||
watch,
|
||||
control,
|
||||
@ -53,7 +53,7 @@ export const ModuleForm: React.FC<Props> = ({
|
||||
});
|
||||
|
||||
const handleCreateUpdateModule = async (formData: Partial<IModule>) => {
|
||||
await handleFormSubmit(formData);
|
||||
await handleFormSubmit(formData, dirtyFields);
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
|
@ -9,6 +9,8 @@ import useToast from "hooks/use-toast";
|
||||
import { ModuleForm } from "components/modules";
|
||||
// types
|
||||
import type { IModule } from "@plane/types";
|
||||
// constants
|
||||
import { MODULE_CREATED, MODULE_UPDATED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
@ -59,7 +61,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
|
||||
message: "Module created successfully.",
|
||||
});
|
||||
captureModuleEvent({
|
||||
eventName: "Module created",
|
||||
eventName: MODULE_CREATED,
|
||||
payload: { ...res, state: "SUCCESS" },
|
||||
});
|
||||
})
|
||||
@ -70,13 +72,13 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
|
||||
message: err.detail ?? "Module could not be created. Please try again.",
|
||||
});
|
||||
captureModuleEvent({
|
||||
eventName: "Module created",
|
||||
eventName: MODULE_CREATED,
|
||||
payload: { ...data, state: "FAILED" },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateModule = async (payload: Partial<IModule>) => {
|
||||
const handleUpdateModule = async (payload: Partial<IModule>, dirtyFields: any) => {
|
||||
if (!workspaceSlug || !projectId || !data) return;
|
||||
|
||||
const selectedProjectId = payload.project ?? projectId.toString();
|
||||
@ -90,8 +92,8 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
|
||||
message: "Module updated successfully.",
|
||||
});
|
||||
captureModuleEvent({
|
||||
eventName: "Module updated",
|
||||
payload: { ...res, state: "SUCCESS" },
|
||||
eventName: MODULE_UPDATED,
|
||||
payload: { ...res, changed_properties: Object.keys(dirtyFields), state: "SUCCESS" },
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
@ -101,20 +103,20 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
|
||||
message: err.detail ?? "Module could not be updated. Please try again.",
|
||||
});
|
||||
captureModuleEvent({
|
||||
eventName: "Module updated",
|
||||
eventName: MODULE_UPDATED,
|
||||
payload: { ...data, state: "FAILED" },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: Partial<IModule>) => {
|
||||
const handleFormSubmit = async (formData: Partial<IModule>, dirtyFields: any) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const payload: Partial<IModule> = {
|
||||
...formData,
|
||||
};
|
||||
if (!data) await handleCreateModule(payload);
|
||||
else await handleUpdateModule(payload);
|
||||
else await handleUpdateModule(payload, dirtyFields);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -16,6 +16,7 @@ import { renderFormattedDate } from "helpers/date-time.helper";
|
||||
// constants
|
||||
import { MODULE_STATUS } from "constants/module";
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
moduleId: string;
|
||||
@ -36,7 +37,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { setTrackElement, captureEvent } = useEventTracker();
|
||||
// derived values
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
@ -46,13 +47,21 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the module to favorites. Please try again.",
|
||||
addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId)
|
||||
.then(() => {
|
||||
captureEvent(MODULE_FAVORITED, {
|
||||
module_id: moduleId,
|
||||
element: "Grid layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the module to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@ -60,13 +69,21 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the module from favorites. Please try again.",
|
||||
removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId)
|
||||
.then(() => {
|
||||
captureEvent(MODULE_UNFAVORITED, {
|
||||
module_id: moduleId,
|
||||
element: "Grid layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the module from favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@ -84,14 +101,14 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
||||
const handleEditModule = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTrackElement("Modules page board layout");
|
||||
setTrackElement("Modules page grid layout");
|
||||
setEditModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteModule = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTrackElement("Modules page board layout");
|
||||
setTrackElement("Modules page grid layout");
|
||||
setDeleteModal(true);
|
||||
};
|
||||
|
||||
|
@ -16,6 +16,7 @@ import { renderFormattedDate } from "helpers/date-time.helper";
|
||||
// constants
|
||||
import { MODULE_STATUS } from "constants/module";
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
moduleId: string;
|
||||
@ -36,7 +37,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { setTrackElement, captureEvent } = useEventTracker();
|
||||
// derived values
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
@ -46,13 +47,21 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the module to favorites. Please try again.",
|
||||
addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId)
|
||||
.then(() => {
|
||||
captureEvent(MODULE_FAVORITED, {
|
||||
module_id: moduleId,
|
||||
element: "Grid layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the module to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@ -60,13 +69,21 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the module from favorites. Please try again.",
|
||||
removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId)
|
||||
.then(() => {
|
||||
captureEvent(MODULE_UNFAVORITED, {
|
||||
module_id: moduleId,
|
||||
element: "Grid layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the module from favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
|
@ -34,6 +34,7 @@ import { ILinkDetails, IModule, ModuleLink } from "@plane/types";
|
||||
// constant
|
||||
import { MODULE_STATUS } from "constants/module";
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { MODULE_LINK_CREATED, MODULE_LINK_DELETED, MODULE_LINK_UPDATED, MODULE_UPDATED } from "constants/event-tracker";
|
||||
|
||||
const defaultValues: Partial<IModule> = {
|
||||
lead: "",
|
||||
@ -66,7 +67,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink } = useModule();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker();
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
@ -77,7 +78,19 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
|
||||
const submitChanges = (data: Partial<IModule>) => {
|
||||
if (!workspaceSlug || !projectId || !moduleId) return;
|
||||
updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data);
|
||||
updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data)
|
||||
.then((res) => {
|
||||
captureModuleEvent({
|
||||
eventName: MODULE_UPDATED,
|
||||
payload: { ...res, changed_properties: Object.keys(data)[0], element: "Right side-peek", state: "SUCCESS" },
|
||||
});
|
||||
})
|
||||
.catch((_) => {
|
||||
captureModuleEvent({
|
||||
eventName: MODULE_UPDATED,
|
||||
payload: { ...data, state: "FAILED" },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateLink = async (formData: ModuleLink) => {
|
||||
@ -87,6 +100,10 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
|
||||
createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload)
|
||||
.then(() => {
|
||||
captureEvent(MODULE_LINK_CREATED, {
|
||||
module_id: moduleId,
|
||||
state: "SUCCESS",
|
||||
});
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Module link created",
|
||||
@ -109,6 +126,10 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
|
||||
updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload)
|
||||
.then(() => {
|
||||
captureEvent(MODULE_LINK_UPDATED, {
|
||||
module_id: moduleId,
|
||||
state: "SUCCESS",
|
||||
});
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Module link updated",
|
||||
@ -129,6 +150,10 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
|
||||
deleteModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId)
|
||||
.then(() => {
|
||||
captureEvent(MODULE_LINK_DELETED, {
|
||||
module_id: moduleId,
|
||||
state: "SUCCESS",
|
||||
});
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Module link deleted",
|
||||
@ -187,8 +212,8 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
|
||||
if (watch("start_date") && watch("target_date") && watch("start_date") !== "" && watch("start_date") !== "") {
|
||||
submitChanges({
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
target_date: renderFormattedPayloadDate(`${watch("target_date")}`),
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
});
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
@ -294,7 +319,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
render={({ field: { value } }) => (
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
customButton={
|
||||
<span
|
||||
|
@ -1,10 +1,12 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import { ArchiveRestore, Clock, MessageSquare, User2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { ArchiveRestore, Clock, MessageSquare, MoreVertical, User2 } from "lucide-react";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useEventTracker } from "hooks/store";
|
||||
// icons
|
||||
import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui";
|
||||
// constants
|
||||
@ -13,9 +15,12 @@ import { snoozeOptions } from "constants/notification";
|
||||
import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "helpers/string.helper";
|
||||
import { calculateTimeAgo, renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper";
|
||||
// type
|
||||
import type { IUserNotification } from "@plane/types";
|
||||
import type { IUserNotification, NotificationType } from "@plane/types";
|
||||
// constants
|
||||
import { ISSUE_OPENED, NOTIFICATIONS_READ, NOTIFICATION_ARCHIVED, NOTIFICATION_SNOOZED } from "constants/event-tracker";
|
||||
|
||||
type NotificationCardProps = {
|
||||
selectedTab: NotificationType;
|
||||
notification: IUserNotification;
|
||||
isSnoozedTabOpen: boolean;
|
||||
closePopover: () => void;
|
||||
@ -28,6 +33,7 @@ type NotificationCardProps = {
|
||||
|
||||
export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
const {
|
||||
selectedTab,
|
||||
notification,
|
||||
isSnoozedTabOpen,
|
||||
closePopover,
|
||||
@ -37,11 +43,78 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
setSelectedNotificationForSnooze,
|
||||
markSnoozeNotification,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
// states
|
||||
const [showSnoozeOptions, setshowSnoozeOptions] = React.useState(false);
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// refs
|
||||
const snoozeRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const moreOptions = [
|
||||
{
|
||||
id: 1,
|
||||
name: notification.read_at ? "Mark as unread" : "Mark as read",
|
||||
icon: <MessageSquare className="h-3.5 w-3.5 text-custom-text-300" />,
|
||||
onClick: () => {
|
||||
markNotificationReadStatusToggle(notification.id).then(() => {
|
||||
setToastAlert({
|
||||
title: notification.read_at ? "Notification marked as read" : "Notification marked as unread",
|
||||
type: "success",
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: notification.archived_at ? "Unarchive" : "Archive",
|
||||
icon: notification.archived_at ? (
|
||||
<ArchiveRestore className="h-3.5 w-3.5 text-custom-text-300" />
|
||||
) : (
|
||||
<ArchiveIcon className="h-3.5 w-3.5 text-custom-text-300" />
|
||||
),
|
||||
onClick: () => {
|
||||
markNotificationArchivedStatus(notification.id).then(() => {
|
||||
setToastAlert({
|
||||
title: notification.archived_at ? "Notification un-archived" : "Notification archived",
|
||||
type: "success",
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const snoozeOptionOnClick = (date: Date | null) => {
|
||||
if (!date) {
|
||||
setSelectedNotificationForSnooze(notification.id);
|
||||
return;
|
||||
}
|
||||
markSnoozeNotification(notification.id, date).then(() => {
|
||||
setToastAlert({
|
||||
title: `Notification snoozed till ${renderFormattedDate(date)}`,
|
||||
type: "success",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// close snooze options on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: any) => {
|
||||
if (snoozeRef.current && !snoozeRef.current?.contains(event.target)) {
|
||||
setshowSnoozeOptions(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside, true);
|
||||
document.addEventListener("touchend", handleClickOutside, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside, true);
|
||||
document.removeEventListener("touchend", handleClickOutside, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isSnoozedTabOpen && new Date(notification.snoozed_till!) < new Date()) return null;
|
||||
|
||||
@ -49,6 +122,10 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
<Link
|
||||
onClick={() => {
|
||||
markNotificationReadStatus(notification.id);
|
||||
captureEvent(ISSUE_OPENED, {
|
||||
issue_id: notification.data.issue.id,
|
||||
element: "notification",
|
||||
});
|
||||
closePopover();
|
||||
}}
|
||||
href={`/${workspaceSlug}/projects/${notification.project}/${
|
||||
@ -87,57 +164,136 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full space-y-2.5 overflow-hidden">
|
||||
{!notification.message ? (
|
||||
<div className="w-full break-words text-sm">
|
||||
<span className="font-semibold">
|
||||
{notification.triggered_by_details.is_bot
|
||||
? notification.triggered_by_details.first_name
|
||||
: notification.triggered_by_details.display_name}{" "}
|
||||
</span>
|
||||
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.verb}{" "}
|
||||
{notification.data.issue_activity.field === "comment"
|
||||
? "commented"
|
||||
: notification.data.issue_activity.field === "None"
|
||||
? null
|
||||
: replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "}
|
||||
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.field !== "None"
|
||||
? "to"
|
||||
: ""}
|
||||
<span className="font-semibold">
|
||||
{" "}
|
||||
{notification.data.issue_activity.field !== "None" ? (
|
||||
notification.data.issue_activity.field !== "comment" ? (
|
||||
notification.data.issue_activity.field === "target_date" ? (
|
||||
renderFormattedDate(notification.data.issue_activity.new_value)
|
||||
) : notification.data.issue_activity.field === "attachment" ? (
|
||||
"the issue"
|
||||
) : notification.data.issue_activity.field === "description" ? (
|
||||
stripAndTruncateHTML(notification.data.issue_activity.new_value, 55)
|
||||
<div className="flex items-start">
|
||||
{!notification.message ? (
|
||||
<div className="w-full break-words text-sm">
|
||||
<span className="font-semibold">
|
||||
{notification.triggered_by_details.is_bot
|
||||
? notification.triggered_by_details.first_name
|
||||
: notification.triggered_by_details.display_name}{" "}
|
||||
</span>
|
||||
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.verb}{" "}
|
||||
{notification.data.issue_activity.field === "comment"
|
||||
? "commented"
|
||||
: notification.data.issue_activity.field === "None"
|
||||
? null
|
||||
: replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "}
|
||||
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.field !== "None"
|
||||
? "to"
|
||||
: ""}
|
||||
<span className="font-semibold">
|
||||
{" "}
|
||||
{notification.data.issue_activity.field !== "None" ? (
|
||||
notification.data.issue_activity.field !== "comment" ? (
|
||||
notification.data.issue_activity.field === "target_date" ? (
|
||||
renderFormattedDate(notification.data.issue_activity.new_value)
|
||||
) : notification.data.issue_activity.field === "attachment" ? (
|
||||
"the issue"
|
||||
) : notification.data.issue_activity.field === "description" ? (
|
||||
stripAndTruncateHTML(notification.data.issue_activity.new_value, 55)
|
||||
) : (
|
||||
notification.data.issue_activity.new_value
|
||||
)
|
||||
) : (
|
||||
notification.data.issue_activity.new_value
|
||||
<span>
|
||||
{`"`}
|
||||
{notification.data.issue_activity.new_value.length > 55
|
||||
? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..."
|
||||
: notification.data.issue_activity.issue_comment}
|
||||
{`"`}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span>
|
||||
{`"`}
|
||||
{notification.data.issue_activity.new_value.length > 55
|
||||
? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..."
|
||||
: notification.data.issue_activity.issue_comment}
|
||||
{`"`}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
"the issue and assigned it to you."
|
||||
"the issue and assigned it to you."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full break-words text-sm">
|
||||
<span className="semi-bold">{notification.message}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex md:hidden items-start">
|
||||
<Menu as="div" className={" w-min text-left"}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Menu.Button as={React.Fragment}>
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full items-center gap-x-2 rounded p-0.5 text-sm"
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5 text-custom-text-300" />
|
||||
</button>
|
||||
</Menu.Button>
|
||||
{open && (
|
||||
<Menu.Items className={"absolute right-0 z-10"} static>
|
||||
<div
|
||||
className={
|
||||
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{moreOptions.map((item) => (
|
||||
<Menu.Item as="div">
|
||||
{({ close }) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
item.onClick();
|
||||
close();
|
||||
}}
|
||||
className="flex gap-x-2 items-center p-1.5"
|
||||
>
|
||||
{item.icon}
|
||||
{item.name}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
<Menu.Item as="div">
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setshowSnoozeOptions(true);
|
||||
}}
|
||||
className="flex gap-x-2 items-center p-1.5"
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5 text-custom-text-300" />
|
||||
Snooze
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</Menu>
|
||||
{showSnoozeOptions && (
|
||||
<div
|
||||
ref={snoozeRef}
|
||||
className="absolute right-36 z-20 my-1 top-24 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap"
|
||||
>
|
||||
{snoozeOptions.map((item) => (
|
||||
<p
|
||||
className="p-1.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setshowSnoozeOptions(false);
|
||||
snoozeOptionOnClick(item.value);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full break-words text-sm">
|
||||
<span className="semi-bold">{notification.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 text-xs">
|
||||
<p className="text-custom-text-300">
|
||||
<p className="text-custom-text-300 line-clamp-1">
|
||||
{truncateText(
|
||||
`${notification.data.issue.identifier}-${notification.data.issue.sequence_id} ${notification.data.issue.name}`,
|
||||
50
|
||||
@ -152,7 +308,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="flex-shrink-0 text-custom-text-300">{calculateTimeAgo(notification.created_at)}</p>
|
||||
<p className="flex-shrink-0 text-custom-text-300 mt-auto">{calculateTimeAgo(notification.created_at)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -164,6 +320,11 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
icon: <MessageSquare className="h-3.5 w-3.5 text-custom-text-300" />,
|
||||
onClick: () => {
|
||||
markNotificationReadStatusToggle(notification.id).then(() => {
|
||||
captureEvent(NOTIFICATIONS_READ, {
|
||||
issue_id: notification.data.issue.id,
|
||||
tab: selectedTab,
|
||||
state: "SUCCESS",
|
||||
});
|
||||
setToastAlert({
|
||||
title: notification.read_at ? "Notification marked as read" : "Notification marked as unread",
|
||||
type: "success",
|
||||
@ -181,6 +342,11 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
),
|
||||
onClick: () => {
|
||||
markNotificationArchivedStatus(notification.id).then(() => {
|
||||
captureEvent(NOTIFICATION_ARCHIVED, {
|
||||
issue_id: notification.data.issue.id,
|
||||
tab: selectedTab,
|
||||
state: "SUCCESS",
|
||||
});
|
||||
setToastAlert({
|
||||
title: notification.archived_at ? "Notification un-archived" : "Notification archived",
|
||||
type: "success",
|
||||
@ -195,7 +361,6 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
item.onClick();
|
||||
}}
|
||||
key={item.id}
|
||||
@ -208,9 +373,6 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
<Tooltip tooltipContent="Snooze">
|
||||
<CustomMenu
|
||||
className="flex items-center"
|
||||
menuButtonOnClick={(e: { stopPropagation: () => void }) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
customButton={
|
||||
<div className="flex w-full items-center gap-x-2 rounded bg-custom-background-80 p-0.5 text-sm hover:bg-custom-background-100">
|
||||
<Clock className="h-3.5 w-3.5 text-custom-text-300" />
|
||||
@ -231,6 +393,11 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
}
|
||||
|
||||
markSnoozeNotification(notification.id, item.value).then(() => {
|
||||
captureEvent(NOTIFICATION_SNOOZED, {
|
||||
issue_id: notification.data.issue.id,
|
||||
tab: selectedTab,
|
||||
state: "SUCCESS",
|
||||
});
|
||||
setToastAlert({
|
||||
title: `Notification snoozed till ${renderFormattedDate(item.value)}`,
|
||||
type: "success",
|
||||
|
@ -1,11 +1,22 @@
|
||||
import React from "react";
|
||||
import { ArrowLeft, CheckCheck, Clock, ListFilter, MoreVertical, RefreshCw, X } from "lucide-react";
|
||||
// components
|
||||
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||
// ui
|
||||
import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui";
|
||||
// hooks
|
||||
import { useEventTracker } from "hooks/store";
|
||||
// helpers
|
||||
import { getNumberCount } from "helpers/string.helper";
|
||||
// type
|
||||
import type { NotificationType, NotificationCount } from "@plane/types";
|
||||
// constants
|
||||
import {
|
||||
ARCHIVED_NOTIFICATIONS,
|
||||
NOTIFICATIONS_READ,
|
||||
SNOOZED_NOTIFICATIONS,
|
||||
UNREAD_NOTIFICATIONS,
|
||||
} from "constants/event-tracker";
|
||||
|
||||
type NotificationHeaderProps = {
|
||||
notificationCount?: NotificationCount | null;
|
||||
@ -39,6 +50,8 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
|
||||
setSelectedTab,
|
||||
markAllNotificationsAsRead,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
|
||||
const notificationTabs: Array<{
|
||||
label: string;
|
||||
@ -65,7 +78,11 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-5 pt-5">
|
||||
<h2 className="mb-2 text-xl font-semibold">Notifications</h2>
|
||||
<div className="flex items-center gap-x-2 ">
|
||||
<SidebarHamburgerToggle />
|
||||
<h2 className="md:text-xl md:font-semibold">Notifications</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-x-4 text-custom-text-200">
|
||||
<Tooltip tooltipContent="Refresh">
|
||||
<button
|
||||
@ -84,6 +101,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
|
||||
setSnoozed(false);
|
||||
setArchived(false);
|
||||
setReadNotification((prev) => !prev);
|
||||
captureEvent(UNREAD_NOTIFICATIONS);
|
||||
}}
|
||||
>
|
||||
<ListFilter className="h-3.5 w-3.5" />
|
||||
@ -97,7 +115,12 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
|
||||
}
|
||||
closeOnSelect
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={markAllNotificationsAsRead}>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
markAllNotificationsAsRead();
|
||||
captureEvent(NOTIFICATIONS_READ);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
Mark all as read
|
||||
@ -108,6 +131,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
|
||||
setArchived(false);
|
||||
setReadNotification(false);
|
||||
setSnoozed((prev) => !prev);
|
||||
captureEvent(SNOOZED_NOTIFICATIONS);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -120,6 +144,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
|
||||
setSnoozed(false);
|
||||
setReadNotification(false);
|
||||
setArchived((prev) => !prev);
|
||||
captureEvent(ARCHIVED_NOTIFICATIONS);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -128,11 +153,13 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
<Tooltip tooltipContent="Close">
|
||||
<button type="button" onClick={() => closePopover()}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="hidden md:block">
|
||||
<Tooltip tooltipContent="Close">
|
||||
<button type="button" onClick={() => closePopover()}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 w-full border-b border-custom-border-300 px-5">
|
||||
|
@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useApplication } from "hooks/store";
|
||||
import useUserNotification from "hooks/use-user-notifications";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// components
|
||||
import { EmptyState } from "components/common";
|
||||
import { SnoozeNotificationModal, NotificationCard, NotificationHeader } from "components/notifications";
|
||||
@ -15,8 +16,12 @@ import emptyNotification from "public/empty-state/notification.svg";
|
||||
import { getNumberCount } from "helpers/string.helper";
|
||||
|
||||
export const NotificationPopover = observer(() => {
|
||||
// states
|
||||
const [isActive, setIsActive] = React.useState(false);
|
||||
// store hooks
|
||||
const { theme: themeStore } = useApplication();
|
||||
// refs
|
||||
const notificationPopoverRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const {
|
||||
notifications,
|
||||
@ -44,8 +49,11 @@ export const NotificationPopover = observer(() => {
|
||||
setFetchNotifications,
|
||||
markAllNotificationsAsRead,
|
||||
} = useUserNotification();
|
||||
|
||||
const isSidebarCollapsed = themeStore.sidebarCollapsed;
|
||||
useOutsideClickDetector(notificationPopoverRef, () => {
|
||||
// if snooze modal is open, then don't close the popover
|
||||
if (selectedNotificationForSnooze === null) setIsActive(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -54,141 +62,143 @@ export const NotificationPopover = observer(() => {
|
||||
onClose={() => setSelectedNotificationForSnooze(null)}
|
||||
onSubmit={markSnoozeNotification}
|
||||
notification={notifications?.find((notification) => notification.id === selectedNotificationForSnooze) || null}
|
||||
onSuccess={() => {
|
||||
setSelectedNotificationForSnooze(null);
|
||||
}}
|
||||
onSuccess={() => setSelectedNotificationForSnooze(null)}
|
||||
/>
|
||||
<Popover className="relative w-full">
|
||||
{({ open: isActive, close: closePopover }) => {
|
||||
if (isActive) setFetchNotifications(true);
|
||||
<Popover ref={notificationPopoverRef} className="md:relative w-full">
|
||||
<>
|
||||
<Tooltip tooltipContent="Notifications" position="right" className="ml-2" disabled={!isSidebarCollapsed}>
|
||||
<button
|
||||
className={`group relative flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
|
||||
isActive
|
||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
||||
} ${isSidebarCollapsed ? "justify-center" : ""}`}
|
||||
onClick={() => {
|
||||
if (window.innerWidth < 768) themeStore.toggleSidebar();
|
||||
if (!isActive) setFetchNotifications(true);
|
||||
setIsActive(!isActive);
|
||||
}}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{isSidebarCollapsed ? null : <span>Notifications</span>}
|
||||
{totalNotificationCount && totalNotificationCount > 0 ? (
|
||||
isSidebarCollapsed ? (
|
||||
<span className="absolute right-3.5 top-2 h-2 w-2 rounded-full bg-custom-primary-300" />
|
||||
) : (
|
||||
<span className="ml-auto rounded-full bg-custom-primary-300 px-1.5 text-xs text-white">
|
||||
{getNumberCount(totalNotificationCount)}
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Transition
|
||||
show={isActive}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel
|
||||
className="absolute top-0 left-[280px] md:-top-36 md:ml-8 md:h-[50vh] z-10 flex h-full w-[100vw] flex-col rounded-xl md:border border-custom-border-300 bg-custom-background-100 shadow-lg md:left-full md:w-[36rem]"
|
||||
static
|
||||
>
|
||||
<NotificationHeader
|
||||
notificationCount={notificationCount}
|
||||
notificationMutate={notificationMutate}
|
||||
closePopover={() => setIsActive(false)}
|
||||
isRefreshing={isRefreshing}
|
||||
snoozed={snoozed}
|
||||
archived={archived}
|
||||
readNotification={readNotification}
|
||||
selectedTab={selectedTab}
|
||||
setSnoozed={setSnoozed}
|
||||
setArchived={setArchived}
|
||||
setReadNotification={setReadNotification}
|
||||
setSelectedTab={setSelectedTab}
|
||||
markAllNotificationsAsRead={markAllNotificationsAsRead}
|
||||
/>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip tooltipContent="Notifications" position="right" className="ml-2" disabled={!isSidebarCollapsed}>
|
||||
<Popover.Button
|
||||
className={`group relative flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
|
||||
isActive
|
||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
||||
} ${isSidebarCollapsed ? "justify-center" : ""}`}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{isSidebarCollapsed ? null : <span>Notifications</span>}
|
||||
{totalNotificationCount && totalNotificationCount > 0 ? (
|
||||
isSidebarCollapsed ? (
|
||||
<span className="absolute right-3.5 top-2 h-2 w-2 rounded-full bg-custom-primary-300" />
|
||||
) : (
|
||||
<span className="ml-auto rounded-full bg-custom-primary-300 px-1.5 text-xs text-white">
|
||||
{getNumberCount(totalNotificationCount)}
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
</Popover.Button>
|
||||
</Tooltip>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute -top-36 left-0 z-10 ml-8 flex h-[50vh] w-[20rem] flex-col rounded-xl border border-custom-border-300 bg-custom-background-100 shadow-lg md:left-full md:w-[36rem]">
|
||||
<NotificationHeader
|
||||
notificationCount={notificationCount}
|
||||
notificationMutate={notificationMutate}
|
||||
closePopover={closePopover}
|
||||
isRefreshing={isRefreshing}
|
||||
snoozed={snoozed}
|
||||
archived={archived}
|
||||
readNotification={readNotification}
|
||||
selectedTab={selectedTab}
|
||||
setSnoozed={setSnoozed}
|
||||
setArchived={setArchived}
|
||||
setReadNotification={setReadNotification}
|
||||
setSelectedTab={setSelectedTab}
|
||||
markAllNotificationsAsRead={markAllNotificationsAsRead}
|
||||
/>
|
||||
|
||||
{notifications ? (
|
||||
notifications.length > 0 ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="divide-y divide-custom-border-100">
|
||||
{notifications.map((notification) => (
|
||||
<NotificationCard
|
||||
key={notification.id}
|
||||
isSnoozedTabOpen={snoozed}
|
||||
closePopover={closePopover}
|
||||
notification={notification}
|
||||
markNotificationArchivedStatus={markNotificationArchivedStatus}
|
||||
markNotificationReadStatus={markNotificationAsRead}
|
||||
markNotificationReadStatusToggle={markNotificationReadStatus}
|
||||
setSelectedNotificationForSnooze={setSelectedNotificationForSnooze}
|
||||
markSnoozeNotification={markSnoozeNotification}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isLoadingMore && (
|
||||
<div className="my-6 flex items-center justify-center text-sm">
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="mr-2 h-6 w-6 animate-spin fill-blue-600 text-custom-text-200"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading notifications</p>
|
||||
</div>
|
||||
)}
|
||||
{hasMore && !isLoadingMore && (
|
||||
<button
|
||||
type="button"
|
||||
className="my-6 flex w-full items-center justify-center text-sm font-medium text-custom-primary-100"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => {
|
||||
setSize((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full w-full scale-75 place-items-center overflow-hidden">
|
||||
<EmptyState
|
||||
title="You're updated with all the notifications"
|
||||
description="You have read all the notifications."
|
||||
image={emptyNotification}
|
||||
{notifications ? (
|
||||
notifications.length > 0 ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="divide-y divide-custom-border-100">
|
||||
{notifications.map((notification) => (
|
||||
<NotificationCard
|
||||
selectedTab={selectedTab}
|
||||
key={notification.id}
|
||||
isSnoozedTabOpen={snoozed}
|
||||
closePopover={() => setIsActive(false)}
|
||||
notification={notification}
|
||||
markNotificationArchivedStatus={markNotificationArchivedStatus}
|
||||
markNotificationReadStatus={markNotificationAsRead}
|
||||
markNotificationReadStatusToggle={markNotificationReadStatus}
|
||||
setSelectedNotificationForSnooze={setSelectedNotificationForSnooze}
|
||||
markSnoozeNotification={markSnoozeNotification}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isLoadingMore && (
|
||||
<div className="my-6 flex items-center justify-center text-sm">
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="mr-2 h-6 w-6 animate-spin fill-blue-600 text-custom-text-200"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading notifications</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-4 overflow-y-auto p-5">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
)}
|
||||
{hasMore && !isLoadingMore && (
|
||||
<button
|
||||
type="button"
|
||||
className="my-6 flex w-full items-center justify-center text-sm font-medium text-custom-primary-100"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => {
|
||||
setSize((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full w-full scale-75 place-items-center overflow-hidden">
|
||||
<EmptyState
|
||||
title="You're updated with all the notifications"
|
||||
description="You have read all the notifications."
|
||||
image={emptyNotification}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-4 overflow-y-auto p-5">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
@ -109,7 +109,12 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
// This is a workaround to fix the issue of the Notification popover modal close on closing this modal
|
||||
const closeTimeout = setTimeout(() => {
|
||||
onClose();
|
||||
clearTimeout(closeTimeout);
|
||||
}, 50);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
reset({ ...defaultValues });
|
||||
clearTimeout(timeout);
|
||||
@ -142,7 +147,7 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-2xl">
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 text-left shadow-custom-shadow-md transition-all w-full sm:w-full sm:!max-w-2xl">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
@ -156,8 +161,8 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="mt-5 flex flex-col md:!flex-row md:items-center gap-3">
|
||||
<div className="flex-1 pb-3 md:pb-0">
|
||||
<h6 className="mb-2 block text-sm font-medium text-custom-text-400">Pick a date</h6>
|
||||
<Controller
|
||||
name="date"
|
||||
|
@ -11,11 +11,13 @@ import { WorkspaceService } from "services/workspace.service";
|
||||
// constants
|
||||
import { USER_WORKSPACES, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
|
||||
import { ROLE } from "constants/workspace";
|
||||
import { MEMBER_ACCEPTED } from "constants/event-tracker";
|
||||
// types
|
||||
import { IWorkspaceMemberInvitation } from "@plane/types";
|
||||
// icons
|
||||
import { CheckCircle2, Search } from "lucide-react";
|
||||
import {} from "hooks/store/use-event-tracker";
|
||||
import { getUserRole } from "helpers/user.helper";
|
||||
|
||||
type Props = {
|
||||
handleNextStep: () => void;
|
||||
@ -58,11 +60,19 @@ export const Invitations: React.FC<Props> = (props) => {
|
||||
if (invitationsRespond.length <= 0) return;
|
||||
|
||||
setIsJoiningWorkspaces(true);
|
||||
const invitation = invitations?.find((invitation) => invitation.id === invitationsRespond[0]);
|
||||
|
||||
await workspaceService
|
||||
.joinWorkspaces({ invitations: invitationsRespond })
|
||||
.then(async (res) => {
|
||||
captureEvent("Member accepted", { ...res, state: "SUCCESS", accepted_from: "App" });
|
||||
.then(async () => {
|
||||
captureEvent(MEMBER_ACCEPTED, {
|
||||
member_id: invitation?.id,
|
||||
role: getUserRole(invitation?.role!),
|
||||
project_id: undefined,
|
||||
accepted_from: "App",
|
||||
state: "SUCCESS",
|
||||
element: "Workspace invitations page",
|
||||
});
|
||||
await fetchWorkspaces();
|
||||
await mutate(USER_WORKSPACES);
|
||||
await updateLastWorkspace();
|
||||
@ -71,7 +81,14 @@ export const Invitations: React.FC<Props> = (props) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
captureEvent("Member accepted", { state: "FAILED", accepted_from: "App" });
|
||||
captureEvent(MEMBER_ACCEPTED, {
|
||||
member_id: invitation?.id,
|
||||
role: getUserRole(invitation?.role!),
|
||||
project_id: undefined,
|
||||
accepted_from: "App",
|
||||
state: "FAILED",
|
||||
element: "Workspace invitations page",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsJoiningWorkspaces(false));
|
||||
};
|
||||
|
@ -18,6 +18,7 @@ import { Check, ChevronDown, Plus, XCircle } from "lucide-react";
|
||||
import { WorkspaceService } from "services/workspace.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useEventTracker } from "hooks/store";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// components
|
||||
@ -28,6 +29,9 @@ import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown";
|
||||
import { IUser, IWorkspace, TOnboardingSteps } from "@plane/types";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles, ROLE } from "constants/workspace";
|
||||
import { MEMBER_INVITED } from "constants/event-tracker";
|
||||
// helpers
|
||||
import { getUserRole } from "helpers/user.helper";
|
||||
// assets
|
||||
import user1 from "public/users/user-1.png";
|
||||
import user2 from "public/users/user-2.png";
|
||||
@ -267,6 +271,8 @@ export const InviteMembers: React.FC<Props> = (props) => {
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const { resolvedTheme } = useTheme();
|
||||
// store hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
|
||||
const {
|
||||
control,
|
||||
@ -305,6 +311,17 @@ export const InviteMembers: React.FC<Props> = (props) => {
|
||||
})),
|
||||
})
|
||||
.then(async () => {
|
||||
captureEvent(MEMBER_INVITED, {
|
||||
emails: [
|
||||
...payload.emails.map((email) => ({
|
||||
email: email.email,
|
||||
role: getUserRole(email.role),
|
||||
})),
|
||||
],
|
||||
project_id: undefined,
|
||||
state: "SUCCESS",
|
||||
element: "Onboarding",
|
||||
});
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
@ -313,13 +330,18 @@ export const InviteMembers: React.FC<Props> = (props) => {
|
||||
|
||||
await nextStep();
|
||||
})
|
||||
.catch((err) =>
|
||||
.catch((err) => {
|
||||
captureEvent(MEMBER_INVITED, {
|
||||
project_id: undefined,
|
||||
state: "FAILED",
|
||||
element: "Onboarding",
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const appendField = () => {
|
||||
|
@ -15,6 +15,8 @@ import CyclesTour from "public/onboarding/cycles.webp";
|
||||
import ModulesTour from "public/onboarding/modules.webp";
|
||||
import ViewsTour from "public/onboarding/views.webp";
|
||||
import PagesTour from "public/onboarding/pages.webp";
|
||||
// constants
|
||||
import { PRODUCT_TOUR_SKIPPED, PRODUCT_TOUR_STARTED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
onComplete: () => void;
|
||||
@ -79,7 +81,7 @@ export const TourRoot: React.FC<Props> = observer((props) => {
|
||||
const [step, setStep] = useState<TTourSteps>("welcome");
|
||||
// store hooks
|
||||
const { commandPalette: commandPaletteStore } = useApplication();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { setTrackElement, captureEvent } = useEventTracker();
|
||||
const { currentUser } = useUser();
|
||||
|
||||
const currentStepIndex = TOUR_STEPS.findIndex((tourStep) => tourStep.key === step);
|
||||
@ -103,13 +105,22 @@ export const TourRoot: React.FC<Props> = observer((props) => {
|
||||
</p>
|
||||
<div className="flex h-full items-end">
|
||||
<div className="mt-8 flex items-center gap-6">
|
||||
<Button variant="primary" onClick={() => setStep("issues")}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
captureEvent(PRODUCT_TOUR_STARTED);
|
||||
setStep("issues");
|
||||
}}
|
||||
>
|
||||
Take a Product Tour
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-transparent text-xs font-medium text-custom-primary-100 outline-custom-text-100"
|
||||
onClick={onComplete}
|
||||
onClick={() => {
|
||||
captureEvent(PRODUCT_TOUR_SKIPPED);
|
||||
onComplete();
|
||||
}}
|
||||
>
|
||||
No thanks, I will explore it myself
|
||||
</button>
|
||||
@ -156,8 +167,8 @@ export const TourRoot: React.FC<Props> = observer((props) => {
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setTrackElement("Product tour");
|
||||
onComplete();
|
||||
setTrackElement("Onboarding tour");
|
||||
commandPaletteStore.toggleCreateProjectModal(true);
|
||||
}}
|
||||
>
|
||||
|
@ -4,7 +4,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Camera, User2 } from "lucide-react";
|
||||
// hooks
|
||||
import { useUser, useWorkspace } from "hooks/store";
|
||||
import { useEventTracker, useUser, useWorkspace } from "hooks/store";
|
||||
// components
|
||||
import { Button, Input } from "@plane/ui";
|
||||
import { OnboardingSidebar, OnboardingStepIndicator } from "components/onboarding";
|
||||
@ -15,6 +15,7 @@ import { IUser } from "@plane/types";
|
||||
import { FileService } from "services/file.service";
|
||||
// assets
|
||||
import IssuesSvg from "public/onboarding/onboarding-issues.webp";
|
||||
import { USER_DETAILS } from "constants/event-tracker";
|
||||
|
||||
const defaultValues: Partial<IUser> = {
|
||||
first_name: "",
|
||||
@ -48,6 +49,7 @@ export const UserDetails: React.FC<Props> = observer((props) => {
|
||||
// store hooks
|
||||
const { updateCurrentUser } = useUser();
|
||||
const { workspaces } = useWorkspace();
|
||||
const { captureEvent } = useEventTracker();
|
||||
// derived values
|
||||
const workspaceName = workspaces ? Object.values(workspaces)?.[0]?.name : "New Workspace";
|
||||
// form info
|
||||
@ -76,7 +78,21 @@ export const UserDetails: React.FC<Props> = observer((props) => {
|
||||
},
|
||||
};
|
||||
|
||||
await updateCurrentUser(payload);
|
||||
await updateCurrentUser(payload)
|
||||
.then(() => {
|
||||
captureEvent(USER_DETAILS, {
|
||||
use_case: formData.use_case,
|
||||
state: "SUCCESS",
|
||||
element: "Onboarding",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
captureEvent(USER_DETAILS, {
|
||||
use_case: formData.use_case,
|
||||
state: "FAILED",
|
||||
element: "Onboarding",
|
||||
});
|
||||
});
|
||||
};
|
||||
const handleDelete = (url: string | null | undefined) => {
|
||||
if (!url) return;
|
||||
|
@ -5,12 +5,13 @@ import { Button, Input } from "@plane/ui";
|
||||
// types
|
||||
import { IUser, IWorkspace, TOnboardingSteps } from "@plane/types";
|
||||
// hooks
|
||||
import { useUser, useWorkspace } from "hooks/store";
|
||||
import { useEventTracker, useUser, useWorkspace } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
import { WorkspaceService } from "services/workspace.service";
|
||||
// constants
|
||||
import { RESTRICTED_URLS } from "constants/workspace";
|
||||
import { WORKSPACE_CREATED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
stepChange: (steps: Partial<TOnboardingSteps>) => Promise<void>;
|
||||
@ -33,6 +34,7 @@ export const Workspace: React.FC<Props> = (props) => {
|
||||
// store hooks
|
||||
const { updateCurrentUser } = useUser();
|
||||
const { createWorkspace, fetchWorkspaces, workspaces } = useWorkspace();
|
||||
const { captureWorkspaceEvent } = useEventTracker();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@ -46,31 +48,48 @@ export const Workspace: React.FC<Props> = (props) => {
|
||||
setSlugError(false);
|
||||
|
||||
await createWorkspace(formData)
|
||||
.then(async () => {
|
||||
.then(async (res) => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Workspace created successfully.",
|
||||
});
|
||||
captureWorkspaceEvent({
|
||||
eventName: WORKSPACE_CREATED,
|
||||
payload: {
|
||||
...res,
|
||||
state: "SUCCESS",
|
||||
first_time: true,
|
||||
element: "Onboarding",
|
||||
},
|
||||
});
|
||||
await fetchWorkspaces();
|
||||
await completeStep();
|
||||
})
|
||||
.catch(() =>
|
||||
.catch(() => {
|
||||
captureWorkspaceEvent({
|
||||
eventName: WORKSPACE_CREATED,
|
||||
payload: {
|
||||
state: "FAILED",
|
||||
first_time: true,
|
||||
element: "Onboarding",
|
||||
},
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Workspace could not be created. Please try again.",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
} else setSlugError(true);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Some error occurred while creating workspace. Please try again.",
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const completeStep = async () => {
|
||||
|
@ -13,6 +13,7 @@ import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
import { PRODUCT_TOUR_COMPLETED } from "constants/event-tracker";
|
||||
|
||||
export const WorkspaceDashboardView = observer(() => {
|
||||
// theme
|
||||
@ -37,9 +38,8 @@ export const WorkspaceDashboardView = observer(() => {
|
||||
const handleTourCompleted = () => {
|
||||
updateTourCompleted()
|
||||
.then(() => {
|
||||
captureEvent("User tour complete", {
|
||||
captureEvent(PRODUCT_TOUR_COMPLETED, {
|
||||
user_id: currentUser?.id,
|
||||
email: currentUser?.email,
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
@ -84,7 +84,7 @@ export const WorkspaceDashboardView = observer(() => {
|
||||
primaryButton={{
|
||||
text: "Build your first project",
|
||||
onClick: () => {
|
||||
setTrackElement("Dashboard");
|
||||
setTrackElement("Dashboard empty state");
|
||||
toggleCreateProjectModal(true);
|
||||
},
|
||||
}}
|
||||
|
@ -3,10 +3,14 @@ import { useRouter } from "next/router";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// components
|
||||
import { PageForm } from "./page-form";
|
||||
// hooks
|
||||
import { useEventTracker } from "hooks/store";
|
||||
// types
|
||||
import { IPage } from "@plane/types";
|
||||
import { useProjectPages } from "hooks/store/use-project-page";
|
||||
import { IPageStore } from "store/page.store";
|
||||
// constants
|
||||
import { PAGE_CREATED, PAGE_UPDATED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
// data?: IPage | null;
|
||||
@ -21,12 +25,30 @@ export const CreateUpdatePageModal: FC<Props> = (props) => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
// store hooks
|
||||
const { createPage } = useProjectPages();
|
||||
const { capturePageEvent } = useEventTracker();
|
||||
|
||||
const createProjectPage = async (payload: IPage) => {
|
||||
if (!workspaceSlug) return;
|
||||
await createPage(workspaceSlug.toString(), projectId, payload);
|
||||
await createPage(workspaceSlug.toString(), projectId, payload)
|
||||
.then((res) => {
|
||||
capturePageEvent({
|
||||
eventName: PAGE_CREATED,
|
||||
payload: {
|
||||
...res,
|
||||
state: "SUCCESS",
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
capturePageEvent({
|
||||
eventName: PAGE_CREATED,
|
||||
payload: {
|
||||
state: "FAILED",
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: IPage) => {
|
||||
@ -39,6 +61,14 @@ export const CreateUpdatePageModal: FC<Props> = (props) => {
|
||||
if (pageStore.access !== formData.access) {
|
||||
formData.access === 1 ? await pageStore.makePrivate() : await pageStore.makePublic();
|
||||
}
|
||||
capturePageEvent({
|
||||
eventName: PAGE_UPDATED,
|
||||
payload: {
|
||||
...pageStore,
|
||||
state: "SUCCESS",
|
||||
},
|
||||
});
|
||||
console.log("Page updated successfully", pageStore);
|
||||
} else {
|
||||
await createProjectPage(formData);
|
||||
}
|
||||
|
@ -4,12 +4,14 @@ import { observer } from "mobx-react-lite";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
// hooks
|
||||
import { usePage } from "hooks/store";
|
||||
import { useEventTracker, usePage } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// types
|
||||
import { useProjectPages } from "hooks/store/use-project-page";
|
||||
// constants
|
||||
import { PAGE_DELETED } from "constants/event-tracker";
|
||||
|
||||
type TConfirmPageDeletionProps = {
|
||||
pageId: string;
|
||||
@ -27,6 +29,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const { deletePage } = useProjectPages();
|
||||
const { capturePageEvent } = useEventTracker();
|
||||
const pageStore = usePage(pageId);
|
||||
|
||||
// toast alert
|
||||
@ -49,6 +52,13 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
|
||||
// Delete Page will only delete the page from the archive page map, at this point only archived pages can be deleted
|
||||
await deletePage(workspaceSlug.toString(), projectId as string, pageId)
|
||||
.then(() => {
|
||||
capturePageEvent({
|
||||
eventName: PAGE_DELETED,
|
||||
payload: {
|
||||
...pageStore,
|
||||
state: "SUCCESS",
|
||||
},
|
||||
});
|
||||
handleClose();
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
@ -57,6 +67,13 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
capturePageEvent({
|
||||
eventName: PAGE_DELETED,
|
||||
payload: {
|
||||
...pageStore,
|
||||
state: "FAILED",
|
||||
},
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
|
@ -22,16 +22,15 @@ export const ProfileNavbar: React.FC<Props> = (props) => {
|
||||
const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB;
|
||||
|
||||
return (
|
||||
<div className="sticky -top-0.5 z-10 flex items-center justify-between gap-4 border-b border-custom-border-300 bg-custom-background-100 px-4 sm:px-5 md:static">
|
||||
<div className="sticky -top-0.5 z-10 hidden md:flex items-center justify-between gap-4 border-b border-custom-border-300 bg-custom-background-100 px-4 sm:px-5 md:static">
|
||||
<div className="flex items-center overflow-x-scroll">
|
||||
{tabsList.map((tab) => (
|
||||
<Link key={tab.route} href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}>
|
||||
<span
|
||||
className={`flex whitespace-nowrap border-b-2 p-4 text-sm font-medium outline-none ${
|
||||
router.pathname === tab.selected
|
||||
? "border-custom-primary-100 text-custom-primary-100"
|
||||
: "border-transparent"
|
||||
}`}
|
||||
className={`flex whitespace-nowrap border-b-2 p-4 text-sm font-medium outline-none ${router.pathname === tab.selected
|
||||
? "border-custom-primary-100 text-custom-primary-100"
|
||||
: "border-transparent"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { FC } from "react";
|
||||
import React, { FC } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
import { Button, Checkbox } from "@plane/ui";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
@ -23,6 +23,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
|
||||
// form data
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
control,
|
||||
setValue,
|
||||
formState: { isSubmitting, isDirty, dirtyFields },
|
||||
@ -78,12 +79,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
|
||||
control={control}
|
||||
name="property_change"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={() => onChange(!value)}
|
||||
className="w-3.5 h-3.5 mx-2 cursor-pointer !border-custom-border-100"
|
||||
/>
|
||||
<Checkbox checked={value} onChange={() => onChange(!value)} className="mx-2" />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@ -100,14 +96,14 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
|
||||
control={control}
|
||||
name="state_change"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={value}
|
||||
intermediate={!value && watch("issue_completed")}
|
||||
onChange={() => {
|
||||
setValue("issue_completed", !value);
|
||||
onChange(!value);
|
||||
}}
|
||||
className="w-3.5 h-3.5 mx-2 cursor-pointer"
|
||||
className="mx-2"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -123,12 +119,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
|
||||
control={control}
|
||||
name="issue_completed"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={() => onChange(!value)}
|
||||
className="w-3.5 h-3.5 mx-2 cursor-pointer"
|
||||
/>
|
||||
<Checkbox checked={value} onChange={() => onChange(!value)} className="mx-2" />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@ -145,12 +136,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
|
||||
control={control}
|
||||
name="comment"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={() => onChange(!value)}
|
||||
className="w-3.5 h-3.5 mx-2 cursor-pointer"
|
||||
/>
|
||||
<Checkbox checked={value} onChange={() => onChange(!value)} className="mx-2" />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@ -167,12 +153,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
|
||||
control={control}
|
||||
name="mention"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={() => onChange(!value)}
|
||||
className="w-3.5 h-3.5 mx-2 cursor-pointer"
|
||||
/>
|
||||
<Checkbox checked={value} onChange={() => onChange(!value)} className="mx-2" />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
@ -4,7 +4,7 @@ import useSWR from "swr";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useUser } from "hooks/store";
|
||||
import { useApplication, useUser } from "hooks/store";
|
||||
// services
|
||||
import { UserService } from "services/user.service";
|
||||
// components
|
||||
@ -18,6 +18,8 @@ import { renderFormattedDate } from "helpers/date-time.helper";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// fetch-keys
|
||||
import { USER_PROFILE_PROJECT_SEGREGATION } from "constants/fetch-keys";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// services
|
||||
const userService = new UserService();
|
||||
@ -28,6 +30,8 @@ export const ProfileSidebar = observer(() => {
|
||||
const { workspaceSlug, userId } = router.query;
|
||||
// store hooks
|
||||
const { currentUser } = useUser();
|
||||
const { theme: themeStore } = useApplication();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: userProjectsData } = useSWR(
|
||||
workspaceSlug && userId ? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString()) : null,
|
||||
@ -36,6 +40,14 @@ export const ProfileSidebar = observer(() => {
|
||||
: null
|
||||
);
|
||||
|
||||
useOutsideClickDetector(ref, () => {
|
||||
if (themeStore.profileSidebarCollapsed === false) {
|
||||
if (window.innerWidth < 768) {
|
||||
themeStore.toggleProfileSidebar();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const userDetails = [
|
||||
{
|
||||
label: "Joined on",
|
||||
@ -47,8 +59,26 @@ export const ProfileSidebar = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggleProfileSidebar = () => {
|
||||
if (window && window.innerWidth < 768) {
|
||||
themeStore.toggleProfileSidebar(true);
|
||||
}
|
||||
if (window && themeStore.profileSidebarCollapsed && window.innerWidth >= 768) {
|
||||
themeStore.toggleProfileSidebar(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleToggleProfileSidebar);
|
||||
handleToggleProfileSidebar();
|
||||
return () => window.removeEventListener("resize", handleToggleProfileSidebar);
|
||||
}, [themeStore]);
|
||||
|
||||
return (
|
||||
<div className="w-full flex-shrink-0 overflow-y-auto shadow-custom-shadow-sm md:h-full md:w-80 border-l border-custom-border-100">
|
||||
<div
|
||||
className={`flex-shrink-0 overflow-hidden overflow-y-auto shadow-custom-shadow-sm border-l border-custom-border-100 bg-custom-sidebar-background-100 h-full z-[5] fixed md:relative transition-all w-full md:w-[300px]`}
|
||||
style={themeStore.profileSidebarCollapsed ? { marginLeft: `${window?.innerWidth || 0}px` } : {}}
|
||||
>
|
||||
{userProjectsData ? (
|
||||
<>
|
||||
<div className="relative h-32">
|
||||
@ -132,13 +162,12 @@ export const ProfileSidebar = observer(() => {
|
||||
{project.assigned_issues > 0 && (
|
||||
<Tooltip tooltipContent="Completion percentage" position="left">
|
||||
<div
|
||||
className={`rounded px-1 py-0.5 text-xs font-medium ${
|
||||
completedIssuePercentage <= 35
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: completedIssuePercentage <= 70
|
||||
className={`rounded px-1 py-0.5 text-xs font-medium ${completedIssuePercentage <= 35
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: completedIssuePercentage <= 70
|
||||
? "bg-yellow-500/10 text-yellow-500"
|
||||
: "bg-green-500/10 text-green-500"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{completedIssuePercentage}%
|
||||
</div>
|
||||
|
@ -18,6 +18,7 @@ import { getRandomEmoji, renderEmoji } from "helpers/emoji.helper";
|
||||
import { NETWORK_CHOICES, PROJECT_UNSPLASH_COVERS } from "constants/project";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
import { PROJECT_CREATED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
@ -134,13 +135,8 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
|
||||
state: "SUCCESS",
|
||||
};
|
||||
captureProjectEvent({
|
||||
eventName: "Project created",
|
||||
eventName: PROJECT_CREATED,
|
||||
payload: newPayload,
|
||||
group: {
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: res.workspace,
|
||||
},
|
||||
});
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
@ -160,16 +156,11 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
|
||||
message: err.data[key],
|
||||
});
|
||||
captureProjectEvent({
|
||||
eventName: "Project created",
|
||||
eventName: PROJECT_CREATED,
|
||||
payload: {
|
||||
...payload,
|
||||
state: "FAILED",
|
||||
},
|
||||
group: {
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
},
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -10,6 +10,8 @@ import useToast from "hooks/use-toast";
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// types
|
||||
import type { IProject } from "@plane/types";
|
||||
// constants
|
||||
import { PROJECT_DELETED } from "constants/event-tracker";
|
||||
|
||||
type DeleteProjectModal = {
|
||||
isOpen: boolean;
|
||||
@ -62,13 +64,8 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => {
|
||||
|
||||
handleClose();
|
||||
captureProjectEvent({
|
||||
eventName: "Project deleted",
|
||||
eventName: PROJECT_DELETED,
|
||||
payload: { ...project, state: "SUCCESS", element: "Project general settings" },
|
||||
group: {
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
},
|
||||
});
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
@ -78,13 +75,8 @@ export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => {
|
||||
})
|
||||
.catch(() => {
|
||||
captureProjectEvent({
|
||||
eventName: "Project deleted",
|
||||
eventName: PROJECT_DELETED,
|
||||
payload: { ...project, state: "FAILED", element: "Project general settings" },
|
||||
group: {
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
},
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
|
@ -18,6 +18,7 @@ import { renderFormattedDate } from "helpers/date-time.helper";
|
||||
import { NETWORK_CHOICES } from "constants/project";
|
||||
// services
|
||||
import { ProjectService } from "services/project";
|
||||
import { PROJECT_UPDATED } from "constants/event-tracker";
|
||||
|
||||
export interface IProjectDetailsForm {
|
||||
project: IProject;
|
||||
@ -45,7 +46,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
|
||||
setValue,
|
||||
setError,
|
||||
reset,
|
||||
formState: { errors },
|
||||
formState: { errors, dirtyFields },
|
||||
} = useForm<IProject>({
|
||||
defaultValues: {
|
||||
...project,
|
||||
@ -77,13 +78,15 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
|
||||
|
||||
return updateProject(workspaceSlug.toString(), project.id, payload)
|
||||
.then((res) => {
|
||||
const changed_properties = Object.keys(dirtyFields);
|
||||
console.log(dirtyFields);
|
||||
captureProjectEvent({
|
||||
eventName: "Project updated",
|
||||
payload: { ...res, state: "SUCCESS", element: "Project general settings" },
|
||||
group: {
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: res.workspace,
|
||||
eventName: PROJECT_UPDATED,
|
||||
payload: {
|
||||
...res,
|
||||
changed_properties: changed_properties,
|
||||
state: "SUCCESS",
|
||||
element: "Project general settings",
|
||||
},
|
||||
});
|
||||
setToastAlert({
|
||||
@ -94,13 +97,8 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
captureProjectEvent({
|
||||
eventName: "Project updated",
|
||||
eventName: PROJECT_UPDATED,
|
||||
payload: { ...payload, state: "FAILED", element: "Project general settings" },
|
||||
group: {
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id,
|
||||
},
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
@ -153,7 +151,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
|
||||
<img src={watch("cover_image")!} alt={watch("cover_image")!} className="h-44 w-full rounded-md object-cover" />
|
||||
<div className="absolute bottom-4 z-5 flex w-full items-end justify-between gap-3 px-4">
|
||||
<div className="z-5 absolute bottom-4 flex w-full items-end justify-between gap-3 px-4">
|
||||
<div className="flex flex-grow gap-3 truncate">
|
||||
<div className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-lg bg-custom-background-90">
|
||||
<div className="grid h-7 w-7 place-items-center">
|
||||
|
@ -11,6 +11,8 @@ import useToast from "hooks/use-toast";
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// types
|
||||
import { IProject } from "@plane/types";
|
||||
// constants
|
||||
import { PROJECT_MEMBER_LEAVE } from "constants/event-tracker";
|
||||
|
||||
type FormData = {
|
||||
projectName: string;
|
||||
@ -63,8 +65,9 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => {
|
||||
.then(() => {
|
||||
handleClose();
|
||||
router.push(`/${workspaceSlug}/projects`);
|
||||
captureEvent("Project member leave", {
|
||||
captureEvent(PROJECT_MEMBER_LEAVE, {
|
||||
state: "SUCCESS",
|
||||
element: "Project settings members page",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
@ -73,8 +76,9 @@ export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => {
|
||||
title: "Error!",
|
||||
message: "Something went wrong please try again later.",
|
||||
});
|
||||
captureEvent("Project member leave", {
|
||||
captureEvent(PROJECT_MEMBER_LEAVE, {
|
||||
state: "FAILED",
|
||||
element: "Project settings members page",
|
||||
});
|
||||
});
|
||||
} else {
|
||||
|
@ -3,7 +3,7 @@ import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useMember, useProject, useUser } from "hooks/store";
|
||||
import { useEventTracker, useMember, useProject, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { ConfirmProjectMemberRemove } from "components/project";
|
||||
@ -14,6 +14,7 @@ import { ChevronDown, Dot, XCircle } from "lucide-react";
|
||||
// constants
|
||||
import { ROLE } from "constants/workspace";
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { PROJECT_MEMBER_LEAVE } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
userId: string;
|
||||
@ -35,6 +36,7 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
project: { removeMemberFromProject, getProjectMemberDetails, updateMember },
|
||||
} = useMember();
|
||||
const { captureEvent } = useEventTracker();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@ -48,8 +50,11 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
|
||||
if (userDetails.member.id === currentUser?.id) {
|
||||
await leaveProject(workspaceSlug.toString(), projectId.toString())
|
||||
.then(async () => {
|
||||
captureEvent(PROJECT_MEMBER_LEAVE, {
|
||||
state: "SUCCESS",
|
||||
element: "Project settings members page",
|
||||
});
|
||||
await fetchProjects(workspaceSlug.toString());
|
||||
|
||||
router.push(`/${workspaceSlug}/projects`);
|
||||
})
|
||||
.catch((err) =>
|
||||
|
@ -9,9 +9,12 @@ import { useEventTracker, useMember, useUser, useWorkspace } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Avatar, Button, CustomSelect, CustomSearchSelect } from "@plane/ui";
|
||||
// helpers
|
||||
import { getUserRole } from "helpers/user.helper";
|
||||
// constants
|
||||
import { ROLE } from "constants/workspace";
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { PROJECT_MEMBER_ADDED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
@ -49,7 +52,6 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const {
|
||||
project: { projectMemberIds, bulkAddMembersToProject },
|
||||
workspace: { workspaceMemberIds, getWorkspaceMemberDetails },
|
||||
@ -79,7 +81,7 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
|
||||
const payload = { ...formData };
|
||||
|
||||
await bulkAddMembersToProject(workspaceSlug.toString(), projectId.toString(), payload)
|
||||
.then((res) => {
|
||||
.then(() => {
|
||||
if (onSuccess) onSuccess();
|
||||
onClose();
|
||||
setToastAlert({
|
||||
@ -87,32 +89,23 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
|
||||
type: "success",
|
||||
message: "Members added successfully.",
|
||||
});
|
||||
captureEvent(
|
||||
"Member added",
|
||||
{
|
||||
...res,
|
||||
state: "SUCCESS",
|
||||
},
|
||||
{
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
}
|
||||
);
|
||||
captureEvent(PROJECT_MEMBER_ADDED, {
|
||||
members: [
|
||||
...payload.members.map((member) => ({
|
||||
member_id: member.member_id,
|
||||
role: ROLE[member.role],
|
||||
})),
|
||||
],
|
||||
state: "SUCCESS",
|
||||
element: "Project settings members page",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
captureEvent(
|
||||
"Member added",
|
||||
{
|
||||
state: "FAILED",
|
||||
},
|
||||
{
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
}
|
||||
);
|
||||
captureEvent(PROJECT_MEMBER_ADDED, {
|
||||
state: "FAILED",
|
||||
element: "Project settings members page",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
reset(defaultValues);
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user