[WEB-4230] refactor: Analytics code refacor, Removal of nivo charts dependencies and translations (#7131)

* chore: added code split for the analytics store

* chore: done some refactor

* refactor: update entity keys in analytics and translations

* chore: updated the translations

* refactor: simplify AnalyticsStoreV2 class by removing unnecessary constructor

* feat: add AnalyticsStoreV2 class and interface for enhanced analytics functionality

* feat: enhance WorkItemsModal and analytics store with isEpic functionality

* feat: integrate isEpic state into TotalInsights and WorkItemsModal components

* refactor: remove isEpic state from WorkItemsModalMainContent component

* refactor: removed old  analytics components and related services

* refactor: new analytics

* refactor: removed all nivo chart dependencies

* chore: resolved coderabbit comments

* fix: update processUrl to handle custom-work-items in peek view

* feat: implement CSV export functionality in InsightTable component

* feat: enhance analytics service with filter parameters and improve data handling in InsightTable

* feat: add new translation keys for various statuses across multiple languages

* [WEB-4246] fix: enhance analytics components to include 'isEpic' parameter for improved data fetching

* chore: update yarn.lock to remove deprecated @nivo packages and clean up unused dependencies
This commit is contained in:
JayashTripathy 2025-06-06 01:53:38 +05:30 committed by GitHub
parent 570a9e319e
commit 14d2d69120
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
151 changed files with 1144 additions and 4800 deletions

View file

@ -1 +0,0 @@
export * from "./overview/root";

View file

@ -1,75 +0,0 @@
import { ColumnDef, Row, Table } from "@tanstack/react-table";
import { download, generateCsv, mkConfig } from "export-to-csv";
import { useParams } from "next/navigation";
import { Download } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { AnalyticsTableDataMap, TAnalyticsTabsV2Base } from "@plane/types";
import { Button } from "@plane/ui";
import { DataTable } from "./data-table";
import { TableLoader } from "./loader";
interface InsightTableProps<T extends Exclude<TAnalyticsTabsV2Base, "overview">> {
analyticsType: T;
data?: AnalyticsTableDataMap[T][];
isLoading?: boolean;
columns: ColumnDef<AnalyticsTableDataMap[T]>[];
columnsLabels?: Record<string, string>;
headerText: string;
}
export const InsightTable = <T extends Exclude<TAnalyticsTabsV2Base, "overview">>(
props: InsightTableProps<T>
): React.ReactElement => {
const { data, isLoading, columns, columnsLabels, headerText } = props;
const params = useParams();
const { t } = useTranslation();
const workspaceSlug = params.workspaceSlug.toString();
if (isLoading) {
return <TableLoader columns={columns} rows={5} />;
}
const csvConfig = mkConfig({
fieldSeparator: ",",
filename: `${workspaceSlug}-analytics`,
decimalSeparator: ".",
useKeysAsHeaders: true,
});
const exportCSV = (rows: Row<AnalyticsTableDataMap[T]>[]) => {
const rowData: any = rows.map((row) => {
const { project_id, avatar_url, assignee_id, ...exportableData } = row.original;
return Object.fromEntries(
Object.entries(exportableData).map(([key, value]) => {
if (columnsLabels?.[key]) {
return [columnsLabels[key], value];
}
return [key, value];
})
);
});
const csv = generateCsv(csvConfig)(rowData);
download(csvConfig)(csv);
};
return (
<div className="">
{data ? (
<DataTable
columns={columns}
data={data}
searchPlaceholder={`${data.length} ${headerText}`}
actions={(table: Table<AnalyticsTableDataMap[T]>) => (
<Button
variant="accent-primary"
prependIcon={<Download className="h-3.5 w-3.5" />}
onClick={() => exportCSV(table.getFilteredRowModel().rows)}
>
<div>{t("exporter.csv.short_description")}</div>
</Button>
)}
/>
) : (
<div>No data</div>
)}
</div>
);
};

View file

@ -1,66 +0,0 @@
// plane package imports
import { observer } from "mobx-react-lite";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { insightsFields } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IAnalyticsResponseV2, TAnalyticsTabsV2Base } from "@plane/types";
//hooks
import { cn } from "@/helpers/common.helper";
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
//services
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
// plane web components
import InsightCard from "./insight-card";
const analyticsV2Service = new AnalyticsV2Service();
const TotalInsights: React.FC<{ analyticsType: TAnalyticsTabsV2Base; peekView?: boolean }> = observer(
({ analyticsType, peekView }) => {
const params = useParams();
const workspaceSlug = params.workspaceSlug.toString();
const { t } = useTranslation();
const { selectedDuration, selectedProjects, selectedDurationLabel, selectedCycle, selectedModule, isPeekView } =
useAnalyticsV2();
const { data: totalInsightsData, isLoading } = useSWR(
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
() =>
analyticsV2Service.getAdvanceAnalytics<IAnalyticsResponseV2>(
workspaceSlug,
analyticsType,
{
// date_filter: selectedDuration,
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
...(selectedModule ? { module_id: selectedModule } : {}),
},
isPeekView
)
);
return (
<div
className={cn(
"grid grid-cols-1 gap-8 sm:grid-cols-2 md:gap-10",
!peekView
? insightsFields[analyticsType].length % 5 === 0
? "gap-10 lg:grid-cols-5"
: "gap-8 lg:grid-cols-4"
: "grid-cols-2"
)}
>
{insightsFields[analyticsType]?.map((item: string) => (
<InsightCard
key={`${analyticsType}-${item}`}
isLoading={isLoading}
data={totalInsightsData?.[item]}
label={t(`workspace_analytics.${item}`)}
versus={selectedDurationLabel}
/>
))}
</div>
);
}
);
export default TotalInsights;

View file

@ -2,13 +2,13 @@
import { observer } from "mobx-react-lite";
// hooks
import { useProject } from "@/hooks/store";
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
import { useAnalytics } from "@/hooks/store/use-analytics";
// components
import DurationDropdown from "./select/duration";
import { ProjectSelect } from "./select/project";
const AnalyticsFilterActions = observer(() => {
const { selectedProjects, selectedDuration, updateSelectedProjects, updateSelectedDuration } = useAnalyticsV2();
const { selectedProjects, selectedDuration, updateSelectedProjects, updateSelectedDuration } = useAnalytics();
const { workspaceProjectIds } = useProject();
return (
<div className="flex items-center justify-end gap-2">

View file

@ -0,0 +1,9 @@
import { mkConfig } from "export-to-csv";
export const csvConfig = (workspaceSlug: string) =>
mkConfig({
fieldSeparator: ",",
filename: `${workspaceSlug}-analytics`,
decimalSeparator: ".",
useKeysAsHeaders: true,
});

View file

@ -1,94 +0,0 @@
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useForm } from "react-hook-form";
import useSWR from "swr";
import { IAnalyticsParams } from "@plane/types";
// services
// components
import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSidebar } from "@/components/analytics";
// types
// fetch-keys
import { ANALYTICS } from "@/constants/fetch-keys";
import { cn } from "@/helpers/common.helper";
import { useAppTheme } from "@/hooks/store";
import { hideFloatingBot, showFloatingBot } from "@/plane-web/helpers/pi-chat.helper";
import { AnalyticsService } from "@/services/analytics.service";
type Props = {
additionalParams?: Partial<IAnalyticsParams>;
fullScreen: boolean;
};
const defaultValues: IAnalyticsParams = {
x_axis: "priority",
y_axis: "issue_count",
segment: null,
project: null,
};
const analyticsService = new AnalyticsService();
export const CustomAnalytics: React.FC<Props> = observer((props) => {
const { additionalParams, fullScreen } = props;
const { workspaceSlug, projectId } = useParams();
const { control, watch, setValue } = useForm({ defaultValues });
const params: IAnalyticsParams = {
x_axis: watch("x_axis"),
y_axis: watch("y_axis"),
segment: watch("segment"),
project: projectId ? [projectId.toString()] : watch("project"),
...additionalParams,
};
const { data: analytics, error: analyticsError } = useSWR(
workspaceSlug ? ANALYTICS(workspaceSlug.toString(), params) : null,
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
);
const { workspaceAnalyticsSidebarCollapsed } = useAppTheme();
const isProjectLevel = projectId ? true : false;
useEffect(() => {
hideFloatingBot();
return () => {
showFloatingBot();
};
}, []);
return (
<div className={cn("relative flex h-full w-full overflow-hidden", isProjectLevel ? "flex-col-reverse" : "")}>
<div className="flex h-full w-full flex-col overflow-hidden">
<CustomAnalyticsSelectBar
control={control}
setValue={setValue}
params={params}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
/>
<CustomAnalyticsMainContent
analytics={analytics}
error={analyticsError}
params={params}
fullScreen={fullScreen}
/>
</div>
<div
className={cn(
"border-l border-custom-border-200 transition-all",
!isProjectLevel
? "absolute bottom-0 right-0 top-0 h-full max-w-[250px] flex-shrink-0 sm:max-w-full md:relative"
: ""
)}
style={workspaceAnalyticsSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}}
>
<CustomAnalyticsSidebar analytics={analytics} params={params} isProjectLevel={isProjectLevel} />
</div>
</div>
);
});

View file

@ -1,73 +0,0 @@
// nivo
import { BarTooltipProps } from "@nivo/bar";
// plane imports
import { ANALYTICS_DATE_KEYS } from "@plane/constants";
import { IAnalyticsParams, IAnalyticsResponse } from "@plane/types";
// helpers
import { renderMonthAndYear } from "@/helpers/analytics.helper";
type Props = {
datum: BarTooltipProps<any>;
analytics: IAnalyticsResponse;
params: IAnalyticsParams;
};
export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) => {
let tooltipValue: string | number = "";
const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
if (!assignee) return "No assignee";
return assignee.assignees__display_name || "No assignee";
};
if (params.segment) {
if (ANALYTICS_DATE_KEYS.includes(params.segment)) tooltipValue = renderMonthAndYear(datum.id);
else if (params.segment === "labels__id") {
const label = analytics.extras.label_details.find((l) => l.labels__id === datum.id);
tooltipValue = label && label.labels__name ? label.labels__name : "None";
} else if (params.segment === "state_id") {
const state = analytics.extras.state_details.find((s) => s.state_id === datum.id);
tooltipValue = state && state.state__name ? state.state__name : "None";
} else if (params.segment === "issue_cycle__cycle_id") {
const cycle = analytics.extras.cycle_details.find((c) => c.issue_cycle__cycle_id === datum.id);
tooltipValue = cycle && cycle.issue_cycle__cycle__name ? cycle.issue_cycle__cycle__name : "None";
} else if (params.segment === "issue_module__module_id") {
const selectedModule = analytics.extras.module_details.find((m) => m.issue_module__module_id === datum.id);
tooltipValue =
selectedModule && selectedModule.issue_module__module__name
? selectedModule.issue_module__module__name
: "None";
} else tooltipValue = datum.id;
} else {
if (ANALYTICS_DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue;
else tooltipValue = datum.id === "count" ? "Work item count" : "Estimate";
}
return (
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: datum.color,
}}
/>
<span
className={`font-medium text-custom-text-200 ${
params.segment
? params.segment === "priority" || params.segment === "state__group"
? "capitalize"
: ""
: params.x_axis === "priority" || params.x_axis === "state__group"
? "capitalize"
: ""
}`}
>
{params.segment === "assignees__id" ? renderAssigneeName(tooltipValue.toString()) : tooltipValue}:
</span>
<span>{datum.value}</span>
</div>
);
};

View file

@ -1,136 +0,0 @@
"use client";
// nivo
import { BarDatum } from "@nivo/bar";
// components
import { IAnalyticsParams, IAnalyticsResponse } from "@plane/types";
import { Tooltip } from "@plane/ui";
// ui
import { BarGraph } from "@/components/ui";
// helpers
import { generateBarColor, generateDisplayName, renderChartDynamicLabel } from "@/helpers/analytics.helper";
import { findStringWithMostCharacters } from "@/helpers/array.helper";
import { getFileURL } from "@/helpers/file.helper";
// types
import { CustomTooltip } from "./custom-tooltip";
type Props = {
analytics: IAnalyticsResponse;
barGraphData: {
data: BarDatum[];
xAxisKeys: string[];
};
params: IAnalyticsParams;
yAxisKey: "count" | "estimate";
fullScreen: boolean;
};
export const AnalyticsGraph: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey, fullScreen }) => {
const generateYAxisTickValues = () => {
if (!analytics) return [];
let data: number[] = [];
if (params.segment)
// find the total no of work items in each segment
data = Object.keys(analytics.distribution).map((segment) => {
let totalSegmentIssues = 0;
analytics.distribution[segment].map((s) => {
totalSegmentIssues += s[yAxisKey] as number;
});
return totalSegmentIssues;
});
else data = barGraphData.data.map((d) => d[yAxisKey] as number);
return data;
};
const longestXAxisLabel = findStringWithMostCharacters(barGraphData.data.map((d) => `${d.name}`));
return (
<BarGraph
data={barGraphData.data}
indexBy="name"
keys={barGraphData.xAxisKeys}
colors={(datum) =>
generateBarColor(
params.segment ? `${datum.id}` : `${datum.indexValue}`,
analytics,
params,
params.segment ? "segment" : "x_axis"
)
}
customYAxisTickValues={generateYAxisTickValues()}
tooltip={(datum) => <CustomTooltip datum={datum} analytics={analytics} params={params} />}
height={fullScreen ? "400px" : "300px"}
margin={{
right: 20,
bottom: params.x_axis === "assignees__id" ? 50 : renderChartDynamicLabel(longestXAxisLabel)?.length * 5 + 20,
}}
axisBottom={{
tickSize: 0,
tickPadding: 10,
tickRotation: barGraphData.data.length > 7 ? -45 : 0,
renderTick:
params.x_axis === "assignees__id"
? (datum) => {
const assignee = analytics.extras.assignee_details?.find((a) => a?.assignees__id === datum?.value);
if (assignee?.assignees__avatar_url && assignee?.assignees__avatar_url !== "")
return (
<Tooltip tooltipContent={assignee?.assignees__display_name}>
<g transform={`translate(${datum.x},${datum.y})`}>
<image
x={-8}
y={10}
width={16}
height={16}
xlinkHref={getFileURL(assignee?.assignees__avatar_url)}
style={{ clipPath: "circle(50%)" }}
/>
</g>
</Tooltip>
);
else
return (
<Tooltip tooltipContent={assignee?.assignees__display_name}>
<g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{params.x_axis === "assignees__id"
? datum.value && datum.value !== "None"
? generateDisplayName(datum.value, analytics, params, "x_axis")[0].toUpperCase()
: "?"
: datum.value && datum.value !== "None"
? `${datum.value}`.toUpperCase()[0]
: "?"}
</text>
</g>
</Tooltip>
);
}
: (datum) => (
<Tooltip tooltipContent={generateDisplayName(datum.value, analytics, params, "x_axis")}>
<g transform={`translate(${datum.x},${datum.y + 20})`}>
<text
x={0}
y={datum.y}
textAnchor={`${barGraphData.data.length > 7 ? "end" : "middle"}`}
fontSize={10}
fill="rgb(var(--color-text-200))"
className={`${barGraphData.data.length > 7 ? "-rotate-45" : ""}`}
>
{renderChartDynamicLabel(generateDisplayName(datum.value, analytics, params, "x_axis"))?.label}
</text>
</g>
</Tooltip>
),
}}
theme={{
axis: {},
}}
/>
);
};

View file

@ -1,7 +0,0 @@
export * from "./graph";
export * from "./select";
export * from "./custom-analytics";
export * from "./main-content";
export * from "./select-bar";
export * from "./sidebar";
export * from "./table";

View file

@ -1,85 +0,0 @@
"use client";
import { useParams } from "next/navigation";
import { mutate } from "swr";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "@plane/types";
// ui
import { Button, Loader } from "@plane/ui";
// components
import { AnalyticsGraph, AnalyticsTable } from "@/components/analytics";
// fetch-keys
import { ANALYTICS } from "@/constants/fetch-keys";
// helpers
import { convertResponseToBarGraphData } from "@/helpers/analytics.helper";
type Props = {
analytics: IAnalyticsResponse | undefined;
error: any;
fullScreen: boolean;
params: IAnalyticsParams;
};
export const CustomAnalyticsMainContent: React.FC<Props> = (props) => {
const { analytics, error, fullScreen, params } = props;
const { workspaceSlug } = useParams();
const yAxisKey = params.y_axis === "issue_count" ? "count" : "estimate";
const barGraphData = convertResponseToBarGraphData(analytics?.distribution, params);
return (
<>
{!error ? (
analytics ? (
analytics.total > 0 ? (
<div className="h-full overflow-y-auto vertical-scrollbar scrollbar-md">
<AnalyticsGraph
analytics={analytics}
barGraphData={barGraphData}
params={params}
yAxisKey={yAxisKey}
fullScreen={fullScreen}
/>
<AnalyticsTable analytics={analytics} barGraphData={barGraphData} params={params} yAxisKey={yAxisKey} />
</div>
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-custom-text-200">
<p className="text-sm">No matching work items found. Try changing the parameters.</p>
</div>
</div>
)
) : (
<Loader className="space-y-6">
<Loader.Item height="300px" />
<Loader className="space-y-4">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
</Loader>
)
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-custom-text-200">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<Button
variant="primary"
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
Refresh
</Button>
</div>
</div>
</div>
)}
</>
);
};

View file

@ -1,94 +0,0 @@
import { observer } from "mobx-react";
import { Control, Controller, UseFormSetValue } from "react-hook-form";
// plane imports
import { ANALYTICS_X_AXIS_VALUES } from "@plane/constants";
import { IAnalyticsParams } from "@plane/types";
import { Row } from "@plane/ui";
// components
import { SelectProject, SelectSegment, SelectXAxis, SelectYAxis } from "@/components/analytics";
// hooks
import { useProject } from "@/hooks/store";
type Props = {
control: Control<IAnalyticsParams, any>;
setValue: UseFormSetValue<IAnalyticsParams>;
params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean;
};
export const CustomAnalyticsSelectBar: React.FC<Props> = observer((props) => {
const { control, setValue, params, fullScreen, isProjectLevel } = props;
const { workspaceProjectIds: workspaceProjectIds, currentProjectDetails } = useProject();
const analyticsOptions = isProjectLevel
? ANALYTICS_X_AXIS_VALUES.filter((v) => {
if (v.value === "issue_cycle__cycle_id" && !currentProjectDetails?.cycle_view) return false;
if (v.value === "issue_module__module_id" && !currentProjectDetails?.module_view) return false;
return true;
})
: ANALYTICS_X_AXIS_VALUES;
return (
<Row
className={`grid items-center gap-4 py-2.5 ${
isProjectLevel ? "grid-cols-1 sm:grid-cols-3" : "grid-cols-2"
} ${fullScreen ? "md:py-5 lg:grid-cols-4" : ""}`}
>
{!isProjectLevel && (
<div>
<h6 className="text-xs text-custom-text-200 mb-2">Project</h6>
<Controller
name="project"
control={control}
render={({ field: { value, onChange } }) => (
<SelectProject
value={value ?? undefined}
onChange={onChange}
projectIds={workspaceProjectIds ?? undefined}
/>
)}
/>
</div>
)}
<div>
<h6 className="text-xs text-custom-text-200 mb-2">Measure (y-axis)</h6>
<Controller
name="y_axis"
control={control}
render={({ field: { value, onChange } }) => <SelectYAxis value={value} onChange={onChange} />}
/>
</div>
<div>
<h6 className="text-xs text-custom-text-200 mb-2">Dimension (x-axis)</h6>
<Controller
name="x_axis"
control={control}
render={({ field: { value, onChange } }) => (
<SelectXAxis
value={value}
onChange={(val: string) => {
if (params.segment === val) setValue("segment", null);
onChange(val);
}}
params={params}
analyticsOptions={analyticsOptions}
/>
)}
/>
</div>
<div>
<h6 className="text-xs text-custom-text-200 mb-2">Group</h6>
<Controller
name="segment"
control={control}
render={({ field: { value, onChange } }) => (
<SelectSegment value={value} onChange={onChange} params={params} analyticsOptions={analyticsOptions} />
)}
/>
</div>
</Row>
);
});

View file

@ -1,4 +0,0 @@
export * from "./project";
export * from "./segment";
export * from "./x-axis";
export * from "./y-axis";

View file

@ -1,52 +0,0 @@
"use client";
import { observer } from "mobx-react";
// hooks
import { CustomSearchSelect } from "@plane/ui";
import { useProject } from "@/hooks/store";
// ui
type Props = {
value: string[] | undefined;
onChange: (val: string[] | null) => void;
projectIds: string[] | undefined;
};
export const SelectProject: React.FC<Props> = observer((props) => {
const { value, onChange, projectIds } = props;
const { getProjectById } = useProject();
const options = projectIds?.map((projectId) => {
const projectDetails = getProjectById(projectId);
return {
value: projectDetails?.id,
query: `${projectDetails?.name} ${projectDetails?.identifier}`,
content: (
<div className="flex items-center gap-2 max-w-[300px]">
<span className="text-[0.65rem] text-custom-text-200 flex-shrink-0">{projectDetails?.identifier}</span>
<span className="flex-grow truncate">{projectDetails?.name}</span>
</div>
),
};
});
return (
<CustomSearchSelect
value={value ?? []}
onChange={(val: string[]) => onChange(val)}
options={options}
label={
<div className="truncate">
{value && value.length > 0
? projectIds
?.filter((p) => value.includes(p))
.map((p) => getProjectById(p)?.name)
.join(", ")
: "All projects"}
</div>
}
multiple
/>
);
});

View file

@ -1,45 +0,0 @@
"use client";
import { useParams } from "next/navigation";
import { IAnalyticsParams, TXAxisValues } from "@plane/types";
// ui
import { CustomSelect } from "@plane/ui";
type Props = {
value: TXAxisValues | null | undefined;
onChange: () => void;
params: IAnalyticsParams;
analyticsOptions: { value: TXAxisValues; label: string }[];
};
export const SelectSegment: React.FC<Props> = ({ value, onChange, params, analyticsOptions }) => {
const { cycleId, moduleId } = useParams();
return (
<CustomSelect
value={value}
label={
<span>
{analyticsOptions.find((v) => v.value === value)?.label ?? (
<span className="text-custom-text-200">No value</span>
)}
</span>
}
onChange={onChange}
maxHeight="lg"
>
<CustomSelect.Option value={null}>No value</CustomSelect.Option>
{analyticsOptions.map((item) => {
if (params.x_axis === item.value) return null;
if (cycleId && item.value === "issue_cycle__cycle_id") return null;
if (moduleId && item.value === "issue_module__module_id") return null;
return (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
);
})}
</CustomSelect>
);
};

View file

@ -1,40 +0,0 @@
"use client";
import { useParams } from "next/navigation";
import { IAnalyticsParams, TXAxisValues } from "@plane/types";
// ui
import { CustomSelect } from "@plane/ui";
type Props = {
value: TXAxisValues;
onChange: (val: string) => void;
params: IAnalyticsParams;
analyticsOptions: { value: TXAxisValues; label: string }[];
};
export const SelectXAxis: React.FC<Props> = (props) => {
const { value, onChange, params, analyticsOptions } = props;
const { cycleId, moduleId } = useParams();
return (
<CustomSelect
value={value}
label={<span>{analyticsOptions.find((v) => v.value === value)?.label}</span>}
onChange={onChange}
maxHeight="lg"
>
{analyticsOptions.map((item) => {
if (params.segment === item.value) return null;
if (cycleId && item.value === "issue_cycle__cycle_id") return null;
if (moduleId && item.value === "issue_module__module_id") return null;
return (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
);
})}
</CustomSelect>
);
};

View file

@ -1,58 +0,0 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { ANALYTICS_Y_AXIS_VALUES } from "@plane/constants";
import { TYAxisValues } from "@plane/types";
import { CustomSelect } from "@plane/ui";
// hooks
import { useProjectEstimates } from "@/hooks/store";
// plane web constants
import { EEstimateSystem } from "@/plane-web/constants/estimates";
type Props = {
value: TYAxisValues;
onChange: () => void;
};
export const SelectYAxis: React.FC<Props> = observer(({ value, onChange }) => {
// hooks
const { projectId } = useParams();
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
const isEstimateEnabled = (analyticsOption: string) => {
if (analyticsOption === "estimate") {
if (
projectId &&
currentActiveEstimateId &&
areEstimateEnabledByProjectId(projectId.toString()) &&
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS
) {
return true;
} else {
return false;
}
}
return true;
};
return (
<CustomSelect
value={value}
label={<span>{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}</span>}
onChange={onChange}
maxHeight="lg"
>
{ANALYTICS_Y_AXIS_VALUES.map(
(item) =>
isEstimateEnabled(item.value) && (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
)
)}
</CustomSelect>
);
});

View file

@ -1,3 +0,0 @@
export * from "./projects-list";
export * from "./sidebar-header";
export * from "./sidebar";

View file

@ -1,89 +0,0 @@
import { observer } from "mobx-react";
// icons
import { Contrast, LayoutGrid, Users, Loader as Spinner } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Loader } from "@plane/ui";
// components
import { Logo } from "@/components/common";
// helpers
import { truncateText } from "@/helpers/string.helper";
// hooks
import { useProject } from "@/hooks/store";
type Props = {
projectIds: string[];
isLoading: boolean;
isUpdating: boolean;
};
export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((props) => {
const { projectIds, isLoading, isUpdating } = props;
// store hooks
const { getProjectById, getProjectAnalyticsCountById } = useProject();
const { t } = useTranslation();
return (
<div className="relative flex flex-col gap-4 h-full">
<div className="flex gap-2 items-center">
<h4 className="font-medium">{t("workspace_analytics.selected_projects")}</h4>
{isUpdating && <Spinner className="animate-spin size-3" />}
</div>
<div className="relative space-y-6 overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-md">
{projectIds.map((projectId) => {
const project = getProjectById(projectId);
const projectAnalyticsCount = getProjectAnalyticsCountById(projectId);
if (!project) return;
return (
<div key={projectId} className="w-full">
<div className="flex items-center gap-1 text-sm">
<div className="h-6 w-6 grid place-items-center">
<Logo logo={project.logo_props} />
</div>
<h5 className="flex items-center gap-1">
<p className="break-words">{truncateText(project.name, 20)}</p>
<span className="ml-1 text-xs text-custom-text-200">({project.identifier})</span>
</h5>
</div>
<div className="mt-4 w-full space-y-3 px-2">
{isLoading ? (
<Loader className="space-y-3">
<Loader.Item height="16px" />
<Loader.Item height="16px" />
<Loader.Item height="16px" />
</Loader>
) : (
<>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<Users className="text-custom-text-200" size={14} strokeWidth={2} />
<h6>{t("workspace_analytics.total_members")}</h6>
</div>
<span className="text-custom-text-200">{projectAnalyticsCount?.total_members}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<Contrast className="text-custom-text-200" size={14} strokeWidth={2} />
<h6>{t("workspace_analytics.total_cycles")}</h6>
</div>
<span className="text-custom-text-200">{projectAnalyticsCount?.total_cycles}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<LayoutGrid className="text-custom-text-200" size={14} strokeWidth={2} />
<h6>{t("workspace_analytics.total_modules")}</h6>
</div>
<span className="text-custom-text-200">{projectAnalyticsCount?.total_modules}</span>
</div>
</>
)}
</div>
</div>
);
})}
</div>
</div>
);
});

View file

@ -1,104 +0,0 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { NETWORK_CHOICES } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Logo } from "@/components/common";
// constants
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useCycle, useMember, useModule, useProject } from "@/hooks/store";
export const CustomAnalyticsSidebarHeader = observer(() => {
const { projectId, cycleId, moduleId } = useParams();
const { getProjectById } = useProject();
const { getCycleById } = useCycle();
const { getModuleById } = useModule();
const { getUserDetails } = useMember();
const { t } = useTranslation();
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined;
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
const moduleLeadDetails = moduleDetails && moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined;
return (
<>
{projectId ? (
cycleDetails ? (
<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">
<h6 className="text-custom-text-200">Lead</h6>
<span>{cycleOwnerDetails?.display_name}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6>
<span>
{cycleDetails.start_date && cycleDetails.start_date !== ""
? renderFormattedDate(cycleDetails.start_date)
: "No start date"}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Target Date</h6>
<span>
{cycleDetails.end_date && cycleDetails.end_date !== ""
? renderFormattedDate(cycleDetails.end_date)
: "No end date"}
</span>
</div>
</div>
</div>
) : moduleDetails ? (
<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">
<h6 className="text-custom-text-200">Lead</h6>
{moduleLeadDetails && <span>{moduleLeadDetails?.display_name}</span>}
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6>
<span>
{moduleDetails.start_date && moduleDetails.start_date !== ""
? renderFormattedDate(moduleDetails.start_date)
: "No start date"}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Target Date</h6>
<span>
{moduleDetails.target_date && moduleDetails.target_date !== ""
? renderFormattedDate(moduleDetails.target_date)
: "No end date"}
</span>
</div>
</div>
</div>
) : (
<div className="h-full overflow-y-auto">
<div className="flex items-center gap-1">
{projectDetails && (
<span className="h-6 w-6 grid place-items-center flex-shrink-0">
<Logo logo={projectDetails.logo_props} />
</span>
)}
<h4 className="break-words font-medium">{projectDetails?.name}</h4>
</div>
<div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Network</h6>
<span>{t(NETWORK_CHOICES.find((n) => n.key === projectDetails?.network)?.i18n_label ?? "")}</span>
</div>
</div>
</div>
)
) : null}
</>
);
});

View file

@ -1,212 +0,0 @@
"use client";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR, { mutate } from "swr";
// icons
import { CalendarDays, Download, RefreshCw } from "lucide-react";
// types
import { useTranslation } from "@plane/i18n";
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types";
// ui
import { Button, LayersIcon, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { CustomAnalyticsSidebarHeader, CustomAnalyticsSidebarProjectsList } from "@/components/analytics";
// constants
import { ANALYTICS } from "@/constants/fetch-keys";
// helpers
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useCycle, useModule, useProject, useWorkspace, useUser } from "@/hooks/store";
// services
import { AnalyticsService } from "@/services/analytics.service";
type Props = {
analytics: IAnalyticsResponse | undefined;
params: IAnalyticsParams;
isProjectLevel: boolean;
};
const analyticsService = new AnalyticsService();
const PROJECT_ANALYTICS_COUNT_PARAMS = {
fields: "total_members,total_cycles,total_modules",
};
export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
const { analytics, params, isProjectLevel = false } = props;
// router
const { workspaceSlug, projectId, cycleId, moduleId } = useParams();
// store hooks
const { data: currentUser } = useUser();
const { workspaceProjectIds, getProjectById, fetchProjectAnalyticsCount } = useProject();
const { getWorkspaceById } = useWorkspace();
const { t } = useTranslation();
const { fetchCycleDetails, getCycleById } = useCycle();
const { fetchModuleDetails, getModuleById } = useModule();
// fetch project analytics count
const { isLoading: isProjectAnalyticsLoading, isValidating: isProjectAnalyticsUpdating } = useSWR(
workspaceSlug ? ["projectAnalyticsCount", workspaceSlug] : null,
workspaceSlug ? () => fetchProjectAnalyticsCount(workspaceSlug.toString(), PROJECT_ANALYTICS_COUNT_PARAMS) : null
);
const projectDetails = projectId ? (getProjectById(projectId.toString()) ?? undefined) : undefined;
const trackExportAnalytics = () => {
if (!currentUser) return;
const eventPayload: any = {
workspaceSlug: workspaceSlug?.toString(),
params: {
x_axis: params.x_axis,
y_axis: params.y_axis,
group: params.segment,
project: params.project,
},
};
if (projectDetails) {
const workspaceDetails = projectDetails.workspace as IWorkspace;
eventPayload.workspaceId = workspaceDetails.id;
eventPayload.workspaceName = workspaceDetails.name;
eventPayload.projectId = projectDetails.id;
eventPayload.projectIdentifier = projectDetails.identifier;
eventPayload.projectName = projectDetails.name;
}
if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails;
const currentProjectDetails = getProjectById(details?.project_id || "");
const currentWorkspaceDetails = getWorkspaceById(details?.workspace_id || "");
eventPayload.workspaceId = details?.workspace_id;
eventPayload.workspaceName = currentWorkspaceDetails?.name;
eventPayload.projectId = details?.project_id;
eventPayload.projectIdentifier = currentProjectDetails?.identifier;
eventPayload.projectName = currentProjectDetails?.name;
}
if (cycleDetails) {
eventPayload.cycleId = cycleDetails.id;
eventPayload.cycleName = cycleDetails.name;
}
if (moduleDetails) {
eventPayload.moduleId = moduleDetails.id;
eventPayload.moduleName = moduleDetails.name;
}
};
const exportAnalytics = () => {
if (!workspaceSlug) return;
const data: IExportAnalyticsFormData = {
x_axis: params.x_axis,
y_axis: params.y_axis,
};
if (params.segment) data.segment = params.segment;
if (params.project) data.project = params.project;
analyticsService
.exportAnalytics(workspaceSlug.toString(), data)
.then((res) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: res.message,
});
trackExportAnalytics();
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "There was some error in exporting the analytics. Please try again.",
})
);
};
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
// fetch cycle details
useEffect(() => {
if (!workspaceSlug || !projectId || !cycleId || cycleDetails) return;
fetchCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString());
}, [cycleId, cycleDetails, fetchCycleDetails, projectId, workspaceSlug]);
// fetch module details
useEffect(() => {
if (!workspaceSlug || !projectId || !moduleId || moduleDetails) return;
fetchModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString());
}, [moduleId, moduleDetails, fetchModuleDetails, projectId, workspaceSlug]);
const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds;
return (
<div
className={cn(
"relative flex h-full w-full items-start justify-between gap-2 bg-custom-sidebar-background-100 px-5 py-4",
!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 : "..."}
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>{t("work_items")}</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">
<CalendarDays className="h-3.5 w-3.5" />
{renderFormattedDate(
(cycleId
? cycleDetails?.created_at
: moduleId
? moduleDetails?.created_at
: projectDetails?.created_at) ?? ""
)}
</div>
)}
</div>
<div className={cn("h-full w-full overflow-hidden", isProjectLevel ? "hidden" : "block")}>
<>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<CustomAnalyticsSidebarProjectsList
projectIds={selectedProjects}
isLoading={isProjectAnalyticsLoading}
isUpdating={isProjectAnalyticsUpdating}
/>
)}
<CustomAnalyticsSidebarHeader />
</>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="neutral-primary"
prependIcon={<RefreshCw className="h-3 w-3 md:h-3.5 md:w-3.5" />}
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
<div className={cn(isProjectLevel ? "hidden md:block" : "", "capitalize")}>{t("refresh")}</div>
</Button>
<Button variant="primary" prependIcon={<Download className="h-3.5 w-3.5" />} onClick={exportAnalytics}>
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>{t("exporter.csv.short_description")}</div>
</Button>
</div>
</div>
);
});

View file

@ -1,106 +0,0 @@
"use client";
import { BarDatum } from "@nivo/bar";
// plane package imports
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "@plane/constants";
import { IAnalyticsParams, IAnalyticsResponse, TIssuePriorities } from "@plane/types";
import { PriorityIcon, Tooltip } from "@plane/ui";
// helpers
import { generateBarColor, generateDisplayName, renderChartDynamicLabel } from "@/helpers/analytics.helper";
import { cn } from "@/helpers/common.helper";
type Props = {
analytics: IAnalyticsResponse;
barGraphData: {
data: BarDatum[];
xAxisKeys: string[];
};
params: IAnalyticsParams;
yAxisKey: "count" | "estimate";
};
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => (
<div className="w-full overflow-hidden overflow-x-auto">
<table className="w-full overflow-hidden divide-y divide-custom-border-200 whitespace-nowrap border-y border-custom-border-200">
<thead className="bg-custom-background-80">
<tr className="divide-x divide-custom-border-200 text-sm text-custom-text-100">
<th scope="col" className="px-page-x py-3 text-left font-medium">
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === params.x_axis)?.label}
</th>
{params.segment ? (
barGraphData.xAxisKeys.map((key) => (
<th
key={`segment-${key}`}
scope="col"
className={`px-page-x py-3 text-left font-medium ${
params.segment === "priority" || params.segment === "state__group" ? "capitalize" : ""
}`}
>
<div className="flex items-center gap-2">
{params.segment === "priority" ? (
<PriorityIcon priority={key as TIssuePriorities} />
) : (
<span
className="h-3 w-3 flex-shrink-0 rounded"
style={{
backgroundColor: generateBarColor(key, analytics, params, "segment"),
}}
/>
)}
{renderChartDynamicLabel(generateDisplayName(key, analytics, params, "segment"))?.label}
</div>
</th>
))
) : (
<th scope="col" className="px-page-x py-3 text-left font-medium sm:pr-0">
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === params.y_axis)?.label}
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-custom-border-200">
{barGraphData.data.map((item, index) => (
<tr key={`table-row-${index}`} className="divide-x divide-custom-border-200 text-xs text-custom-text-200">
<td className="px-page-x py-2">
<div className="relative flex items-center gap-2 w-full overflow-hidden">
<div className="flex-shrink-0 h-3 w-3 rounded overflow-hidden">
{params.x_axis === "priority" ? (
<PriorityIcon size={12} priority={(item.name as string).toLowerCase() as TIssuePriorities} />
) : (
<div
className="w-full h-full"
style={{
backgroundColor: generateBarColor(`${item.name}`, analytics, params, "x_axis"),
}}
/>
)}
</div>
<div
className={cn(
"font-medium",
["priority", "state__group"].includes(params.x_axis) ? `capitalize` : ``
)}
>
<Tooltip tooltipContent={generateDisplayName(`${item.name}`, analytics, params, "x_axis")}>
<div className="overflow-hidden w-full whitespace-normal break-words truncate line-clamp-1">
{generateDisplayName(`${item.name}`, analytics, params, "x_axis")}
</div>
</Tooltip>
</div>
</div>
</td>
{params.segment ? (
barGraphData.xAxisKeys.map((key, index) => (
<td key={`segment-value-${index}`} className="whitespace-nowrap px-page-x py-2 sm:pr-0">
{item[key] ?? 0}
</td>
))
) : (
<td className="whitespace-nowrap px-page-x py-2 sm:pr-0">{item[yAxisKey]}</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);

View file

@ -11,8 +11,8 @@ type Props = {
className?: string;
};
const AnalyticsV2EmptyState = ({ title, description, assetPath, className }: Props) => {
const backgroundReolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-grid-background" });
const AnalyticsEmptyState = ({ title, description, assetPath, className }: Props) => {
const backgroundReolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-grid-background" });
return (
<div
@ -45,4 +45,4 @@ const AnalyticsV2EmptyState = ({ title, description, assetPath, className }: Pro
</div>
);
};
export default AnalyticsV2EmptyState;
export default AnalyticsEmptyState;

View file

@ -1,3 +1 @@
export * from "./custom-analytics";
export * from "./scope-and-demand";
export * from "./project-modal";
export * from "./overview/root";

View file

@ -1,12 +1,12 @@
// plane package imports
import React, { useMemo } from "react";
import { IAnalyticsResponseFieldsV2 } from "@plane/types";
import { IAnalyticsResponseFields } from "@plane/types";
import { Loader } from "@plane/ui";
// components
import TrendPiece from "./trend-piece";
export type InsightCardProps = {
data?: IAnalyticsResponseFieldsV2;
data?: IAnalyticsResponseFields;
label: string;
isLoading?: boolean;
versus?: string | null;

View file

@ -23,7 +23,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { cn } from "@plane/utils";
// plane web components
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import AnalyticsV2EmptyState from "../empty-state";
import AnalyticsEmptyState from "../empty-state";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@ -40,7 +40,7 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
const { t } = useTranslation();
const inputRef = React.useRef<HTMLInputElement>(null);
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-table" });
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-table" });
const table = useReactTable({
data,
@ -155,9 +155,9 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
<TableRow>
<TableCell colSpan={columns.length} className="p-0">
<div className="flex h-[350px] w-full items-center justify-center border border-custom-border-100 ">
<AnalyticsV2EmptyState
title={t("workspace_analytics.empty_state_v2.customized_insights.title")}
description={t("workspace_analytics.empty_state_v2.customized_insights.description")}
<AnalyticsEmptyState
title={t("workspace_analytics.empty_state.customized_insights.title")}
description={t("workspace_analytics.empty_state.customized_insights.description")}
className="border-0"
assetPath={resolvedPath}
/>

View file

@ -0,0 +1,49 @@
import { ColumnDef, Row, Table } from "@tanstack/react-table";
import { Download } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { AnalyticsTableDataMap, TAnalyticsTabsBase } from "@plane/types";
import { Button } from "@plane/ui";
import { DataTable } from "./data-table";
import { TableLoader } from "./loader";
interface InsightTableProps<T extends Exclude<TAnalyticsTabsBase, "overview">> {
analyticsType: T;
data?: AnalyticsTableDataMap[T][];
isLoading?: boolean;
columns: ColumnDef<AnalyticsTableDataMap[T]>[];
columnsLabels?: Record<string, string>;
headerText: string;
onExport?: (rows: Row<AnalyticsTableDataMap[T]>[]) => void;
}
export const InsightTable = <T extends Exclude<TAnalyticsTabsBase, "overview">>(
props: InsightTableProps<T>
): React.ReactElement => {
const { data, isLoading, columns, headerText, onExport } = props;
const { t } = useTranslation();
if (isLoading) {
return <TableLoader columns={columns} rows={5} />;
}
return (
<div className="">
{data ? (
<DataTable
columns={columns}
data={data}
searchPlaceholder={`${data.length} ${headerText}`}
actions={(table: Table<AnalyticsTableDataMap[T]>) => (
<Button
variant="accent-primary"
prependIcon={<Download className="h-3.5 w-3.5" />}
onClick={() => onExport?.(table.getFilteredRowModel().rows)}
>
<div>{t("exporter.csv.short_description")}</div>
</Button>
)}
/>
) : (
<div>{t("common.no_data_yet")}</div>
)}
</div>
);
};

View file

@ -1,107 +0,0 @@
"use client";
import React, { Fragment } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { Tab } from "@headlessui/react";
// plane package imports
import { ANALYTICS_TABS, EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Header, EHeaderVariant } from "@plane/ui";
// components
import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics";
import { PageHead } from "@/components/core";
import { ComicBoxButton, DetailedEmptyState } from "@/components/empty-state";
// hooks
import { useCommandPalette, useEventTracker, useProject, useUserPermissions, useWorkspace } from "@/hooks/store";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
const OldAnalyticsPage = observer(() => {
const searchParams = useSearchParams();
const analytics_tab = searchParams.get("analytics_tab");
// plane imports
const { t } = useTranslation();
// store hooks
const { toggleCreateProjectModal } = useCommandPalette();
const { setTrackElement } = useEventTracker();
const { workspaceProjectIds, loader } = useProject();
const { currentWorkspace } = useWorkspace();
const { allowPermissions } = useUserPermissions();
// helper hooks
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/analytics" });
// derived values
const pageTitle = currentWorkspace?.name
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
: undefined;
// permissions
const canPerformEmptyStateActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
// TODO: refactor loader implementation
return (
<>
<PageHead title={pageTitle} />
{workspaceProjectIds && (
<>
{workspaceProjectIds.length > 0 || loader === "init-loader" ? (
<div className="flex h-full flex-col overflow-hidden bg-custom-background-100">
<Tab.Group as={Fragment} defaultIndex={analytics_tab === "custom" ? 1 : 0}>
<Header variant={EHeaderVariant.SECONDARY}>
<Tab.List as="div" className="flex space-x-2 h-full">
{ANALYTICS_TABS.map((tab) => (
<Tab key={tab.key} as={Fragment}>
{({ selected }) => (
<button
className={`text-sm group relative flex items-center gap-1 h-full px-3 cursor-pointer transition-all font-medium outline-none ${
selected ? "text-custom-primary-100 " : "hover:text-custom-text-200"
}`}
>
{t(tab.i18n_title)}
<div
className={`border absolute bottom-0 right-0 left-0 rounded-t-md ${selected ? "border-custom-primary-100" : "border-transparent group-hover:border-custom-border-200"}`}
/>
</button>
)}
</Tab>
))}
</Tab.List>
</Header>
<Tab.Panels as={Fragment}>
<Tab.Panel as={Fragment}>
<ScopeAndDemand fullScreen />
</Tab.Panel>
<Tab.Panel as={Fragment}>
<CustomAnalytics fullScreen />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
) : (
<DetailedEmptyState
title={t("workspace_analytics.empty_state.general.title")}
description={t("workspace_analytics.empty_state.general.description")}
assetPath={resolvedPath}
customPrimaryButton={
<ComicBoxButton
label={t("workspace_analytics.empty_state.general.primary_button.text")}
title={t("workspace_analytics.empty_state.general.primary_button.comic.title")}
description={t("workspace_analytics.empty_state.general.primary_button.comic.description")}
onClick={() => {
setTrackElement("Analytics empty state");
toggleCreateProjectModal(true);
}}
disabled={!canPerformEmptyStateActions}
/>
}
/>
)}
</>
)}
</>
);
});
export default OldAnalyticsPage;

View file

@ -6,7 +6,7 @@ import useSWR from "swr";
import { useTranslation } from "@plane/i18n";
import { Loader } from "@plane/ui";
// plane web hooks
import { useAnalyticsV2, useProject } from "@/hooks/store";
import { useAnalytics, useProject } from "@/hooks/store";
// plane web components
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
import ActiveProjectItem from "./active-project-item";
@ -15,7 +15,7 @@ const ActiveProjects = observer(() => {
const { t } = useTranslation();
const { fetchProjectAnalyticsCount } = useProject();
const { workspaceSlug } = useParams();
const { selectedDurationLabel } = useAnalyticsV2();
const { selectedDurationLabel } = useAnalytics();
const { data: projectAnalyticsCount, isLoading: isProjectAnalyticsCountLoading } = useSWR(
workspaceSlug ? ["projectAnalyticsCount", workspaceSlug] : null,
workspaceSlug

View file

@ -6,13 +6,13 @@ import useSWR from "swr";
import { useTranslation } from "@plane/i18n";
import { TChartData } from "@plane/types";
// hooks
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
import { useAnalytics } from "@/hooks/store/use-analytics";
// services
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
import { AnalyticsService } from "@/services/analytics.service";
// plane web components
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
import AnalyticsV2EmptyState from "../empty-state";
import AnalyticsEmptyState from "../empty-state";
import { ProjectInsightsLoader } from "../loaders";
const RadarChart = dynamic(() =>
@ -21,25 +21,28 @@ const RadarChart = dynamic(() =>
}))
);
const analyticsV2Service = new AnalyticsV2Service();
const analyticsService = new AnalyticsService();
const ProjectInsights = observer(() => {
const params = useParams();
const { t } = useTranslation();
const workspaceSlug = params.workspaceSlug.toString();
const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } =
useAnalyticsV2();
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-radar" });
useAnalytics();
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-radar" });
const { data: projectInsightsData, isLoading: isLoadingProjectInsight } = useSWR(
`radar-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
() =>
analyticsV2Service.getAdvanceAnalyticsCharts<TChartData<string, string>[]>(workspaceSlug, "projects", {
// date_filter: selectedDuration,
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
...(selectedModule ? { module_id: selectedModule } : {}),
},
analyticsService.getAdvanceAnalyticsCharts<TChartData<string, string>[]>(
workspaceSlug,
"projects",
{
// date_filter: selectedDuration,
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
...(selectedModule ? { module_id: selectedModule } : {}),
},
isPeekView
)
);
@ -53,9 +56,9 @@ const ProjectInsights = observer(() => {
{isLoadingProjectInsight ? (
<ProjectInsightsLoader />
) : projectInsightsData && projectInsightsData?.length == 0 ? (
<AnalyticsV2EmptyState
title={t("workspace_analytics.empty_state_v2.project_insights.title")}
description={t("workspace_analytics.empty_state_v2.project_insights.description")}
<AnalyticsEmptyState
title={t("workspace_analytics.empty_state.project_insights.title")}
description={t("workspace_analytics.empty_state.project_insights.description")}
className="h-[300px]"
assetPath={resolvedPath}
/>

View file

@ -1,37 +0,0 @@
import { observer } from "mobx-react";
// icons
import { Expand, Shrink, X } from "lucide-react";
type Props = {
fullScreen: boolean;
handleClose: () => void;
setFullScreen: React.Dispatch<React.SetStateAction<boolean>>;
title: string;
};
export const ProjectAnalyticsModalHeader: React.FC<Props> = observer((props) => {
const { fullScreen, handleClose, setFullScreen, title } = props;
return (
<div className="flex items-center justify-between gap-4 bg-custom-background-100 px-5 py-4 text-sm">
<h3 className="break-words">Analytics for {title}</h3>
<div className="flex items-center gap-2">
<button
type="button"
className="hidden md:grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
onClick={() => setFullScreen((prevData) => !prevData)}
>
{fullScreen ? <Shrink size={14} strokeWidth={2} /> : <Expand size={14} strokeWidth={2} />}
</button>
<button
type="button"
className="grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
onClick={handleClose}
>
<X size={14} strokeWidth={2} />
</button>
</div>
</div>
);
});

View file

@ -1,3 +0,0 @@
export * from "./header";
export * from "./main-content";
export * from "./modal";

View file

@ -1,57 +0,0 @@
import React, { Fragment } from "react";
import { observer } from "mobx-react";
import { Tab } from "@headlessui/react";
// plane package imports
import { ANALYTICS_TABS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ICycle, IModule, IProject } from "@plane/types";
// components
import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics";
type Props = {
fullScreen: boolean;
cycleDetails: ICycle | undefined;
moduleDetails: IModule | undefined;
projectDetails: IProject | undefined;
};
export const ProjectAnalyticsModalMainContent: React.FC<Props> = observer((props) => {
const { fullScreen, cycleDetails, moduleDetails } = props;
const { t } = useTranslation();
return (
<Tab.Group as={React.Fragment}>
<Tab.List as="div" className="flex space-x-2 border-b h-[50px] border-custom-border-200 px-0 md:px-3">
{ANALYTICS_TABS.map((tab) => (
<Tab key={tab.key} as={Fragment}>
{({ selected }) => (
<button
className={`text-sm group relative flex items-center gap-1 h-[50px] px-3 cursor-pointer transition-all font-medium outline-none ${
selected ? "text-custom-primary-100 " : "hover:text-custom-text-200"
}`}
>
{t(tab.i18n_title)}
<div
className={`border absolute bottom-0 right-0 left-0 rounded-t-md ${selected ? "border-custom-primary-100" : "border-transparent group-hover:border-custom-border-200"}`}
/>
</button>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels as={React.Fragment}>
<Tab.Panel as={React.Fragment}>
<ScopeAndDemand fullScreen={fullScreen} />
</Tab.Panel>
<Tab.Panel as={React.Fragment}>
<CustomAnalytics
additionalParams={{
cycle: cycleDetails?.id,
module: moduleDetails?.id,
}}
fullScreen={fullScreen}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);
});

View file

@ -1,71 +0,0 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import { Dialog, Transition } from "@headlessui/react";
import { ICycle, IModule, IProject } from "@plane/types";
// components
import { ProjectAnalyticsModalHeader, ProjectAnalyticsModalMainContent } from "@/components/analytics";
// types
type Props = {
isOpen: boolean;
onClose: () => void;
cycleDetails?: ICycle | undefined;
moduleDetails?: IModule | undefined;
projectDetails?: IProject | undefined;
};
export const ProjectAnalyticsModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose, cycleDetails, moduleDetails, projectDetails } = props;
const [fullScreen, setFullScreen] = useState(false);
const handleClose = () => {
onClose();
};
return (
<Transition.Root appear show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="transition-transform duration-300"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition-transform duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
<Dialog.Panel>
<div
className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${
fullScreen ? "w-full p-2" : "w-full sm:w-full md:w-1/2"
}`}
>
<div
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
fullScreen ? "rounded-lg border" : "border-l"
}`}
>
<ProjectAnalyticsModalHeader
fullScreen={fullScreen}
handleClose={handleClose}
setFullScreen={setFullScreen}
title={cycleDetails?.name ?? moduleDetails?.name ?? projectDetails?.name ?? ""}
/>
<ProjectAnalyticsModalMainContent
fullScreen={fullScreen}
cycleDetails={cycleDetails}
moduleDetails={moduleDetails}
projectDetails={projectDetails}
/>
</div>
</div>
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition.Root>
);
});

View file

@ -1,58 +0,0 @@
// plane imports
import { STATE_GROUPS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// types
import { IDefaultAnalyticsResponse, TStateGroups } from "@plane/types";
// constants
import { Card } from "@plane/ui";
type Props = {
defaultAnalytics: IDefaultAnalyticsResponse;
};
export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => {
const { t } = useTranslation();
return (
<Card>
<div>
<h4 className="text-base font-medium text-custom-text-100">{t("workspace_analytics.open_tasks")}</h4>
<h3 className="mt-1 text-xl font-semibold">{defaultAnalytics.open_issues}</h3>
</div>
<div className="space-y-6 pb-2">
{defaultAnalytics?.open_issues_classified.map((group) => {
const percentage = ((group.state_count / defaultAnalytics.total_issues) * 100).toFixed(0);
return (
<div key={group.state_group} className="space-y-2">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-1">
<span
className="h-2 w-2 rounded-full"
style={{
backgroundColor: STATE_GROUPS[group.state_group as TStateGroups].color,
}}
/>
<h6 className="capitalize">{group.state_group}</h6>
<span className="ml-1 rounded-3xl bg-custom-background-80 px-2 py-0.5 text-[0.65rem] text-custom-text-200">
{group.state_count}
</span>
</div>
<p className="text-custom-text-200">{percentage}%</p>
</div>
<div className="bar relative h-1 w-full rounded bg-custom-background-80">
<div
className="absolute left-0 top-0 h-1 rounded duration-300"
style={{
width: `${percentage}%`,
backgroundColor: STATE_GROUPS[group.state_group as TStateGroups].color,
}}
/>
</div>
</div>
);
})}
</div>
</Card>
);
};

View file

@ -1,5 +0,0 @@
export * from "./demand";
export * from "./leaderboard";
export * from "./scope-and-demand";
export * from "./scope";
export * from "./year-wise-issues";

View file

@ -1,69 +0,0 @@
// plane ui
import { useTranslation } from "@plane/i18n";
import { Card } from "@plane/ui";
// components
import { ProfileEmptyState } from "@/components/ui";
// helpers
import { getFileURL } from "@/helpers/file.helper";
// image
import emptyUsers from "@/public/empty-state/empty_users.svg";
type Props = {
users: {
avatar_url: string | null;
display_name: string | null;
firstName: string;
lastName: string;
count: number;
id: string;
}[];
title: string;
emptyStateMessage: string;
workspaceSlug: string;
};
export const AnalyticsLeaderBoard: React.FC<Props> = ({ users, title, emptyStateMessage, workspaceSlug }) => {
const { t } = useTranslation();
return (
<Card>
<h6 className="text-base font-medium">{title}</h6>
{users.length > 0 ? (
<div className="mt-3 space-y-3">
{users.map((user) => (
<a
key={user?.display_name ?? "None"}
href={`/${workspaceSlug}/profile/${user.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-start justify-between gap-4 text-xs"
>
<div className="flex items-center gap-2">
{user.avatar_url && user.avatar_url !== "" ? (
<div className="relative h-4 w-4 flex-shrink-0 rounded-full">
<img
src={getFileURL(user.avatar_url)}
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
alt={user?.display_name ?? "None"}
/>
</div>
) : (
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 text-[11px] capitalize text-white">
{user?.display_name !== "" ? user?.display_name?.[0] : "?"}
</div>
)}
<span className="break-words text-custom-text-200">
{user?.display_name !== "" ? `${user?.display_name}` : "No assignee"}
</span>
</div>
<span className="flex-shrink-0">{user.count}</span>
</a>
))}
</div>
) : (
<div className="px-7 py-4">
<ProfileEmptyState title={t("no_data_yet")} description={emptyStateMessage} image={emptyUsers} />
</div>
)}
</Card>
);
};

View file

@ -1,115 +0,0 @@
"use client";
import { useParams } from "next/navigation";
import useSWR from "swr";
// ui
import { useTranslation } from "@plane/i18n";
import { Button, ContentWrapper, Loader } from "@plane/ui";
// components
import { AnalyticsDemand, AnalyticsLeaderBoard, AnalyticsScope, AnalyticsYearWiseIssues } from "@/components/analytics";
// fetch-keys
import { DEFAULT_ANALYTICS } from "@/constants/fetch-keys";
// services
import { AnalyticsService } from "@/services/analytics.service";
type Props = {
fullScreen?: boolean;
};
// services
const analyticsService = new AnalyticsService();
export const ScopeAndDemand: React.FC<Props> = (props) => {
const { fullScreen = true } = props;
const { workspaceSlug, projectId, cycleId, moduleId } = useParams();
const { t } = useTranslation();
const isProjectLevel = projectId ? true : false;
const params = isProjectLevel
? {
project: projectId ? [projectId.toString()] : null,
cycle: cycleId ? cycleId.toString() : null,
module: moduleId ? moduleId.toString() : null,
}
: undefined;
const {
data: defaultAnalytics,
error: defaultAnalyticsError,
mutate: mutateDefaultAnalytics,
} = useSWR(
workspaceSlug ? DEFAULT_ANALYTICS(workspaceSlug.toString(), params) : null,
workspaceSlug ? () => analyticsService.getDefaultAnalytics(workspaceSlug.toString(), params) : null
);
// scope data
const pendingIssues = defaultAnalytics?.pending_issue_user ?? [];
const pendingUnAssignedIssuesUser = pendingIssues?.find((issue) => issue.assignees__id === null);
const pendingAssignedIssues = pendingIssues?.filter((issue) => issue.assignees__id !== null);
return (
<>
{!defaultAnalyticsError ? (
defaultAnalytics ? (
<ContentWrapper>
<div className={`grid grid-cols-1 gap-5 ${fullScreen ? "md:grid-cols-2" : ""}`}>
<AnalyticsDemand defaultAnalytics={defaultAnalytics} />
<AnalyticsScope
pendingUnAssignedIssuesUser={pendingUnAssignedIssuesUser}
pendingAssignedIssues={pendingAssignedIssues}
/>
<AnalyticsLeaderBoard
users={defaultAnalytics.most_issue_created_user?.map((user) => ({
avatar_url: user?.created_by__avatar_url,
firstName: user?.created_by__first_name,
lastName: user?.created_by__last_name,
display_name: user?.created_by__display_name,
count: user?.count,
id: user?.created_by__id,
}))}
title={t("workspace_analytics.most_work_items_created.title")}
emptyStateMessage={t("workspace_analytics.most_work_items_created.empty_state")}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
<AnalyticsLeaderBoard
users={defaultAnalytics.most_issue_closed_user?.map((user) => ({
avatar_url: user?.assignees__avatar_url,
firstName: user?.assignees__first_name,
lastName: user?.assignees__last_name,
display_name: user?.assignees__display_name,
count: user?.count,
id: user?.assignees__id,
}))}
title={t("workspace_analytics.most_work_items_closed.title")}
emptyStateMessage={t("workspace_analytics.most_work_items_closed.empty_state")}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
<div className={fullScreen ? "md:col-span-2" : ""}>
<AnalyticsYearWiseIssues defaultAnalytics={defaultAnalytics} />
</div>
</div>
</ContentWrapper>
) : (
<Loader className="grid grid-cols-1 gap-5 p-5 lg:grid-cols-2">
<Loader.Item height="250px" />
<Loader.Item height="250px" />
<Loader.Item height="250px" />
<Loader.Item height="250px" />
</Loader>
)
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-custom-text-200">
<p className="text-sm">{t("workspace_analytics.error")}</p>
<div className="flex items-center justify-center gap-2">
<Button variant="primary" onClick={() => mutateDefaultAnalytics()}>
{t("refresh")}
</Button>
</div>
</div>
</div>
)}
</>
);
};

View file

@ -1,99 +0,0 @@
// plane types
import { useTranslation } from "@plane/i18n";
import { IDefaultAnalyticsUser } from "@plane/types";
// plane ui
import { Card } from "@plane/ui";
// components
import { BarGraph, ProfileEmptyState } from "@/components/ui";
// helpers
import { getFileURL } from "@/helpers/file.helper";
// image
import emptyBarGraph from "@/public/empty-state/empty_bar_graph.svg";
type Props = {
pendingUnAssignedIssuesUser: IDefaultAnalyticsUser | undefined;
pendingAssignedIssues: IDefaultAnalyticsUser[];
};
export const AnalyticsScope: React.FC<Props> = ({ pendingUnAssignedIssuesUser, pendingAssignedIssues }) => {
const { t } = useTranslation();
return (
<Card>
<div className="divide-y divide-custom-border-200">
<div>
<div className="flex items-center justify-between">
<h6 className="text-base font-medium">{t("workspace_analytics.pending_work_items.title")}</h6>
{pendingUnAssignedIssuesUser && (
<div className="relative flex items-center py-1 px-3 rounded-md gap-2 text-xs text-custom-primary-100 bg-custom-primary-100/10">
{t("unassigned")}: {pendingUnAssignedIssuesUser.count}
</div>
)}
</div>
{pendingAssignedIssues && pendingAssignedIssues.length > 0 ? (
<BarGraph
data={pendingAssignedIssues}
indexBy="assignees__id"
keys={["count"]}
height="250px"
colors={() => `#f97316`}
customYAxisTickValues={pendingAssignedIssues.map((d) => (d.count > 0 ? d.count : 50))}
tooltip={(datum) => {
const assignee = pendingAssignedIssues.find((a) => a.assignees__id === `${datum.indexValue}`);
return (
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
<span className="font-medium text-custom-text-200">
{assignee ? assignee.assignees__display_name : "No assignee"}:{" "}
</span>
{datum.value}
</div>
);
}}
axisBottom={{
renderTick: (datum) => {
const assignee = pendingAssignedIssues[datum.tickIndex] ?? "";
if (assignee && assignee?.assignees__avatar_url && assignee?.assignees__avatar_url !== "")
return (
<g transform={`translate(${datum.x},${datum.y})`}>
<image
x={-8}
y={10}
width={16}
height={16}
xlinkHref={getFileURL(assignee?.assignees__avatar_url)}
style={{ clipPath: "circle(50%)" }}
/>
</g>
);
else
return (
<g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{datum.value ? `${assignee.assignees__display_name}`.toUpperCase()[0] : "?"}
</text>
</g>
);
},
}}
margin={{ top: 20 }}
theme={{
axis: {},
}}
/>
) : (
<div className="px-7 py-4">
<ProfileEmptyState
title={t("no_data_yet")}
description={t("workspace_analytics.pending_work_items.empty_state")}
image={emptyBarGraph}
/>
</div>
)}
</div>
</div>
</Card>
);
};

View file

@ -1,64 +0,0 @@
// ui
import { useTranslation } from "@plane/i18n";
import { IDefaultAnalyticsResponse } from "@plane/types";
import { Card } from "@plane/ui";
import { LineGraph, ProfileEmptyState } from "@/components/ui";
// image
import { MONTHS_LIST } from "@/constants/calendar";
import emptyGraph from "@/public/empty-state/empty_graph.svg";
// types
// constants
type Props = {
defaultAnalytics: IDefaultAnalyticsResponse;
};
export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) => {
const { t } = useTranslation();
return (
<Card>
<h1 className="py-3 text-base font-medium">{t("workspace_analytics.work_items_closed_in_a_year.title")}</h1>
{defaultAnalytics.issue_completed_month_wise.length > 0 ? (
<LineGraph
data={[
{
id: "issues_closed",
color: "rgb(var(--color-primary-100))",
data: Object.entries(MONTHS_LIST).map(([index, month]) => ({
x: t(month.shortTitle),
y:
defaultAnalytics.issue_completed_month_wise.find((data) => data.month === parseInt(index, 10))
?.count || 0,
})),
},
]}
customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map((data) => data.count)}
height="300px"
colors={(datum) => datum.color}
curve="monotoneX"
margin={{ top: 20 }}
enableSlices="x"
sliceTooltip={(datum) => (
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
{datum.slice.points[0].data.yFormatted}
<span className="text-custom-text-200"> {t("workspace_analytics.work_items_closed_in")} </span>
{datum.slice.points[0].data.xFormatted}
</div>
)}
theme={{
background: "rgb(var(--color-background-100))",
}}
enableArea
/>
) : (
<div className="px-7 py-4">
<ProfileEmptyState
title={t("no_data_yet")}
description={t("workspace_analytics.work_items_closed_in_a_year.empty_state")}
image={emptyGraph}
/>
</div>
)}
</Card>
);
};

View file

@ -3,31 +3,30 @@ import { observer } from "mobx-react";
import { Control, Controller, UseFormSetValue } from "react-hook-form";
import { Calendar, SlidersHorizontal } from "lucide-react";
// plane package imports
import { ANALYTICS_V2_X_AXIS_VALUES, ANALYTICS_V2_Y_AXIS_VALUES, ChartYAxisMetric } from "@plane/constants";
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, ChartYAxisMetric } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IAnalyticsV2Params } from "@plane/types";
import { IAnalyticsParams } from "@plane/types";
import { cn } from "@plane/utils";
// plane web components
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
import { SelectXAxis } from "./select-x-axis";
import { SelectYAxis } from "./select-y-axis";
type Props = {
control: Control<IAnalyticsV2Params, unknown>;
setValue: UseFormSetValue<IAnalyticsV2Params>;
params: IAnalyticsV2Params;
control: Control<IAnalyticsParams, unknown>;
setValue: UseFormSetValue<IAnalyticsParams>;
params: IAnalyticsParams;
workspaceSlug: string;
classNames?: string;
};
export const AnalyticsV2SelectParams: React.FC<Props> = observer((props) => {
export const AnalyticsSelectParams: React.FC<Props> = observer((props) => {
const { control, params, classNames } = props;
const xAxisOptions = useMemo(
() => ANALYTICS_V2_X_AXIS_VALUES.filter((option) => option.value !== params.group_by),
() => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.group_by),
[params.group_by]
);
const groupByOptions = useMemo(
() => ANALYTICS_V2_X_AXIS_VALUES.filter((option) => option.value !== params.x_axis),
() => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.x_axis),
[params.x_axis]
);
@ -43,7 +42,7 @@ export const AnalyticsV2SelectParams: React.FC<Props> = observer((props) => {
onChange={(val: ChartYAxisMetric | null) => {
onChange(val);
}}
options={ANALYTICS_V2_Y_AXIS_VALUES}
options={ANALYTICS_Y_AXIS_VALUES}
hiddenOptions={[ChartYAxisMetric.ESTIMATE_POINT_COUNT]}
/>
)}

View file

@ -2,7 +2,7 @@
import React, { ReactNode } from "react";
import { Calendar } from "lucide-react";
// plane package imports
import { ANALYTICS_V2_DURATION_FILTER_OPTIONS } from "@plane/constants";
import { ANALYTICS_DURATION_FILTER_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { CustomSearchSelect } from "@plane/ui";
// types
@ -10,7 +10,7 @@ import { TDropdownProps } from "@/components/dropdowns/types";
type Props = TDropdownProps & {
value: string | null;
onChange: (val: (typeof ANALYTICS_V2_DURATION_FILTER_OPTIONS)[number]["value"]) => void;
onChange: (val: (typeof ANALYTICS_DURATION_FILTER_OPTIONS)[number]["value"]) => void;
//optional
button?: ReactNode;
dropdownArrow?: boolean;
@ -23,7 +23,7 @@ type Props = TDropdownProps & {
function DurationDropdown({ placeholder = "Duration", onChange, value }: Props) {
useTranslation();
const options = ANALYTICS_V2_DURATION_FILTER_OPTIONS.map((option) => ({
const options = ANALYTICS_DURATION_FILTER_OPTIONS.map((option) => ({
value: option.value,
query: option.name,
content: (
@ -40,7 +40,7 @@ function DurationDropdown({ placeholder = "Duration", onChange, value }: Props)
label={
<div className="flex items-center gap-2 p-1 ">
<Calendar className="h-4 w-4" />
{value ? ANALYTICS_V2_DURATION_FILTER_OPTIONS.find((opt) => opt.value === value)?.name : placeholder}
{value ? ANALYTICS_DURATION_FILTER_OPTIONS.find((opt) => opt.value === value)?.name : placeholder}
</div>
}
/>

View file

@ -0,0 +1,102 @@
// plane package imports
import { observer } from "mobx-react-lite";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { IInsightField, insightsFields } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IAnalyticsResponse, TAnalyticsTabsBase } from "@plane/types";
//hooks
import { cn } from "@/helpers/common.helper";
import { useAnalytics } from "@/hooks/store/use-analytics";
//services
import { AnalyticsService } from "@/services/analytics.service";
// plane web components
import InsightCard from "./insight-card";
const analyticsService = new AnalyticsService();
const getInsightLabel = (
analyticsType: TAnalyticsTabsBase,
item: IInsightField,
isEpic: boolean | undefined,
t: (key: string, options?: any) => string
) => {
if (analyticsType === "work-items") {
return isEpic
? t(item.i18nKey, { entity: t("common.epics") })
: t(item.i18nKey, { entity: t("common.work_items") });
}
// Get the base translation with entity
const baseTranslation = t(item.i18nKey, {
...item.i18nProps,
entity: item.i18nProps?.entity && t(item.i18nProps?.entity),
});
// Add prefix if available
const prefix = item.i18nProps?.prefix ? `${t(item.i18nProps.prefix)} ` : "";
// Add suffix if available
const suffix = item.i18nProps?.suffix ? ` ${t(item.i18nProps.suffix)}` : "";
// Combine prefix, base translation, and suffix
return `${prefix}${baseTranslation}${suffix}`;
};
const TotalInsights: React.FC<{
analyticsType: TAnalyticsTabsBase;
peekView?: boolean;
}> = observer(({ analyticsType, peekView }) => {
const params = useParams();
const workspaceSlug = params.workspaceSlug.toString();
const { t } = useTranslation();
const {
selectedDuration,
selectedProjects,
selectedDurationLabel,
selectedCycle,
selectedModule,
isPeekView,
isEpic,
} = useAnalytics();
const { data: totalInsightsData, isLoading } = useSWR(
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isEpic}`,
() =>
analyticsService.getAdvanceAnalytics<IAnalyticsResponse>(
workspaceSlug,
analyticsType,
{
// date_filter: selectedDuration,
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
...(selectedModule ? { module_id: selectedModule } : {}),
...(isEpic ? { epic: true } : {}),
},
isPeekView
)
);
return (
<div
className={cn(
"grid grid-cols-1 gap-8 sm:grid-cols-2 md:gap-10",
!peekView
? insightsFields[analyticsType]?.length % 5 === 0
? "gap-10 lg:grid-cols-5"
: "gap-8 lg:grid-cols-4"
: "grid-cols-2"
)}
>
{insightsFields[analyticsType]?.map((item) => (
<InsightCard
key={`${analyticsType}-${item.key}`}
isLoading={isLoading}
data={totalInsightsData?.[item.key]}
label={getInsightLabel(analyticsType, item, isEpic, t)}
versus={selectedDurationLabel}
/>
))}
</div>
);
});
export default TotalInsights;

View file

@ -5,35 +5,46 @@ import useSWR from "swr";
// plane package imports
import { useTranslation } from "@plane/i18n";
import { AreaChart } from "@plane/propel/charts/area-chart";
import { IChartResponseV2, TChartData } from "@plane/types";
import { IChartResponse, TChartData } from "@plane/types";
import { renderFormattedDate } from "@plane/utils";
// hooks
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
import { useAnalytics } from "@/hooks/store/use-analytics";
// services
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
import { AnalyticsService } from "@/services/analytics.service";
// plane web components
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
import AnalyticsV2EmptyState from "../empty-state";
import AnalyticsEmptyState from "../empty-state";
import { ChartLoader } from "../loaders";
const analyticsV2Service = new AnalyticsV2Service();
const analyticsService = new AnalyticsService();
const CreatedVsResolved = observer(() => {
const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } =
useAnalyticsV2();
const {
selectedDuration,
selectedDurationLabel,
selectedProjects,
selectedCycle,
selectedModule,
isPeekView,
isEpic,
} = useAnalytics();
const params = useParams();
const { t } = useTranslation();
const workspaceSlug = params.workspaceSlug.toString();
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-area" });
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-area" });
const { data: createdVsResolvedData, isLoading: isCreatedVsResolvedLoading } = useSWR(
`created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
`created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}-${isEpic}`,
() =>
analyticsV2Service.getAdvanceAnalyticsCharts<IChartResponseV2>(workspaceSlug, "work-items", {
// date_filter: selectedDuration,
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
...(selectedModule ? { module_id: selectedModule } : {}),
},
analyticsService.getAdvanceAnalyticsCharts<IChartResponse>(
workspaceSlug,
"work-items",
{
// date_filter: selectedDuration,
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
...(selectedModule ? { module_id: selectedModule } : {}),
...(isEpic ? { epic: true } : {}),
},
isPeekView
)
);
@ -89,11 +100,11 @@ const CreatedVsResolved = observer(() => {
areas={areas}
xAxis={{
key: "name",
label: "Date",
label: t("date"),
}}
yAxis={{
key: "count",
label: "Number of Issues",
label: t("no_of", { entity: t("work_items") }),
offset: -30,
dx: -22,
}}
@ -110,9 +121,9 @@ const CreatedVsResolved = observer(() => {
}}
/>
) : (
<AnalyticsV2EmptyState
title={t("workspace_analytics.empty_state_v2.created_vs_resolved.title")}
description={t("workspace_analytics.empty_state_v2.created_vs_resolved.description")}
<AnalyticsEmptyState
title={t("workspace_analytics.empty_state.created_vs_resolved.title")}
description={t("workspace_analytics.empty_state.created_vs_resolved.description")}
className="h-[350px]"
assetPath={resolvedPath}
/>

View file

@ -4,14 +4,14 @@ import { useForm } from "react-hook-form";
// plane package imports
import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IAnalyticsV2Params } from "@plane/types";
import { IAnalyticsParams } from "@plane/types";
import { cn } from "@plane/utils";
// plane web components
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
import { AnalyticsV2SelectParams } from "../select/analytics-params";
import { AnalyticsSelectParams } from "../select/analytics-params";
import PriorityChart from "./priority-chart";
const defaultValues: IAnalyticsV2Params = {
const defaultValues: IAnalyticsParams = {
x_axis: ChartXAxisProperty.PRIORITY,
y_axis: ChartYAxisMetric.WORK_ITEM_COUNT,
};
@ -19,7 +19,7 @@ const defaultValues: IAnalyticsV2Params = {
const CustomizedInsights = observer(({ peekView }: { peekView?: boolean }) => {
const { t } = useTranslation();
const { workspaceSlug } = useParams();
const { control, watch, setValue } = useForm<IAnalyticsV2Params>({
const { control, watch, setValue } = useForm<IAnalyticsParams>({
defaultValues: {
...defaultValues,
},
@ -37,7 +37,7 @@ const CustomizedInsights = observer(({ peekView }: { peekView?: boolean }) => {
className="col-span-1"
headerClassName={cn(peekView ? "flex-col items-start" : "")}
actions={
<AnalyticsV2SelectParams
<AnalyticsSelectParams
control={control}
setValue={setValue}
params={params}

View file

@ -5,7 +5,7 @@ import { Tab } from "@headlessui/react";
import { ICycle, IModule, IProject } from "@plane/types";
import { Spinner } from "@plane/ui";
// hooks
import { useAnalyticsV2 } from "@/hooks/store";
import { useAnalytics } from "@/hooks/store";
// plane web components
import TotalInsights from "../../total-insights";
import CreatedVsResolved from "../created-vs-resolved";
@ -21,7 +21,7 @@ type Props = {
export const WorkItemsModalMainContent: React.FC<Props> = observer((props) => {
const { projectDetails, cycleDetails, moduleDetails, fullScreen } = props;
const { updateSelectedProjects, updateSelectedCycle, updateSelectedModule, updateIsPeekView } = useAnalyticsV2();
const { updateSelectedProjects, updateSelectedCycle, updateSelectedModule, updateIsPeekView } = useAnalytics();
const [isModalConfigured, setIsModalConfigured] = useState(false);
useEffect(() => {

View file

@ -1,8 +1,9 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { Dialog, Transition } from "@headlessui/react";
// plane package imports
import { ICycle, IModule, IProject } from "@plane/types";
import { useAnalytics } from "@/hooks/store";
// plane web components
import { WorkItemsModalMainContent } from "./content";
import { WorkItemsModalHeader } from "./header";
@ -13,17 +14,22 @@ type Props = {
projectDetails?: IProject | undefined;
cycleDetails?: ICycle | undefined;
moduleDetails?: IModule | undefined;
isEpic?: boolean;
};
export const WorkItemsModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose, projectDetails, moduleDetails, cycleDetails } = props;
const { isOpen, onClose, projectDetails, moduleDetails, cycleDetails, isEpic } = props;
const { updateIsEpic } = useAnalytics();
const [fullScreen, setFullScreen] = useState(false);
const handleClose = () => {
onClose();
};
useEffect(() => {
updateIsEpic(isEpic ?? false);
}, [isEpic, updateIsEpic]);
return (
<Transition.Root appear show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>

View file

@ -8,8 +8,8 @@ import useSWR from "swr";
// plane package imports
import { Download } from "lucide-react";
import {
ANALYTICS_V2_X_AXIS_VALUES,
ANALYTICS_V2_Y_AXIS_VALUES,
ANALYTICS_X_AXIS_VALUES,
ANALYTICS_Y_AXIS_VALUES,
CHART_COLOR_PALETTES,
ChartXAxisDateGrouping,
ChartXAxisProperty,
@ -18,17 +18,17 @@ import {
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { BarChart } from "@plane/propel/charts/bar-chart";
import { IChartResponseV2 } from "@plane/types";
import { IChartResponse } from "@plane/types";
import { TBarItem, TChart, TChartData, TChartDatum } from "@plane/types/src/charts";
// plane web components
import { Button } from "@plane/ui";
import { generateExtendedColors, parseChartData } from "@/components/chart/utils";
// hooks
import { useProjectState } from "@/hooks/store";
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
import { useAnalytics } from "@/hooks/store/use-analytics";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
import AnalyticsV2EmptyState from "../empty-state";
import { AnalyticsService } from "@/services/analytics.service";
import AnalyticsEmptyState from "../empty-state";
import { DataTable } from "../insight-table/data-table";
import { ChartLoader } from "../loaders";
import { generateBarColor } from "./utils";
@ -40,13 +40,13 @@ interface Props {
x_axis_date_grouping?: ChartXAxisDateGrouping;
}
const analyticsV2Service = new AnalyticsV2Service();
const analyticsService = new AnalyticsService();
const PriorityChart = observer((props: Props) => {
const { x_axis, y_axis, group_by } = props;
const { t } = useTranslation();
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-bar" });
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-bar" });
// store hooks
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView } = useAnalyticsV2();
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics();
const { workspaceStates } = useProjectState();
const { resolvedTheme } = useTheme();
// router
@ -55,9 +55,9 @@ const PriorityChart = observer((props: Props) => {
const { data: priorityChartData, isLoading: priorityChartLoading } = useSWR(
`customized-insights-chart-${workspaceSlug}-${selectedDuration}-
${selectedProjects}-${selectedCycle}-${selectedModule}-${props.x_axis}-${props.y_axis}-${props.group_by}-${isPeekView}`,
${selectedProjects}-${selectedCycle}-${selectedModule}-${props.x_axis}-${props.y_axis}-${props.group_by}-${isPeekView}-${isEpic}`,
() =>
analyticsV2Service.getAdvanceAnalyticsCharts<TChart>(
analyticsService.getAdvanceAnalyticsCharts<TChart>(
workspaceSlug,
"custom-work-items",
{
@ -65,6 +65,7 @@ const PriorityChart = observer((props: Props) => {
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
...(selectedModule ? { module_id: selectedModule } : {}),
...(isEpic ? { epic: true } : {}),
...props,
},
isPeekView
@ -132,11 +133,11 @@ const PriorityChart = observer((props: Props) => {
}, [chart_model, group_by, parsedData, resolvedTheme, workspaceStates, x_axis, y_axis]);
const yAxisLabel = useMemo(
() => ANALYTICS_V2_Y_AXIS_VALUES.find((item) => item.value === props.y_axis)?.label ?? props.y_axis,
() => ANALYTICS_Y_AXIS_VALUES.find((item) => item.value === props.y_axis)?.label ?? props.y_axis,
[props.y_axis]
);
const xAxisLabel = useMemo(
() => ANALYTICS_V2_X_AXIS_VALUES.find((item) => item.value === props.x_axis)?.label ?? props.x_axis,
() => ANALYTICS_X_AXIS_VALUES.find((item) => item.value === props.x_axis)?.label ?? props.x_axis,
[props.x_axis]
);
@ -237,9 +238,9 @@ const PriorityChart = observer((props: Props) => {
/>
</>
) : (
<AnalyticsV2EmptyState
title={t("workspace_analytics.empty_state_v2.customized_insights.title")}
description={t("workspace_analytics.empty_state_v2.customized_insights.description")}
<AnalyticsEmptyState
title={t("workspace_analytics.empty_state.customized_insights.title")}
description={t("workspace_analytics.empty_state.customized_insights.description")}
className="h-[350px]"
assetPath={resolvedPath}
/>

View file

@ -1,5 +1,6 @@
import { useMemo } from "react";
import { useMemo, useCallback } from "react";
import { ColumnDef, Row } from "@tanstack/react-table";
import { download, generateCsv } from "export-to-csv";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
@ -12,13 +13,14 @@ import { Avatar } from "@plane/ui";
import { getFileURL } from "@plane/utils";
import { Logo } from "@/components/common/logo";
// hooks
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
import { useAnalytics } from "@/hooks/store/use-analytics";
import { useProject } from "@/hooks/store/use-project";
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
import { AnalyticsService } from "@/services/analytics.service";
// plane web components
import { csvConfig } from "../config";
import { InsightTable } from "../insight-table";
const analyticsV2Service = new AnalyticsV2Service();
const analyticsService = new AnalyticsService();
const WorkItemsInsightTable = observer(() => {
// router
@ -27,11 +29,11 @@ const WorkItemsInsightTable = observer(() => {
const { t } = useTranslation();
// store hooks
const { getProjectById } = useProject();
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView } = useAnalyticsV2();
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics();
const { data: workItemsData, isLoading } = useSWR(
`insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
`insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}-${isEpic}`,
() =>
analyticsV2Service.getAdvanceAnalyticsStats<WorkItemInsightColumns[]>(
analyticsService.getAdvanceAnalyticsStats<WorkItemInsightColumns[]>(
workspaceSlug,
"work-items",
{
@ -39,23 +41,25 @@ const WorkItemsInsightTable = observer(() => {
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
...(selectedModule ? { module_id: selectedModule } : {}),
...(isEpic ? { epic: true } : {}),
},
isPeekView
)
);
// derived values
const columnsLabels = useMemo(
() => ({
backlog_work_items: t("workspace_projects.state.backlog"),
started_work_items: t("workspace_projects.state.started"),
un_started_work_items: t("workspace_projects.state.unstarted"),
completed_work_items: t("workspace_projects.state.completed"),
cancelled_work_items: t("workspace_projects.state.cancelled"),
project__name: t("common.project"),
display_name: t("common.assignee"),
}),
[t]
);
const columnsLabels: Record<keyof Omit<WorkItemInsightColumns, "project_id" | "avatar_url" | "assignee_id">, string> =
useMemo(
() => ({
backlog_work_items: t("workspace_projects.state.backlog"),
started_work_items: t("workspace_projects.state.started"),
un_started_work_items: t("workspace_projects.state.unstarted"),
completed_work_items: t("workspace_projects.state.completed"),
cancelled_work_items: t("workspace_projects.state.cancelled"),
project__name: t("common.project"),
display_name: t("common.assignee"),
}),
[t]
);
const columns = useMemo(
() =>
[
@ -135,6 +139,25 @@ const WorkItemsInsightTable = observer(() => {
[columnsLabels, getProjectById, isPeekView, t]
);
const exportCSV = useCallback(
(rows: Row<AnalyticsTableDataMap["work-items"]>[]) => {
const rowData: any = rows.map((row) => {
const { project_id, avatar_url, assignee_id, ...exportableData } = row.original;
return Object.fromEntries(
Object.entries(exportableData).map(([key, value]) => {
if (columnsLabels?.[key as keyof typeof columnsLabels]) {
return [columnsLabels[key as keyof typeof columnsLabels], value];
}
return [key, value];
})
);
});
const csv = generateCsv(csvConfig(workspaceSlug))(rowData);
download(csvConfig(workspaceSlug))(csv);
},
[columnsLabels, workspaceSlug]
);
return (
<InsightTable<"work-items">
analyticsType="work-items"
@ -142,7 +165,8 @@ const WorkItemsInsightTable = observer(() => {
isLoading={isLoading}
columns={columns}
columnsLabels={columnsLabels}
headerText={isPeekView ? columnsLabels["display_name"] : columnsLabels["project__name"]}
headerText={isPeekView ? t("common.assignee") : t("common.projects")}
onExport={exportCSV}
/>
);
});

View file

@ -1,155 +1,64 @@
import React from "react";
import { eachDayOfInterval, isValid } from "date-fns";
import { TModuleCompletionChartDistribution } from "@plane/types";
// ui
import { LineGraph } from "@/components/ui";
// helpers
import { getDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper";
//types
import { AreaChart } from "@plane/propel/charts/area-chart";
import { TChartData, TModuleCompletionChartDistribution } from "@plane/types";
import { renderFormattedDateWithoutYear } from "@/helpers/date-time.helper";
type Props = {
distribution: TModuleCompletionChartDistribution;
startDate: string | Date;
endDate: string | Date;
totalIssues: number;
className?: string;
plotTitle?: string;
};
const styleById = {
ideal: {
strokeDasharray: "6, 3",
strokeWidth: 1,
},
default: {
strokeWidth: 1,
},
};
const DashedLine = ({ series, lineGenerator, xScale, yScale }: any) =>
series.map(({ id, data, color }: any) => (
<path
key={id}
d={lineGenerator(
data.map((d: any) => ({
x: xScale(d.data.x),
y: yScale(d.data.y),
}))
)}
fill="none"
stroke={color ?? "#ddd"}
style={styleById[id as keyof typeof styleById] || styleById.default}
/>
));
const ProgressChart: React.FC<Props> = ({
distribution,
startDate,
endDate,
totalIssues,
className = "",
plotTitle = "work items",
}) => {
const chartData = Object.keys(distribution ?? []).map((key) => ({
currentDate: renderFormattedDateWithoutYear(key),
pending: distribution[key],
const ProgressChart: React.FC<Props> = ({ distribution, totalIssues, className = "", plotTitle = "work items" }) => {
const chartData: TChartData<string, string>[] = Object.keys(distribution ?? []).map((key, index) => ({
name: renderFormattedDateWithoutYear(key),
current: distribution[key] ?? 0,
ideal: totalIssues * (1 - index / (Object.keys(distribution ?? []).length - 1)),
}));
const generateXAxisTickValues = () => {
const start = getDate(startDate);
const end = getDate(endDate);
let dates: Date[] = [];
if (start && end && isValid(start) && isValid(end)) {
dates = eachDayOfInterval({ start, end });
}
if (dates.length === 0) return [];
const formattedDates = dates.map((d) => renderFormattedDateWithoutYear(d));
const firstDate = formattedDates[0];
const lastDate = formattedDates[formattedDates.length - 1];
if (formattedDates.length <= 2) return [firstDate, lastDate];
const middleDateIndex = Math.floor(formattedDates.length / 2);
const middleDate = formattedDates[middleDateIndex];
return [firstDate, middleDate, lastDate];
};
return (
<div className={`flex w-full items-center justify-center ${className}`}>
<LineGraph
animate
curve="monotoneX"
height="160px"
width="100%"
enableGridY={false}
lineWidth={1}
margin={{ top: 30, right: 30, bottom: 30, left: 30 }}
data={[
<AreaChart
data={chartData}
areas={[
{
id: "pending",
color: "#3F76FF",
data:
chartData.length > 0
? chartData.map((item, index) => ({
index,
x: item.currentDate,
y: item.pending,
color: "#3F76FF",
}))
: [],
enableArea: true,
key: "current",
label: `Current ${plotTitle}`,
strokeColor: "#3F76FF",
fill: "#3F76FF33",
fillOpacity: 1,
showDot: true,
smoothCurves: true,
strokeOpacity: 1,
stackId: "bar-one",
},
{
id: "ideal",
color: "#a9bbd0",
fill: "transparent",
data:
chartData.length > 0
? [
{
x: chartData[0].currentDate,
y: totalIssues,
},
{
x: chartData[chartData.length - 1].currentDate,
y: 0,
},
]
: [],
key: "ideal",
label: `Ideal ${plotTitle}`,
strokeColor: "#A9BBD0",
fill: "#A9BBD0",
fillOpacity: 0,
showDot: true,
smoothCurves: true,
strokeOpacity: 1,
stackId: "bar-two",
style: {
strokeDasharray: "6, 3",
strokeWidth: 1,
},
},
]}
layers={["grid", "markers", "areas", DashedLine, "slices", "points", "axes", "legends"]}
axisBottom={{
tickValues: generateXAxisTickValues(),
}}
enablePoints={false}
enableArea
colors={(datum) => datum.color ?? "#3F76FF"}
customYAxisTickValues={[0, totalIssues]}
gridXValues={
chartData.length > 0 ? chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : "")) : undefined
}
enableSlices="x"
sliceTooltip={(datum) => (
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
{datum.slice.points?.[1]?.data?.yFormatted ?? datum.slice.points[0].data.yFormatted}
<span className="text-custom-text-200"> {plotTitle} pending on </span>
{datum.slice.points[0].data.xFormatted}
</div>
)}
theme={{
background: "transparent",
axis: {
domain: {
line: {
stroke: "rgb(var(--color-border))",
strokeWidth: 1,
},
},
xAxis={{ key: "name", label: "Date" }}
yAxis={{ key: "current", label: "Completion" }}
margin={{ bottom: 30 }}
className="h-[370px] w-full"
legend={{
align: "center",
verticalAlign: "bottom",
layout: "horizontal",
wrapperStyles: {
marginTop: 20,
},
}}
/>

View file

@ -54,17 +54,7 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
{cycle.total_issues > 0 ? (
<>
<div className="h-full w-full px-2">
<div className="flex items-center justify-between gap-4 py-1 text-xs text-custom-text-300">
<div className="flex items-center gap-3 text-custom-text-300">
<div className="flex items-center justify-center gap-1">
<span className="h-2 w-2 rounded-full bg-[#A9BBD0]" />
<span>{t("project_cycles.active_cycle.ideal")}</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2 w-2 rounded-full bg-[#4C8FFF]" />
<span>{t("project_cycles.active_cycle.current")}</span>
</div>
</div>
<div className="flex items-center justify-end gap-4 py-1 text-xs text-custom-text-300">
{estimateType === "points" ? (
<span>{`Pending points - ${cycle.backlog_estimate_points + cycle.unstarted_estimate_points + cycle.started_estimate_points}`}</span>
) : (
@ -78,16 +68,12 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
{estimateType === "points" ? (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycle.start_date ?? ""}
endDate={cycle.end_date ?? ""}
totalIssues={cycle.total_estimate_points || 0}
plotTitle={"points"}
/>
) : (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycle.start_date ?? ""}
endDate={cycle.end_date ?? ""}
totalIssues={cycle.total_issues || 0}
plotTitle={"work items"}
/>

View file

@ -1,62 +0,0 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
import { TWidgetKeys } from "@plane/types";
// components
import {
AssignedIssuesWidget,
CreatedIssuesWidget,
IssuesByPriorityWidget,
IssuesByStateGroupWidget,
OverviewStatsWidget,
RecentActivityWidget,
RecentCollaboratorsWidget,
RecentProjectsWidget,
WidgetProps,
} from "@/components/dashboard";
// hooks
import { useDashboard } from "@/hooks/store";
const WIDGETS_LIST: {
[key in TWidgetKeys]: { component: React.FC<WidgetProps>; fullWidth: boolean };
} = {
overview_stats: { component: OverviewStatsWidget, fullWidth: true },
assigned_issues: { component: AssignedIssuesWidget, fullWidth: false },
created_issues: { component: CreatedIssuesWidget, fullWidth: false },
issues_by_state_groups: { component: IssuesByStateGroupWidget, fullWidth: false },
issues_by_priority: { component: IssuesByPriorityWidget, fullWidth: false },
recent_activity: { component: RecentActivityWidget, fullWidth: false },
recent_projects: { component: RecentProjectsWidget, fullWidth: false },
recent_collaborators: { component: RecentCollaboratorsWidget, fullWidth: true },
};
export const DashboardWidgets = observer(() => {
// router
const { workspaceSlug } = useParams();
// store hooks
const { homeDashboardId, homeDashboardWidgets } = useDashboard();
const doesWidgetExist = (widgetKey: TWidgetKeys) =>
Boolean(homeDashboardWidgets?.find((widget) => widget.key === widgetKey));
if (!workspaceSlug || !homeDashboardId) return null;
return (
<div className="relative flex flex-col lg:grid lg:grid-cols-2 gap-7">
{Object.entries(WIDGETS_LIST).map(([key, widget]) => {
const WidgetComponent = widget.component;
// if the widget doesn't exist, return null
if (!doesWidgetExist(key as TWidgetKeys)) return null;
// if the widget is full width, return it in a 2 column grid
if (widget.fullWidth)
return (
<div key={key} className="lg:col-span-2">
<WidgetComponent dashboardId={homeDashboardId} workspaceSlug={workspaceSlug.toString()} />
</div>
);
else
return <WidgetComponent key={key} dashboardId={homeDashboardId} workspaceSlug={workspaceSlug.toString()} />;
})}
</div>
);
});

View file

@ -1,3 +1,2 @@
export * from "./widgets";
export * from "./home-dashboard-widgets";
export * from "./project-empty-state";

View file

@ -5,8 +5,6 @@ export * from "./issue-panels";
export * from "./loaders";
export * from "./assigned-issues";
export * from "./created-issues";
export * from "./issues-by-priority";
export * from "./issues-by-state-group";
export * from "./overview-stats";
export * from "./recent-activity";
export * from "./recent-collaborators";

View file

@ -1,112 +0,0 @@
import { useEffect } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
// types
import { EDurationFilters } from "@plane/constants";
import { TIssuesByPriorityWidgetFilters, TIssuesByPriorityWidgetResponse } from "@plane/types";
// components
import { Card } from "@plane/ui";
import {
DurationFilterDropdown,
IssuesByPriorityEmptyState,
WidgetLoader,
WidgetProps,
} from "@/components/dashboard/widgets";
import { IssuesByPriorityGraph } from "@/components/graphs";
// constants
// helpers
import { getCustomDates } from "@/helpers/dashboard.helper";
// hooks
import { useDashboard } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
const WIDGET_KEY = "issues_by_priority";
export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) => {
const { dashboardId, workspaceSlug } = props;
// router
const router = useAppRouter();
// 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 ?? EDurationFilters.NONE;
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
const handleUpdateFilters = async (filters: Partial<TIssuesByPriorityWidgetFilters>) => {
if (!widgetDetails) return;
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
widgetKey: WIDGET_KEY,
filters,
});
const filterDates = getCustomDates(
filters.duration ?? selectedDuration,
filters.custom_dates ?? selectedCustomDates
);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
});
};
useEffect(() => {
const filterDates = getCustomDates(selectedDuration, selectedCustomDates);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
const totalCount = widgetStats.reduce((acc, item) => acc + item?.count, 0);
const chartData = widgetStats.map((item) => ({
priority: item?.priority,
priority_count: item?.count,
}));
return (
<Card>
<div className="flex items-center justify-between gap-2 mb-4">
<Link
href={`/${workspaceSlug}/workspace-views/assigned`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
>
Assigned by priority
</Link>
<DurationFilterDropdown
customDates={selectedCustomDates}
value={selectedDuration}
onChange={(val, customDates) =>
handleUpdateFilters({
duration: val,
...(val === "custom" ? { custom_dates: customDates } : {}),
})
}
/>
</div>
{totalCount > 0 ? (
<div className="flex h-full items-center">
<div className="-mt-[11px] w-full">
<IssuesByPriorityGraph
data={chartData}
onBarClick={(datum) => {
router.push(
`/${workspaceSlug}/workspace-views/assigned?priority=${`${datum.data.priority}`.toLowerCase()}`
);
}}
/>
</div>
</div>
) : (
<div className="grid h-full place-items-center">
<IssuesByPriorityEmptyState />
</div>
)}
</Card>
);
});

View file

@ -1,251 +0,0 @@
import { useEffect, useState } from "react";
import { linearGradientDef } from "@nivo/core";
import { observer } from "mobx-react";
import Link from "next/link";
// types
import { EDurationFilters, STATE_GROUPS } from "@plane/constants";
import { TIssuesByStateGroupsWidgetFilters, TIssuesByStateGroupsWidgetResponse, TStateGroups } from "@plane/types";
// components
import { Card } from "@plane/ui";
import {
DurationFilterDropdown,
IssuesByStateGroupEmptyState,
WidgetLoader,
WidgetProps,
} from "@/components/dashboard/widgets";
import { PieGraph } from "@/components/ui";
// helpers
import { getCustomDates } from "@/helpers/dashboard.helper";
// hooks
import { useDashboard } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
const WIDGET_KEY = "issues_by_state_groups";
export const STATE_GROUP_GRAPH_COLORS: Record<TStateGroups, string> = {
backlog: "#CDCED6",
unstarted: "#80838D",
started: "#FFC53D",
completed: "#3E9B4F",
cancelled: "#E5484D",
};
// colors for work items by state group widget graph arcs
export const STATE_GROUP_GRAPH_GRADIENTS = [
linearGradientDef("gradientBacklog", [
{ offset: 0, color: "#DEDEDE" },
{ offset: 100, color: "#BABABE" },
]),
linearGradientDef("gradientUnstarted", [
{ offset: 0, color: "#D4D4D4" },
{ offset: 100, color: "#878796" },
]),
linearGradientDef("gradientStarted", [
{ offset: 0, color: "#FFD300" },
{ offset: 100, color: "#FAE270" },
]),
linearGradientDef("gradientCompleted", [
{ offset: 0, color: "#0E8B1B" },
{ offset: 100, color: "#37CB46" },
]),
linearGradientDef("gradientCanceled", [
{ offset: 0, color: "#C90004" },
{ offset: 100, color: "#FF7679" },
]),
];
export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props) => {
const { dashboardId, workspaceSlug } = props;
// states
const [defaultStateGroup, setDefaultStateGroup] = useState<TStateGroups | null>(null);
const [activeStateGroup, setActiveStateGroup] = useState<TStateGroups | null>(null);
// router
const router = useAppRouter();
// store hooks
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard();
// derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TIssuesByStateGroupsWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedDuration = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
const handleUpdateFilters = async (filters: Partial<TIssuesByStateGroupsWidgetFilters>) => {
if (!widgetDetails) return;
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
widgetKey: WIDGET_KEY,
filters,
});
const filterDates = getCustomDates(
filters.duration ?? selectedDuration,
filters.custom_dates ?? selectedCustomDates
);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
});
};
// fetch widget stats
useEffect(() => {
const filterDates = getCustomDates(selectedDuration, selectedCustomDates);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// set active group for center metric
useEffect(() => {
if (!widgetStats) return;
const startedCount = widgetStats?.find((item) => item?.state === "started")?.count ?? 0;
const unStartedCount = widgetStats?.find((item) => item?.state === "unstarted")?.count ?? 0;
const backlogCount = widgetStats?.find((item) => item?.state === "backlog")?.count ?? 0;
const completedCount = widgetStats?.find((item) => item?.state === "completed")?.count ?? 0;
const canceledCount = widgetStats?.find((item) => item?.state === "cancelled")?.count ?? 0;
const stateGroup =
startedCount > 0
? "started"
: unStartedCount > 0
? "unstarted"
: backlogCount > 0
? "backlog"
: completedCount > 0
? "completed"
: canceledCount > 0
? "cancelled"
: null;
setActiveStateGroup(stateGroup);
setDefaultStateGroup(stateGroup);
}, [widgetStats]);
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
const totalCount = widgetStats?.reduce((acc, item) => acc + item?.count, 0);
const chartData = widgetStats?.map((item) => ({
color: STATE_GROUP_GRAPH_COLORS[item?.state as keyof typeof STATE_GROUP_GRAPH_COLORS],
id: item?.state,
label: item?.state,
value: (item?.count / totalCount) * 100,
}));
const CenteredMetric = ({ dataWithArc, centerX, centerY }: any) => {
const data = dataWithArc?.find((datum: any) => datum?.id === activeStateGroup);
const percentage = chartData?.find((item) => item.id === activeStateGroup)?.value?.toFixed(0);
return (
<g>
<text
x={centerX}
y={centerY - 8}
textAnchor="middle"
dominantBaseline="central"
className="text-3xl font-bold"
style={{
fill: data?.color,
}}
>
{percentage}%
</text>
<text
x={centerX}
y={centerY + 20}
textAnchor="middle"
dominantBaseline="central"
className="text-sm font-medium fill-custom-text-300 capitalize"
>
{data?.id}
</text>
</g>
);
};
return (
<Card>
<div className="flex items-center justify-between gap-2 mb-4">
<Link
href={`/${workspaceSlug}/workspace-views/assigned`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
>
Assigned by state
</Link>
<DurationFilterDropdown
customDates={selectedCustomDates}
value={selectedDuration}
onChange={(val, customDates) =>
handleUpdateFilters({
duration: val,
...(val === "custom" ? { custom_dates: customDates } : {}),
})
}
/>
</div>
{totalCount > 0 ? (
<div className="flex items-center mt-11">
<div className="flex flex-col sm:flex-row md:flex-row lg:flex-row items-center justify-evenly gap-x-10 gap-y-8 w-full">
<div>
<PieGraph
data={chartData}
height="220px"
width="200px"
innerRadius={0.6}
cornerRadius={5}
colors={(datum) => datum.data.color}
padAngle={1}
enableArcLinkLabels={false}
enableArcLabels={false}
activeOuterRadiusOffset={5}
tooltip={() => <></>}
margin={{
top: 0,
right: 5,
bottom: 0,
left: 5,
}}
defs={STATE_GROUP_GRAPH_GRADIENTS}
fill={Object.values(STATE_GROUPS).map((p) => ({
match: {
id: p.key,
},
id: `gradient${p.label}`,
}))}
onClick={(datum, e) => {
e.preventDefault();
e.stopPropagation();
router.push(`/${workspaceSlug}/workspace-views/assigned/?state_group=${datum.id}`);
}}
onMouseEnter={(datum) => setActiveStateGroup(datum.id as TStateGroups)}
onMouseLeave={() => setActiveStateGroup(defaultStateGroup)}
layers={["arcs", CenteredMetric]}
/>
</div>
<div className="space-y-6 w-min whitespace-nowrap">
{chartData.map((item) => (
<div key={item.id} className="flex items-center justify-between gap-6">
<div className="flex items-center gap-2.5 w-24">
<div
className="h-3 w-3 rounded-full"
style={{
backgroundColor: item.color,
}}
/>
<span className="text-custom-text-300 text-sm font-medium capitalize">{item.label}</span>
</div>
<span className="text-custom-text-400 text-sm">{item.value.toFixed(0)}%</span>
</div>
))}
</div>
</div>
</div>
) : (
<div className="h-full grid place-items-center">
<IssuesByStateGroupEmptyState />
</div>
)}
</Card>
);
});

View file

@ -1 +0,0 @@
export * from "./issues-by-priority";

View file

@ -1,169 +0,0 @@
import { ComputedDatum } from "@nivo/bar";
import { Theme, linearGradientDef } from "@nivo/core";
import { ISSUE_PRIORITIES } from "@plane/constants";
// components
import { TIssuePriorities } from "@plane/types";
import { BarGraph } from "@/components/ui";
// helpers
import { capitalizeFirstLetter } from "@/helpers/string.helper";
// gradients for work items by priority widget graph bars
export const PRIORITY_GRAPH_GRADIENTS = [
linearGradientDef(
"gradient_urgent",
[
{ offset: 0, color: "#A90408" },
{ offset: 100, color: "#DF4D51" },
],
{
x1: 1,
y1: 0,
x2: 0,
y2: 0,
}
),
linearGradientDef(
"gradient_high",
[
{ offset: 0, color: "#FE6B00" },
{ offset: 100, color: "#FFAC88" },
],
{
x1: 1,
y1: 0,
x2: 0,
y2: 0,
}
),
linearGradientDef(
"gradient_medium",
[
{ offset: 0, color: "#F5AC00" },
{ offset: 100, color: "#FFD675" },
],
{
x1: 1,
y1: 0,
x2: 0,
y2: 0,
}
),
linearGradientDef(
"gradient_low",
[
{ offset: 0, color: "#1B46DE" },
{ offset: 100, color: "#4F9BF4" },
],
{
x1: 1,
y1: 0,
x2: 0,
y2: 0,
}
),
linearGradientDef(
"gradient_none",
[
{ offset: 0, color: "#A0A1A9" },
{ offset: 100, color: "#B9BBC6" },
],
{
x1: 1,
y1: 0,
x2: 0,
y2: 0,
}
),
];
type Props = {
borderRadius?: number;
data: {
priority: TIssuePriorities;
priority_count: number;
}[];
height?: number;
onBarClick?: (
datum: ComputedDatum<any> & {
color: string;
}
) => void;
padding?: number;
theme?: Theme;
};
const PRIORITY_TEXT_COLORS = {
urgent: "#CE2C31",
high: "#AB4800",
medium: "#AB6400",
low: "#1F2D5C",
none: "#60646C",
};
export const IssuesByPriorityGraph: React.FC<Props> = (props) => {
const { borderRadius = 8, data, height = 300, onBarClick, padding = 0.05, theme } = props;
const chartData = data.map((priority) => ({
priority: capitalizeFirstLetter(priority.priority),
value: priority.priority_count,
}));
return (
<BarGraph
data={chartData}
height={`${height}px`}
indexBy="priority"
keys={["value"]}
borderRadius={borderRadius}
padding={padding}
customYAxisTickValues={data.map((p) => p.priority_count)}
axisBottom={{
tickPadding: 8,
tickSize: 0,
}}
tooltip={(datum) => (
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: PRIORITY_TEXT_COLORS[`${datum.data.priority}`.toLowerCase() as TIssuePriorities],
}}
/>
<span className="font-medium text-custom-text-200">{datum.data.priority}:</span>
<span>{datum.value}</span>
</div>
)}
colors={({ data }) => `url(#gradient${data.priority})`}
defs={PRIORITY_GRAPH_GRADIENTS}
fill={ISSUE_PRIORITIES.map((p) => ({
match: {
id: p.key,
},
id: `gradient_${p.key}`,
}))}
onClick={(datum) => {
if (onBarClick) onBarClick(datum);
}}
theme={{
axis: {
domain: {
line: {
stroke: "transparent",
},
},
ticks: {
text: {
fontSize: 13,
},
},
},
grid: {
line: {
stroke: "transparent",
},
},
...theme,
}}
/>
);
};

View file

@ -17,8 +17,7 @@ import { isIssueFilterActive } from "@/helpers/filter.helper";
import { useLabel, useProjectState, useMember, useIssues } from "@/hooks/store";
// plane web types
import { TProject } from "@/plane-web/types";
import { ProjectAnalyticsModal } from "../analytics";
import { WorkItemsModal } from "../analytics-v2/work-items/modal";
import { WorkItemsModal } from "../analytics/work-items/modal";
type Props = {
currentProjectDetails: TProject | undefined;
@ -102,6 +101,7 @@ const HeaderFilters = observer((props: Props) => {
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
<LayoutSelection
layouts={[

View file

@ -211,31 +211,17 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
<Disclosure.Panel className="space-y-4">
{/* progress burndown chart */}
<div>
<div className="relative flex items-center gap-2">
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>{t("ideal")}</span>
</div>
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>{t("current")}</span>
</div>
</div>
{moduleStartDate && moduleEndDate && completionChartDistributionData && (
<Fragment>
{plotType === "points" ? (
<ProgressChart
distribution={completionChartDistributionData}
startDate={moduleStartDate}
endDate={moduleEndDate}
totalIssues={totalEstimatePoints}
plotTitle={"points"}
/>
) : (
<ProgressChart
distribution={completionChartDistributionData}
startDate={moduleStartDate}
endDate={moduleEndDate}
totalIssues={totalIssues}
plotTitle={"work items"}
/>

View file

@ -1 +0,0 @@
export * from "./workspace-dashboard";

View file

@ -1,118 +0,0 @@
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, PRODUCT_TOUR_COMPLETED, EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ContentWrapper } from "@plane/ui";
// components
import { DashboardWidgets } from "@/components/dashboard";
import { ComicBoxButton, DetailedEmptyState } from "@/components/empty-state";
import { IssuePeekOverview } from "@/components/issues";
import { TourRoot } from "@/components/onboarding";
import { UserGreetingsView } from "@/components/user";
// constants
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import {
useCommandPalette,
useUserProfile,
useEventTracker,
useDashboard,
useProject,
useUser,
useUserPermissions,
} from "@/hooks/store";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import useSize from "@/hooks/use-window-size";
export const WorkspaceDashboardView = observer(() => {
// plane hooks
const { t } = useTranslation();
// store hooks
const { captureEvent, setTrackElement } = useEventTracker();
const { toggleCreateProjectModal } = useCommandPalette();
const { workspaceSlug } = useParams();
const { data: currentUser } = useUser();
const { data: currentUserProfile, updateTourCompleted } = useUserProfile();
const { homeDashboardId, fetchHomeDashboardWidgets } = useDashboard();
const { joinedProjectIds, loader } = useProject();
const { allowPermissions } = useUserPermissions();
// helper hooks
const [windowWidth] = useSize();
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/dashboard" });
const handleTourCompleted = () => {
updateTourCompleted()
.then(() => {
captureEvent(PRODUCT_TOUR_COMPLETED, {
user_id: currentUser?.id,
state: "SUCCESS",
});
})
.catch((error) => {
console.error(error);
});
};
// fetch home dashboard widgets on workspace change
useEffect(() => {
if (!workspaceSlug) return;
fetchHomeDashboardWidgets(workspaceSlug?.toString());
}, [fetchHomeDashboardWidgets, workspaceSlug]);
const canPerformEmptyStateActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
// TODO: refactor loader implementation
return (
<>
{currentUserProfile && !currentUserProfile.is_tour_completed && (
<div className="fixed left-0 top-0 z-20 grid h-full w-full place-items-center bg-custom-backdrop bg-opacity-50 transition-opacity">
<TourRoot onComplete={handleTourCompleted} />
</div>
)}
{homeDashboardId && joinedProjectIds && (
<>
{joinedProjectIds.length > 0 || loader === "init-loader" ? (
<>
<IssuePeekOverview />
<ContentWrapper
className={cn("gap-7 bg-custom-background-90/20", {
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
})}
>
{currentUser && <UserGreetingsView user={currentUser} />}
<DashboardWidgets />
</ContentWrapper>
</>
) : (
<DetailedEmptyState
title={t("workspace_dashboard.empty_state.general.title")}
description={t("workspace_dashboard.empty_state.general.description")}
assetPath={resolvedPath}
customPrimaryButton={
<ComicBoxButton
label={t("workspace_dashboard.empty_state.general.primary_button.text")}
title={t("workspace_dashboard.empty_state.general.primary_button.comic.title")}
description={t("workspace_dashboard.empty_state.general.primary_button.comic.description")}
onClick={() => {
setTrackElement("Dashboard empty state");
toggleCreateProjectModal(true);
}}
disabled={!canPerformEmptyStateActions}
/>
}
/>
)}
</>
)}
</>
);
});

View file

@ -1 +0,0 @@
export * from "./priority-distribution";

View file

@ -1,31 +0,0 @@
// components
import { IUserPriorityDistribution } from "@plane/types";
import { IssuesByPriorityGraph } from "@/components/graphs";
import { ProfileEmptyState } from "@/components/ui";
// assets
import emptyBarGraph from "@/public/empty-state/empty_bar_graph.svg";
// types
type Props = {
priorityDistribution: IUserPriorityDistribution[];
};
export const PriorityDistributionContent: React.FC<Props> = (props) => {
const { priorityDistribution } = props;
return (
<div className="flex-grow rounded border border-custom-border-100">
{priorityDistribution.length > 0 ? (
<IssuesByPriorityGraph data={priorityDistribution} />
) : (
<div className="flex-grow p-7">
<ProfileEmptyState
title="No Data yet"
description="Create work items to view the them by priority in the graph for better analysis."
image={emptyBarGraph}
/>
</div>
)}
</div>
);
};

View file

@ -1,35 +0,0 @@
"use client";
// components
// ui
import { IUserPriorityDistribution } from "@plane/types";
import { Loader } from "@plane/ui";
// types
import { PriorityDistributionContent } from "./main-content";
type Props = {
priorityDistribution: IUserPriorityDistribution[] | undefined;
};
export const ProfilePriorityDistribution: React.FC<Props> = (props) => {
const { priorityDistribution } = props;
return (
<div className="flex flex-col space-y-2">
<h3 className="text-lg font-medium">Work items by priority</h3>
{priorityDistribution ? (
<PriorityDistributionContent priorityDistribution={priorityDistribution} />
) : (
<div className="grid place-items-center p-7">
<Loader className="flex items-end gap-12">
<Loader.Item width="30px" height="200px" />
<Loader.Item width="30px" height="150px" />
<Loader.Item width="30px" height="250px" />
<Loader.Item width="30px" height="150px" />
<Loader.Item width="30px" height="100px" />
</Loader>
</div>
)}
</div>
);
};

View file

@ -1,49 +0,0 @@
// nivo
import { ResponsiveBar, BarSvgProps } from "@nivo/bar";
import { CHARTS_THEME, CHART_DEFAULT_MARGIN } from "@plane/constants";
// helpers
import { generateYAxisTickValues } from "@/helpers/graph.helper";
// types
import { TGraph } from "./types";
// constants
type Props = {
indexBy: string;
keys: string[];
customYAxisTickValues?: number[];
};
export const BarGraph: React.FC<Props & TGraph & Omit<BarSvgProps<any>, "height" | "width">> = ({
indexBy,
keys,
customYAxisTickValues,
height = "400px",
width = "100%",
margin,
theme,
...rest
}) => (
<div style={{ height, width }}>
<ResponsiveBar
indexBy={indexBy}
keys={keys}
margin={{ ...CHART_DEFAULT_MARGIN, ...(margin ?? {}) }}
padding={(rest.padding ?? rest.data.length > 7) ? 0.8 : 0.9}
axisLeft={{
tickSize: 0,
tickPadding: 10,
tickValues: customYAxisTickValues ? generateYAxisTickValues(customYAxisTickValues) : undefined,
}}
axisBottom={{
tickSize: 0,
tickPadding: 10,
tickRotation: rest.data.length > 7 ? -45 : 0,
}}
labelTextColor={{ from: "color", modifiers: [["darker", 1.6]] }}
theme={{ ...CHARTS_THEME, ...(theme ?? {}) }}
animate
enableLabel={rest.enableLabel ?? false}
{...rest}
/>
</div>
);

View file

@ -1,34 +0,0 @@
// nivo
import { ResponsiveCalendar, CalendarSvgProps } from "@nivo/calendar";
// types
import { CHARTS_THEME, CHART_DEFAULT_MARGIN } from "@plane/constants";
import { TGraph } from "./types";
// constants
export const CalendarGraph: React.FC<TGraph & Omit<CalendarSvgProps, "height" | "width">> = ({
height = "400px",
width = "100%",
margin,
theme,
...rest
}) => (
<div style={{ height, width }}>
<ResponsiveCalendar
margin={{ ...CHART_DEFAULT_MARGIN, ...(margin ?? {}) }}
colors={
rest.colors ?? [
"rgba(var(--color-primary-100), 0.2)",
"rgba(var(--color-primary-100), 0.4)",
"rgba(var(--color-primary-100), 0.8)",
"rgba(var(--color-primary-100), 1)",
]
}
emptyColor={rest.emptyColor ?? "rgb(var(--color-background-80))"}
dayBorderColor={rest.dayBorderColor ?? "transparent"}
daySpacing={rest.daySpacing ?? 5}
monthBorderColor={rest.monthBorderColor ?? "rgb(var(--color-background-100))"}
theme={{ ...CHARTS_THEME, ...(theme ?? {}) }}
{...rest}
/>
</div>
);

View file

@ -1,5 +0,0 @@
export * from "./bar-graph";
export * from "./calendar-graph";
export * from "./line-graph";
export * from "./pie-graph";
export * from "./scatter-plot-graph";

View file

@ -1,35 +0,0 @@
// nivo
import { ResponsiveLine, LineSvgProps } from "@nivo/line";
// helpers
import { CHARTS_THEME, CHART_DEFAULT_MARGIN } from "@plane/constants";
import { generateYAxisTickValues } from "@/helpers/graph.helper";
// types
import { TGraph } from "./types";
// constants
type Props = {
customYAxisTickValues?: number[];
};
export const LineGraph: React.FC<Props & TGraph & LineSvgProps> = ({
customYAxisTickValues,
height = "400px",
width = "100%",
margin,
theme,
...rest
}) => (
<div style={{ height, width }}>
<ResponsiveLine
margin={{ ...CHART_DEFAULT_MARGIN, ...(margin ?? {}) }}
axisLeft={{
tickSize: 0,
tickPadding: 10,
tickValues: customYAxisTickValues ? generateYAxisTickValues(customYAxisTickValues) : undefined,
}}
theme={{ ...CHARTS_THEME, ...(theme ?? {}) }}
animate
{...rest}
/>
</div>
);

View file

@ -1,23 +0,0 @@
// nivo
import { PieSvgProps, ResponsivePie } from "@nivo/pie";
// types
import { CHARTS_THEME, CHART_DEFAULT_MARGIN } from "@plane/constants";
import { TGraph } from "./types";
// constants
export const PieGraph: React.FC<TGraph & Omit<PieSvgProps<any>, "height" | "width">> = ({
height = "400px",
width = "100%",
margin,
theme,
...rest
}) => (
<div style={{ height, width }}>
<ResponsivePie
margin={{ ...CHART_DEFAULT_MARGIN, ...(margin ?? {}) }}
theme={{ ...CHARTS_THEME, ...(theme ?? {}) }}
animate
{...rest}
/>
</div>
);

View file

@ -1,23 +0,0 @@
// nivo
import { ResponsiveScatterPlot, ScatterPlotSvgProps } from "@nivo/scatterplot";
// types
import { CHARTS_THEME, CHART_DEFAULT_MARGIN } from "@plane/constants";
import { TGraph } from "./types";
// constants
export const ScatterPlotGraph: React.FC<TGraph & Omit<ScatterPlotSvgProps<any>, "height" | "width">> = ({
height = "400px",
width = "100%",
margin,
theme,
...rest
}) => (
<div style={{ height, width }}>
<ResponsiveScatterPlot
margin={{ ...CHART_DEFAULT_MARGIN, ...(margin ?? {}) }}
animate
theme={{ ...CHARTS_THEME, ...(theme ?? {}) }}
{...rest}
/>
</div>
);

View file

@ -1,8 +0,0 @@
import { Theme, Margin } from "@nivo/core";
export type TGraph = {
height?: string;
width?: string;
margin?: Partial<Margin>;
theme?: Theme;
};

View file

@ -1,4 +1,3 @@
export * from "./graphs";
export * from "./empty-space";
export * from "./labels-list";
export * from "./markdown-to-component";

View file

@ -1,4 +1,4 @@
import { IAnalyticsParams, IJiraMetadata } from "@plane/types";
import { IJiraMetadata } from "@plane/types";
const paramsToKey = (params: any) => {
const {
@ -237,14 +237,6 @@ export const MY_PAGES_LIST = (pageId: string) => `MY_PAGE_LIST_${pageId}`;
export const ESTIMATES_LIST = (projectId: string) => `ESTIMATES_LIST_${projectId.toUpperCase()}`;
export const ESTIMATE_DETAILS = (estimateId: string) => `ESTIMATE_DETAILS_${estimateId.toUpperCase()}`;
// analytics
export const ANALYTICS = (workspaceSlug: string, params: IAnalyticsParams) =>
`ANALYTICS${workspaceSlug.toUpperCase()}_${params.x_axis}_${params.y_axis}_${
params.segment
}_${params.project?.toString()}`;
export const DEFAULT_ANALYTICS = (workspaceSlug: string, params?: Partial<IAnalyticsParams>) =>
`DEFAULT_ANALYTICS_${workspaceSlug.toUpperCase()}_${params?.project?.toString()}_${params?.cycle}_${params?.module}`;
// profile
export const USER_PROFILE_DATA = (workspaceSlug: string, userId: string) =>
`USER_PROFILE_ACTIVITY_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;

View file

@ -31,4 +31,4 @@ export * from "./use-workspace";
export * from "./user";
export * from "./use-transient";
export * from "./workspace-draft";
export * from "./use-analytics-v2";
export * from "./use-analytics";

View file

@ -1,11 +0,0 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import { IAnalyticsStoreV2 } from "@/store/analytics-v2.store";
export const useAnalyticsV2 = (): IAnalyticsStoreV2 => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useAnalyticsV2 must be used within StoreProvider");
return context.analyticsV2;
};

View file

@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import { IAnalyticsStore } from "@/plane-web/store/analytics.store";
export const useAnalytics = (): IAnalyticsStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useAnalytics must be used within StoreProvider");
return context.analytics;
};

View file

@ -1,95 +0,0 @@
import { API_BASE_URL } from "@plane/constants";
import { IAnalyticsResponseV2, TAnalyticsTabsV2Base, TAnalyticsGraphsV2Base } from "@plane/types";
import { APIService } from "./api.service";
export class AnalyticsV2Service extends APIService {
constructor() {
super(API_BASE_URL);
}
async getAdvanceAnalytics<T extends IAnalyticsResponseV2>(
workspaceSlug: string,
tab: TAnalyticsTabsV2Base,
params?: Record<string, any>,
isPeekView?: boolean
): Promise<T> {
return this.get(
this.processUrl<TAnalyticsTabsV2Base>("advance-analytics", workspaceSlug, tab, params, isPeekView),
{
params: {
tab,
...params,
},
}
)
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
async getAdvanceAnalyticsStats<T>(
workspaceSlug: string,
tab: Exclude<TAnalyticsTabsV2Base, "overview">,
params?: Record<string, any>,
isPeekView?: boolean
): Promise<T> {
const processedUrl = this.processUrl<Exclude<TAnalyticsTabsV2Base, "overview">>(
"advance-analytics-stats",
workspaceSlug,
tab,
params,
isPeekView
);
return this.get(processedUrl, {
params: {
type: tab,
...params,
},
})
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
async getAdvanceAnalyticsCharts<T>(
workspaceSlug: string,
tab: TAnalyticsGraphsV2Base,
params?: Record<string, any>,
isPeekView?: boolean
): Promise<T> {
const processedUrl = this.processUrl<TAnalyticsGraphsV2Base>(
"advance-analytics-charts",
workspaceSlug,
tab,
params,
isPeekView
);
return this.get(processedUrl, {
params: {
type: tab,
...params,
},
})
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
processUrl<T extends string>(
endpoint: string,
workspaceSlug: string,
tab: T,
params?: Record<string, any>,
isPeekView?: boolean
) {
let processedUrl = `/api/workspaces/${workspaceSlug}`;
if (isPeekView && tab === "work-items") {
const projectId = params?.project_ids.split(",")[0];
processedUrl += `/projects/${projectId}`;
}
return `${processedUrl}/${endpoint}`;
}
}

View file

@ -1,63 +1,99 @@
// services
import {
IAnalyticsParams,
IAnalyticsResponse,
IDefaultAnalyticsResponse,
IExportAnalyticsFormData,
ISaveAnalyticsFormData,
} from "@plane/types";
import { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service";
// types
// helpers
import { API_BASE_URL } from "@plane/constants";
import { IAnalyticsResponse, TAnalyticsTabsBase, TAnalyticsGraphsBase, TAnalyticsFilterParams } from "@plane/types";
import { APIService } from "./api.service";
export class AnalyticsService extends APIService {
constructor() {
super(API_BASE_URL);
}
async getAnalytics(workspaceSlug: string, params: IAnalyticsParams): Promise<IAnalyticsResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/analytics/`, {
params: {
...params,
project: params?.project ? params.project.toString() : null,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getDefaultAnalytics(
async getAdvanceAnalytics<T extends IAnalyticsResponse>(
workspaceSlug: string,
params?: Partial<IAnalyticsParams>
): Promise<IDefaultAnalyticsResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/default-analytics/`, {
tab: TAnalyticsTabsBase,
params?: TAnalyticsFilterParams,
isPeekView?: boolean
): Promise<T> {
return this.get(this.processUrl<TAnalyticsTabsBase>("advance-analytics", workspaceSlug, tab, params, isPeekView), {
params: {
tab,
...params,
project: params?.project ? params.project.toString() : null,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
async saveAnalytics(workspaceSlug: string, data: ISaveAnalyticsFormData): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/analytic-view/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
async getAdvanceAnalyticsStats<T>(
workspaceSlug: string,
tab: Exclude<TAnalyticsTabsBase, "overview">,
params?: TAnalyticsFilterParams,
isPeekView?: boolean
): Promise<T> {
const processedUrl = this.processUrl<Exclude<TAnalyticsTabsBase, "overview">>(
"advance-analytics-stats",
workspaceSlug,
tab,
params,
isPeekView
);
return this.get(processedUrl, {
params: {
type: tab,
...params,
},
})
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
async exportAnalytics(workspaceSlug: string, data: IExportAnalyticsFormData): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/export-analytics/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
async getAdvanceAnalyticsCharts<T>(
workspaceSlug: string,
tab: TAnalyticsGraphsBase,
params?: TAnalyticsFilterParams,
isPeekView?: boolean
): Promise<T> {
const processedUrl = this.processUrl<TAnalyticsGraphsBase>(
"advance-analytics-charts",
workspaceSlug,
tab,
params,
isPeekView
);
return this.get(processedUrl, {
params: {
type: tab,
...params,
},
})
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
processUrl<T extends string>(
endpoint: string,
workspaceSlug: string,
tab: TAnalyticsGraphsBase | TAnalyticsTabsBase,
params?: TAnalyticsFilterParams,
isPeekView?: boolean
) {
let processedUrl = `/api/workspaces/${workspaceSlug}`;
if (isPeekView && (tab === "work-items" || tab === "custom-work-items")) {
const projectIds = params?.project_ids;
if (typeof projectIds !== "string" || !projectIds.trim()) {
throw new Error("project_ids parameter is required for peek view of work items");
}
const projectId = projectIds.split(",")[0];
if (!projectId) {
throw new Error("Invalid project_ids format - no project ID found");
}
processedUrl += `/projects/${projectId}`;
}
return `${processedUrl}/${endpoint}`;
}
}

View file

@ -1,19 +1,19 @@
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { ANALYTICS_V2_DURATION_FILTER_OPTIONS } from "@plane/constants";
import { TAnalyticsTabsV2Base } from "@plane/types";
import { ANALYTICS_DURATION_FILTER_OPTIONS, EIssuesStoreType } from "@plane/constants";
import { TAnalyticsTabsBase } from "@plane/types";
import { CoreRootStore } from "./root.store";
type DurationType = (typeof ANALYTICS_V2_DURATION_FILTER_OPTIONS)[number]["value"];
type DurationType = (typeof ANALYTICS_DURATION_FILTER_OPTIONS)[number]["value"];
export interface IAnalyticsStoreV2 {
export interface IBaseAnalyticsStore {
//observables
currentTab: TAnalyticsTabsV2Base;
currentTab: TAnalyticsTabsBase;
selectedProjects: string[];
selectedDuration: DurationType;
selectedCycle: string;
selectedModule: string;
isPeekView?: boolean;
isEpic?: boolean;
//computed
selectedDurationLabel: DurationType | null;
@ -23,25 +23,28 @@ export interface IAnalyticsStoreV2 {
updateSelectedCycle: (cycle: string) => void;
updateSelectedModule: (module: string) => void;
updateIsPeekView: (isPeekView: boolean) => void;
updateIsEpic: (isEpic: boolean) => void;
}
export class AnalyticsStoreV2 implements IAnalyticsStoreV2 {
export abstract class BaseAnalyticsStore implements IBaseAnalyticsStore {
//observables
currentTab: TAnalyticsTabsV2Base = "overview";
currentTab: TAnalyticsTabsBase = "overview";
selectedProjects: string[] = [];
selectedDuration: DurationType = "last_30_days";
selectedCycle: string = "";
selectedModule: string = "";
isPeekView: boolean = false;
isEpic: boolean = false;
constructor() {
makeObservable(this, {
// observables
currentTab: observable.ref,
selectedDuration: observable.ref,
selectedProjects: observable.ref,
selectedProjects: observable,
selectedCycle: observable.ref,
selectedModule: observable.ref,
isPeekView: observable.ref,
isEpic: observable.ref,
// computed
selectedDurationLabel: computed,
// actions
@ -50,11 +53,12 @@ export class AnalyticsStoreV2 implements IAnalyticsStoreV2 {
updateSelectedCycle: action,
updateSelectedModule: action,
updateIsPeekView: action,
updateIsEpic: action,
});
}
get selectedDurationLabel() {
return ANALYTICS_V2_DURATION_FILTER_OPTIONS.find((item) => item.value === this.selectedDuration)?.name ?? null;
return ANALYTICS_DURATION_FILTER_OPTIONS.find((item) => item.value === this.selectedDuration)?.name ?? null;
}
updateSelectedProjects = (projects: string[]) => {
@ -96,4 +100,10 @@ export class AnalyticsStoreV2 implements IAnalyticsStoreV2 {
this.isPeekView = isPeekView;
});
};
updateIsEpic = (isEpic: boolean) => {
runInAction(() => {
this.isEpic = isEpic;
});
};
}

View file

@ -2,11 +2,11 @@ import { enableStaticRendering } from "mobx-react";
// plane imports
import { FALLBACK_LANGUAGE, LANGUAGE_STORAGE_KEY } from "@plane/i18n";
// plane web store
import { AnalyticsStore, IAnalyticsStore } from "@/plane-web/store/analytics.store";
import { CommandPaletteStore, ICommandPaletteStore } from "@/plane-web/store/command-palette.store";
import { RootStore } from "@/plane-web/store/root.store";
import { IStateStore, StateStore } from "@/plane-web/store/state.store";
// stores
import { IAnalyticsStoreV2, AnalyticsStoreV2 } from "./analytics-v2.store";
import { CycleStore, ICycleStore } from "./cycle.store";
import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
import { DashboardStore, IDashboardStore } from "./dashboard.store";
@ -50,7 +50,7 @@ export class CoreRootStore {
state: IStateStore;
label: ILabelStore;
dashboard: IDashboardStore;
analyticsV2: IAnalyticsStoreV2;
analytics: IAnalyticsStore;
projectPages: IProjectPageStore;
router: IRouterStore;
commandPalette: ICommandPaletteStore;
@ -96,7 +96,7 @@ export class CoreRootStore {
this.transient = new TransientStore();
this.stickyStore = new StickyStore();
this.editorAssetStore = new EditorAssetStore();
this.analyticsV2 = new AnalyticsStoreV2();
this.analytics = new AnalyticsStore();
}
resetOnSignOut() {