[WEB-4246] Analytics minor improvements (#7194)

* chore: updated label for epics

* chore: improved export logic

* refactor: move csvConfig to export.ts and clean up export logic

* refactor: remove unused CSV export logic from WorkItemsInsightTable component

* refactor: streamline data handling in InsightTable component for improved rendering

* feat: add translation for "No. of {entity}" and update priority chart y-axis label to use new translation

* refactor: cleaned up some component and added utilitites

* feat: add "at_risk" translation to multiple languages in translations.json files

* refactor: update TrendPiece component to use new status variants for analytics

* fix: adjust TrendPiece component logic for on-track and off-track status

* refactor: use nullish coalescing operator for yAxis.dx in line and scatter charts

* feat: add "at_risk" translation to various languages in translations.json files

* feat: add "no_of" translation to various languages in translations.json files

* feat: update "at_risk" translation in Ukrainian, Vietnamese, and Chinese locales in translations.json files
This commit is contained in:
JayashTripathy 2025-06-12 21:15:09 +05:30 committed by GitHub
parent ad11a34efc
commit c1a078ef3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 383 additions and 252 deletions

View file

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

View file

@ -39,7 +39,7 @@ const AnalyticsEmptyState = ({ title, description, assetPath, className }: Props
)}
<div className="flex flex-shrink flex-col items-center gap-1.5 text-center">
<h3 className={cn("text-xl font-semibold")}>{title}</h3>
{description && <p className="text-sm text-custom-text-300">{description}</p>}
{description && <p className="text-sm text-custom-text-300 max-w-[350px]">{description}</p>}
</div>
</div>
</div>

View file

@ -0,0 +1,26 @@
import { ColumnDef, Row } from "@tanstack/react-table";
import { download, generateCsv, mkConfig } from "export-to-csv";
export const csvConfig = (workspaceSlug: string) =>
mkConfig({
fieldSeparator: ",",
filename: `${workspaceSlug}-analytics`,
decimalSeparator: ".",
useKeysAsHeaders: true,
});
export const exportCSV = <T>(rows: Row<T>[], columns: ColumnDef<T>[], workspaceSlug: string) => {
const rowData = rows.map((row) => {
const exportColumns = columns.map((col) => col.meta?.export);
const cells = exportColumns.reduce((acc: Record<string, string | number>, col) => {
if (col) {
const cell = col?.value(row) ?? "-";
acc[col.label ?? col.key] = cell;
}
return acc;
}, {});
return cells;
});
const csv = generateCsv(csvConfig(workspaceSlug))(rowData);
download(csvConfig(workspaceSlug))(csv);
};

View file

@ -94,7 +94,7 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search"
value={table.getColumn(table.getHeaderGroups()?.[0].headers[0].id)?.getFilterValue() as string}
value={table.getColumn(table.getHeaderGroups()?.[0]?.headers?.[0]?.id)?.getFilterValue() as string}
onChange={(e) => {
const columnId = table.getHeaderGroups()?.[0]?.headers?.[0]?.id;
if (columnId) table.getColumn(columnId)?.setFilterValue(e.target.value);

View file

@ -26,24 +26,20 @@ export const InsightTable = <T extends Exclude<TAnalyticsTabsBase, "overview">>(
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>
)}
<DataTable
columns={columns}
data={data || []}
searchPlaceholder={`${data?.length || 0} ${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>
);
};

View file

@ -32,7 +32,7 @@ const ProjectInsights = observer(() => {
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-radar" });
const { data: projectInsightsData, isLoading: isLoadingProjectInsight } = useSWR(
`radar-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
`radar-chart-project-insights-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
() =>
analyticsService.getAdvanceAnalyticsCharts<TChartData<string, string>[]>(
workspaceSlug,

View file

@ -8,6 +8,8 @@ type Props = {
percentage: number;
className?: string;
size?: "xs" | "sm" | "md" | "lg";
trendIconVisible?: boolean;
variant?: "simple" | "outlined" | "tinted";
};
const sizeConfig = {
@ -29,16 +31,47 @@ const sizeConfig = {
},
} as const;
const variants: Record<NonNullable<Props["variant"]>, Record<"ontrack" | "offtrack" | "atrisk", string>> = {
simple: {
ontrack: "text-green-500",
offtrack: "text-yellow-500",
atrisk: "text-red-500",
},
outlined: {
ontrack: "text-green-500 border border-green-500",
offtrack: "text-yellow-500 border border-yellow-500",
atrisk: "text-red-500 border border-red-500",
},
tinted: {
ontrack: "text-green-500 bg-green-500/10",
offtrack: "text-yellow-500 bg-yellow-500/10",
atrisk: "text-red-500 bg-red-500/10",
},
} as const;
const TrendPiece = (props: Props) => {
const { percentage, className, size = "sm" } = props;
const isPositive = percentage > 0;
const { percentage, className, trendIconVisible = true, size = "sm", variant = "simple" } = props;
const isOnTrack = percentage >= 66;
const isOffTrack = percentage >= 33 && percentage < 66;
const config = sizeConfig[size];
return (
<div
className={cn("flex items-center gap-1", isPositive ? "text-green-500" : "text-red-500", config.text, className)}
className={cn(
"flex items-center gap-1 p-1 rounded-md",
variants[variant][isOnTrack ? "ontrack" : isOffTrack ? "offtrack" : "atrisk"],
config.text,
className
)}
>
{isPositive ? <TrendingUp className={config.icon} /> : <TrendingDown className={config.icon} />}
{trendIconVisible &&
(isOnTrack ? (
<TrendingUp className={config.icon} />
) : isOffTrack ? (
<TrendingDown className={config.icon} />
) : (
<TrendingDown className={config.icon} />
))}
{Math.round(Math.abs(percentage))}%
</div>
);

View file

@ -104,7 +104,7 @@ const CreatedVsResolved = observer(() => {
}}
yAxis={{
key: "count",
label: t("no_of", { entity: t("work_items") }),
label: t("no_of", { entity: isEpic ? t("epics") : t("work_items") }),
offset: -30,
dx: -22,
}}

View file

@ -1,6 +1,6 @@
import { useMemo } from "react";
import { ColumnDef, Row, Table } from "@tanstack/react-table";
import { mkConfig, generateCsv, download } from "export-to-csv";
import { ColumnDef, RowData, Table } from "@tanstack/react-table";
import { mkConfig } from "export-to-csv";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
@ -18,8 +18,8 @@ import {
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { BarChart } from "@plane/propel/charts/bar-chart";
import { IChartResponse } from "@plane/types";
import { TBarItem, TChart, TChartData, TChartDatum } from "@plane/types/src/charts";
import { ExportConfig } from "@plane/types";
import { TBarItem, TChart, TChartDatum } from "@plane/types/src/charts";
// plane web components
import { Button } from "@plane/ui";
import { generateExtendedColors, parseChartData } from "@/components/chart/utils";
@ -29,10 +29,17 @@ import { useAnalytics } from "@/hooks/store/use-analytics";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { AnalyticsService } from "@/services/analytics.service";
import AnalyticsEmptyState from "../empty-state";
import { exportCSV } from "../export";
import { DataTable } from "../insight-table/data-table";
import { ChartLoader } from "../loaders";
import { generateBarColor } from "./utils";
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
export: ExportConfig<TData>;
}
}
interface Props {
x_axis: ChartXAxisProperty;
y_axis: ChartYAxisMetric;
@ -146,11 +153,25 @@ const PriorityChart = observer((props: Props) => {
{
accessorKey: "name",
header: () => xAxisLabel,
meta: {
export: {
key: xAxisLabel,
value: (row) => row.original.name,
label: xAxisLabel,
},
},
},
{
accessorKey: "count",
header: () => <div className="text-right">Count</div>,
cell: ({ row }) => <div className="text-right">{row.original.count}</div>,
meta: {
export: {
key: "Count",
value: (row) => row.original.count,
label: "Count",
},
},
},
],
[xAxisLabel]
@ -163,40 +184,18 @@ const PriorityChart = observer((props: Props) => {
accessorKey: key,
header: () => <div className="text-right">{parsedData.schema[key]}</div>,
cell: ({ row }) => <div className="text-right">{row.original[key]}</div>,
meta: {
export: {
key,
value: (row) => row.original[key],
label: parsedData.schema[key],
},
},
}))
: [],
[parsedData]
);
const csvConfig = mkConfig({
fieldSeparator: ",",
filename: `${workspaceSlug}-analytics`,
decimalSeparator: ".",
useKeysAsHeaders: true,
});
const exportCSV = (rows: Row<TChartDatum>[]) => {
const rowData = rows.map((row) => {
const hiddenFields = ["key", "avatar_url", "assignee_id", "project_id"];
const otherFields = Object.keys(row.original).filter(
(key) => key !== "name" && key !== "count" && !hiddenFields.includes(key) && !key.includes("id")
);
return {
name: row.original.name,
count: row.original.count,
...otherFields.reduce(
(acc, key) => {
acc[parsedData?.schema[key] ?? key] = row.original[key];
return acc;
},
{} as Record<string, string | number>
),
};
});
const csv = generateCsv(csvConfig)(rowData);
download(csvConfig)(csv);
};
return (
<div className="flex flex-col gap-12 ">
{priorityChartLoading ? (
@ -217,7 +216,7 @@ const PriorityChart = observer((props: Props) => {
}}
yAxis={{
key: "count",
label: yAxisLabel,
label: t("no_of", { entity: yAxisLabel.replace("_", " ") }),
offset: -40,
dx: -26,
}}
@ -230,7 +229,7 @@ const PriorityChart = observer((props: Props) => {
<Button
variant="accent-primary"
prependIcon={<Download className="h-3.5 w-3.5" />}
onClick={() => exportCSV(table.getFilteredRowModel().rows)}
onClick={() => exportCSV(table.getRowModel().rows, [...defaultColumns, ...columns], workspaceSlug)}
>
<div>{t("exporter.csv.short_description")}</div>
</Button>

View file

@ -1,13 +1,12 @@
import { useMemo, useCallback } from "react";
import { ColumnDef, Row } from "@tanstack/react-table";
import { download, generateCsv } from "export-to-csv";
import { useMemo } from "react";
import { ColumnDef, Row, RowData } from "@tanstack/react-table";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { Briefcase, UserRound } from "lucide-react";
// plane package imports
import { useTranslation } from "@plane/i18n";
import { WorkItemInsightColumns, AnalyticsTableDataMap } from "@plane/types";
import { WorkItemInsightColumns, AnalyticsTableDataMap, ExportConfig } from "@plane/types";
// plane web components
import { Avatar } from "@plane/ui";
import { getFileURL } from "@plane/utils";
@ -17,11 +16,17 @@ import { useAnalytics } from "@/hooks/store/use-analytics";
import { useProject } from "@/hooks/store/use-project";
import { AnalyticsService } from "@/services/analytics.service";
// plane web components
import { csvConfig } from "../config";
import { exportCSV } from "../export";
import { InsightTable } from "../insight-table";
const analyticsService = new AnalyticsService();
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
export: ExportConfig<TData>;
}
}
const WorkItemsInsightTable = observer(() => {
// router
const params = useParams();
@ -60,104 +65,125 @@ const WorkItemsInsightTable = observer(() => {
}),
[t]
);
const columns = useMemo(
() =>
[
!isPeekView
? {
accessorKey: "project__name",
header: () => <div className="text-left">{columnsLabels["project__name"]}</div>,
cell: ({ row }) => {
const project = getProjectById(row.original.project_id);
return (
<div className="flex items-center gap-2">
{project?.logo_props ? (
<Logo logo={project.logo_props} size={18} />
) : (
<Briefcase className="h-4 w-4" />
)}
{project?.name}
</div>
);
},
}
: {
accessorKey: "display_name",
header: () => <div className="text-left">{columnsLabels["display_name"]}</div>,
cell: ({ row }: { row: Row<WorkItemInsightColumns> }) => (
<div className="text-left">
<div className="flex items-center gap-2">
{row.original.avatar_url && row.original.avatar_url !== "" ? (
<Avatar
name={row.original.display_name}
src={getFileURL(row.original.avatar_url)}
size={24}
shape="circle"
/>
) : (
<div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-custom-background-80 capitalize overflow-hidden">
{row.original.display_name ? (
row.original.display_name?.[0]
) : (
<UserRound className="text-custom-text-200 " size={12} />
)}
</div>
)}
<span className="break-words text-custom-text-200">
{row.original.display_name ?? t(`Unassigned`)}
</span>
</div>
const columns: ColumnDef<AnalyticsTableDataMap["work-items"]>[] = useMemo(
() => [
!isPeekView
? {
accessorKey: "project__name",
header: () => <div className="text-left">{columnsLabels["project__name"]}</div>,
cell: ({ row }) => {
const project = getProjectById(row.original.project_id);
return (
<div className="flex items-center gap-2">
{project?.logo_props ? (
<Logo logo={project.logo_props} size={18} />
) : (
<Briefcase className="h-4 w-4" />
)}
{project?.name}
</div>
),
);
},
{
accessorKey: "backlog_work_items",
header: () => <div className="text-right">{columnsLabels["backlog_work_items"]}</div>,
cell: ({ row }) => <div className="text-right">{row.original.backlog_work_items}</div>,
meta: {
export: {
key: columnsLabels["project__name"],
value: (row) => row.original.project__name?.toString() ?? "",
},
},
}
: {
accessorKey: "display_name",
header: () => <div className="text-left">{columnsLabels["display_name"]}</div>,
cell: ({ row }: { row: Row<WorkItemInsightColumns> }) => (
<div className="text-left">
<div className="flex items-center gap-2">
{row.original.avatar_url && row.original.avatar_url !== "" ? (
<Avatar
name={row.original.display_name}
src={getFileURL(row.original.avatar_url)}
size={24}
shape="circle"
/>
) : (
<div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-custom-background-80 capitalize overflow-hidden">
{row.original.display_name ? (
row.original.display_name?.[0]
) : (
<UserRound className="text-custom-text-200 " size={12} />
)}
</div>
)}
<span className="break-words text-custom-text-200">
{row.original.display_name ?? t(`Unassigned`)}
</span>
</div>
</div>
),
meta: {
export: {
key: columnsLabels["display_name"],
value: (row) => row.original.display_name?.toString() ?? "",
},
},
},
{
accessorKey: "backlog_work_items",
header: () => <div className="text-right">{columnsLabels["backlog_work_items"]}</div>,
cell: ({ row }) => <div className="text-right">{row.original.backlog_work_items}</div>,
meta: {
export: {
key: columnsLabels["backlog_work_items"],
value: (row) => row.original.backlog_work_items.toString(),
},
},
{
accessorKey: "started_work_items",
header: () => <div className="text-right">{columnsLabels["started_work_items"]}</div>,
cell: ({ row }) => <div className="text-right">{row.original.started_work_items}</div>,
},
{
accessorKey: "started_work_items",
header: () => <div className="text-right">{columnsLabels["started_work_items"]}</div>,
cell: ({ row }) => <div className="text-right">{row.original.started_work_items}</div>,
meta: {
export: {
key: columnsLabels["started_work_items"],
value: (row) => row.original.started_work_items.toString(),
},
},
{
accessorKey: "un_started_work_items",
header: () => <div className="text-right">{columnsLabels["un_started_work_items"]}</div>,
cell: ({ row }) => <div className="text-right">{row.original.un_started_work_items}</div>,
},
{
accessorKey: "un_started_work_items",
header: () => <div className="text-right">{columnsLabels["un_started_work_items"]}</div>,
cell: ({ row }) => <div className="text-right">{row.original.un_started_work_items}</div>,
meta: {
export: {
key: columnsLabels["un_started_work_items"],
value: (row) => row.original.un_started_work_items.toString(),
},
},
{
accessorKey: "completed_work_items",
header: () => <div className="text-right">{columnsLabels["completed_work_items"]}</div>,
cell: ({ row }) => <div className="text-right">{row.original.completed_work_items}</div>,
},
{
accessorKey: "completed_work_items",
header: () => <div className="text-right">{columnsLabels["completed_work_items"]}</div>,
cell: ({ row }) => <div className="text-right">{row.original.completed_work_items}</div>,
meta: {
export: {
key: columnsLabels["completed_work_items"],
value: (row) => row.original.completed_work_items.toString(),
},
},
{
accessorKey: "cancelled_work_items",
header: () => <div className="text-right">{columnsLabels["cancelled_work_items"]}</div>,
cell: ({ row }) => <div className="text-right">{row.original.cancelled_work_items}</div>,
},
{
accessorKey: "cancelled_work_items",
header: () => <div className="text-right">{columnsLabels["cancelled_work_items"]}</div>,
cell: ({ row }) => <div className="text-right">{row.original.cancelled_work_items}</div>,
meta: {
export: {
key: columnsLabels["cancelled_work_items"],
value: (row) => row.original.cancelled_work_items.toString(),
},
},
] as ColumnDef<AnalyticsTableDataMap["work-items"]>[],
},
],
[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"
@ -166,7 +192,7 @@ const WorkItemsInsightTable = observer(() => {
columns={columns}
columnsLabels={columnsLabels}
headerText={isPeekView ? t("common.assignee") : t("common.projects")}
onExport={exportCSV}
onExport={(rows) => workItemsData && exportCSV(rows, columns, workspaceSlug)}
/>
);
});