[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:
parent
570a9e319e
commit
14d2d69120
151 changed files with 1144 additions and 4800 deletions
|
|
@ -1 +0,0 @@
|
|||
export * from "./overview/root";
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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">
|
||||
9
web/core/components/analytics/config.ts
Normal file
9
web/core/components/analytics/config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { mkConfig } from "export-to-csv";
|
||||
|
||||
export const csvConfig = (workspaceSlug: string) =>
|
||||
mkConfig({
|
||||
fieldSeparator: ",",
|
||||
filename: `${workspaceSlug}-analytics`,
|
||||
decimalSeparator: ".",
|
||||
useKeysAsHeaders: true,
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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: {},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from "./project";
|
||||
export * from "./segment";
|
||||
export * from "./x-axis";
|
||||
export * from "./y-axis";
|
||||
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./projects-list";
|
||||
export * from "./sidebar-header";
|
||||
export * from "./sidebar";
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,3 +1 @@
|
|||
export * from "./custom-analytics";
|
||||
export * from "./scope-and-demand";
|
||||
export * from "./project-modal";
|
||||
export * from "./overview/root";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
/>
|
||||
49
web/core/components/analytics/insight-table/root.tsx
Normal file
49
web/core/components/analytics/insight-table/root.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./header";
|
||||
export * from "./main-content";
|
||||
export * from "./modal";
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export * from "./demand";
|
||||
export * from "./leaderboard";
|
||||
export * from "./scope-and-demand";
|
||||
export * from "./scope";
|
||||
export * from "./year-wise-issues";
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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]}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
102
web/core/components/analytics/total-insights.tsx
Normal file
102
web/core/components/analytics/total-insights.tsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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(() => {
|
||||
|
|
@ -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}>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export * from "./widgets";
|
||||
export * from "./home-dashboard-widgets";
|
||||
export * from "./project-empty-state";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from "./issues-by-priority";
|
||||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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={[
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export * from "./workspace-dashboard";
|
||||
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from "./priority-distribution";
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
8
web/core/components/ui/graphs/types.d.ts
vendored
8
web/core/components/ui/graphs/types.d.ts
vendored
|
|
@ -1,8 +0,0 @@
|
|||
import { Theme, Margin } from "@nivo/core";
|
||||
|
||||
export type TGraph = {
|
||||
height?: string;
|
||||
width?: string;
|
||||
margin?: Partial<Margin>;
|
||||
theme?: Theme;
|
||||
};
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
export * from "./graphs";
|
||||
export * from "./empty-space";
|
||||
export * from "./labels-list";
|
||||
export * from "./markdown-to-component";
|
||||
|
|
|
|||
|
|
@ -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()}`;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
11
web/core/hooks/store/use-analytics.ts
Normal file
11
web/core/hooks/store/use-analytics.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue