refactor: dashboards components to resolve char related bugfixes (#2676)

This commit is contained in:
Aaryan Khandelwal
2025-03-07 20:13:29 +05:30
committed by GitHub
parent 78aea5a9ba
commit 3a249cfdbb
15 changed files with 171 additions and 68 deletions

View File

@@ -361,3 +361,8 @@ export const TEXT_WIDGET_Y_AXIS_METRICS_LIST: EWidgetYAxisMetric[] = [
EWidgetYAxisMetric.WORK_ITEM_DUE_THIS_WEEK_COUNT,
EWidgetYAxisMetric.WORK_ITEM_DUE_TODAY_COUNT,
];
export const TO_CAPITALIZE_PROPERTIES: EWidgetXAxisProperty[] = [
EWidgetXAxisProperty.PRIORITY,
EWidgetXAxisProperty.STATE_GROUPS,
];

View File

@@ -998,7 +998,7 @@
"common": {
"add_widget": "Add widget",
"widget_title": {
"label": "Widget title",
"label": "Name this widget",
"placeholder": "e.g., \"To-do yesterday\", \"All Complete\""
},
"chart_type": "Chart type",

View File

@@ -19,6 +19,7 @@ export const PieChart = React.memo(<K extends string, T extends string>(props: T
outerRadius,
showTooltip = true,
showLabel,
customLabel,
centerLabel,
} = props;
@@ -56,7 +57,30 @@ export const PieChart = React.memo(<K extends string, T extends string>(props: T
cy="50%"
innerRadius={innerRadius}
outerRadius={outerRadius}
label={!!showLabel}
label={
showLabel
? (payload) => {
const { cx, cy, fill, innerRadius, midAngle, outerRadius, value } = payload;
const RADIAN = Math.PI / 180;
const radius = 25 + innerRadius + (outerRadius - innerRadius);
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
return (
<text
className="text-xs font-medium"
x={x}
y={y}
fill={fill}
textAnchor={x > cx ? "start" : "end"}
dominantBaseline="central"
>
{customLabel?.(value) ?? value}
</text>
);
}
: undefined
}
>
{renderCells}
{centerLabel && (

View File

@@ -105,6 +105,7 @@ export type TPieChartProps<K extends string, T extends string> = Pick<
innerRadius?: number;
outerRadius?: number;
showLabel: boolean;
customLabel?: (value: any) => string;
centerLabel?: {
className?: string;
fill: string;

View File

@@ -28,7 +28,8 @@ export const DashboardsWidgetsGridRoot: React.FC<Props> = observer((props) => {
const { getDashboardById } = useDashboards();
// derived values
const dashboardDetails = getDashboardById(dashboardId);
const { allWidgetIds, layoutItems, updateWidgetsLayout } = dashboardDetails?.widgetsStore ?? {};
const { canCurrentUserEditDashboard, isViewModeEnabled, widgetsStore } = dashboardDetails ?? {};
const { allWidgetIds, layoutItems, updateWidgetsLayout } = widgetsStore ?? {};
const handleLayoutChange = useCallback(
async (_: Layout[], allLayouts: Layouts) => {
@@ -71,8 +72,8 @@ export const DashboardsWidgetsGridRoot: React.FC<Props> = observer((props) => {
margin={[32, 32]}
containerPadding={[0, 0]}
draggableHandle=".widget-drag-handle"
isDraggable
isResizable
isDraggable={!isViewModeEnabled && canCurrentUserEditDashboard && activeBreakpoint === EWidgetGridBreakpoints.MD}
isResizable={!isViewModeEnabled && canCurrentUserEditDashboard && activeBreakpoint === EWidgetGridBreakpoints.MD}
onBreakpointChange={handleBreakpointChange}
onLayoutChange={handleLayoutChange}
>

View File

@@ -6,7 +6,7 @@ import { useTheme } from "next-themes";
import { CHART_COLOR_PALETTES, DEFAULT_WIDGET_COLOR, EWidgetChartModels } from "@plane/constants";
import { TAreaChartWidgetConfig, TAreaItem } from "@plane/types";
// local imports
import { parseWidgetData, generateExtendedColors, TWidgetComponentProps } from ".";
import { generateExtendedColors, TWidgetComponentProps } from ".";
const AreaChart = dynamic(() =>
import("@plane/propel/charts/area-chart").then((mod) => ({
@@ -15,13 +15,12 @@ const AreaChart = dynamic(() =>
);
export const DashboardAreaChartWidget: React.FC<TWidgetComponentProps> = observer((props) => {
const { widget } = props;
const { parsedData, widget } = props;
// derived values
const { chart_model, data } = widget ?? {};
const { chart_model } = widget ?? {};
const widgetConfig = widget?.config as TAreaChartWidgetConfig | undefined;
const showLegends = !!widgetConfig?.show_legends;
const isComparisonModel = chart_model === EWidgetChartModels.COMPARISON;
const parsedData = parseWidgetData(data);
// next-themes
const { resolvedTheme } = useTheme();
// Get current palette colors and extend if needed

View File

@@ -6,7 +6,7 @@ import { useTheme } from "next-themes";
import { CHART_COLOR_PALETTES, DEFAULT_WIDGET_COLOR, EWidgetChartModels } from "@plane/constants";
import { TBarChartWidgetConfig, TBarItem } from "@plane/types";
// local imports
import { generateExtendedColors, parseWidgetData, TWidgetComponentProps } from ".";
import { generateExtendedColors, TWidgetComponentProps } from ".";
const BarChart = dynamic(() =>
import("@plane/propel/charts/bar-chart").then((mod) => ({
@@ -15,12 +15,11 @@ const BarChart = dynamic(() =>
);
export const DashboardBarChartWidget: React.FC<TWidgetComponentProps> = observer((props) => {
const { widget } = props;
const { parsedData, widget } = props;
// derived values
const { chart_model, data } = widget ?? {};
const { chart_model } = widget ?? {};
const widgetConfig = widget?.config as TBarChartWidgetConfig | undefined;
const showLegends = !!widgetConfig?.show_legends;
const parsedData = parseWidgetData(data);
// next-themes
const { resolvedTheme } = useTheme();
// Get current palette colors and extend if needed

View File

@@ -41,7 +41,7 @@ export const parseDonutChartData = (
};
export const DashboardDonutChartWidget: React.FC<TWidgetComponentProps> = observer((props) => {
const { widget } = props;
const { parsedData, widget } = props;
// derived values
const { chart_model, data, height, width } = widget ?? {};
const widgetConfig = widget?.config as TDonutChartWidgetConfig | undefined;
@@ -50,7 +50,10 @@ export const DashboardDonutChartWidget: React.FC<TWidgetComponentProps> = observ
const showLegends = !!widgetConfig?.show_legends && !isOfUnitHeight;
const legendPosition = (width ?? 1) >= (height ?? 1) ? "right" : "bottom";
const showCenterLabel = !!widgetConfig?.center_value;
const parsedData = useMemo(() => parseDonutChartData(data?.data, chart_model), [chart_model, data?.data]);
const donutParsedData = useMemo(() => {
const secondParse = parseDonutChartData(parsedData.data, chart_model);
return secondParse;
}, [chart_model, parsedData]);
const totalCount = data?.data?.reduce((acc, curr) => acc + curr.count, 0);
const totalCountDigits = totalCount?.toString().length ?? 1;
// next-themes
@@ -62,10 +65,10 @@ export const DashboardDonutChartWidget: React.FC<TWidgetComponentProps> = observ
const cells: TCellItem<string>[] = useMemo(() => {
let parsedCells: TCellItem<string>[];
const extendedColors = generateExtendedColors(baseColors ?? [], parsedData.length);
const extendedColors = generateExtendedColors(baseColors ?? [], donutParsedData.length);
if (chart_model === EWidgetChartModels.BASIC) {
parsedCells = parsedData.map((datum, index) => ({
parsedCells = donutParsedData.map((datum, index) => ({
key: datum.key,
className: "stroke-transparent",
fill: extendedColors[index],
@@ -87,7 +90,7 @@ export const DashboardDonutChartWidget: React.FC<TWidgetComponentProps> = observ
parsedCells = [];
}
return parsedCells;
}, [baseColors, chart_model, parsedData, resolvedTheme, widgetConfig]);
}, [baseColors, chart_model, donutParsedData, resolvedTheme, widgetConfig]);
if (!widget) return null;
@@ -100,7 +103,7 @@ export const DashboardDonutChartWidget: React.FC<TWidgetComponentProps> = observ
bottom: isOfUnitHeight ? 12 : 20,
left: 16,
}}
data={parsedData}
data={donutParsedData}
dataKey="count"
cells={cells}
innerRadius={isOfUnitHeight ? 10 : (height ?? 1) * 20}

View File

@@ -84,16 +84,17 @@ export const DashboardWidgetHeader: React.FC<Props> = observer((props) => {
<h5 className="text-sm font-medium text-custom-text-200 truncate">{widget.name}</h5>
</div>
<div className="flex-shrink-0 hidden group-hover/widget:flex items-center">
{!isViewModeEnabled && (
{!isViewModeEnabled && canCurrentUserEditWidget && (
<Tooltip tooltipContent="Edit">
<button
type="button"
className="grid place-items-center p-1 rounded text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80"
onClick={() => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!widget.id) return;
toggleEditWidget?.(widget.id);
}}
disabled={!canCurrentUserEditWidget}
>
<Pencil className="size-3.5" />
</button>
@@ -103,7 +104,11 @@ export const DashboardWidgetHeader: React.FC<Props> = observer((props) => {
<button
type="button"
className="grid place-items-center p-1 rounded text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80"
onClick={fetchWidgetData}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
fetchWidgetData();
}}
disabled={isFetchingData}
>
<RotateCw

View File

@@ -1,30 +1,36 @@
export * from "./root";
// plane imports
import { EWidgetXAxisProperty, TO_CAPITALIZE_PROPERTIES } from "@plane/constants";
import { TDashboardWidgetData, TDashboardWidgetDatum } from "@plane/types";
import { cn, hexToHsl, hslToHex } from "@plane/utils";
import { capitalizeFirstLetter, cn, hexToHsl, hslToHex } from "@plane/utils";
// plane web store
import { DashboardWidgetInstance } from "@/plane-web/store/dashboards/widget";
export type TWidgetComponentProps = {
parsedData: TDashboardWidgetData;
widget: DashboardWidgetInstance | undefined;
};
type TArgs = {
className?: string;
isEditingEnabled: boolean;
isSelected: boolean;
isResizingDisabled: boolean;
};
export const WIDGET_HEADER_HEIGHT = 36;
export const WIDGET_Y_SPACING = 8;
export const commonWidgetClassName = (args: TArgs) => {
const { className, isSelected } = args;
const { className, isEditingEnabled, isSelected, isResizingDisabled } = args;
const commonClassName = cn(
"group/widget dashboard-widget-item size-full rounded-lg bg-custom-background-100 border border-custom-border-200 transition-colors",
{
"selected border-custom-primary-100": isSelected,
"cursor-pointer": isEditingEnabled,
disabled: isResizingDisabled,
},
className
);
@@ -93,7 +99,11 @@ export const generateExtendedColors = (baseColorSet: string[], targetCount: numb
return colors.slice(0, targetCount);
};
export const parseWidgetData = (data: TDashboardWidgetData | null | undefined): TDashboardWidgetData => {
export const parseWidgetData = (
data: TDashboardWidgetData | null | undefined,
xAxisProperty: EWidgetXAxisProperty | null | undefined,
groupByProperty: EWidgetXAxisProperty | null | undefined
): TDashboardWidgetData => {
if (!data) {
return {
data: [],
@@ -106,13 +116,28 @@ export const parseWidgetData = (data: TDashboardWidgetData | null | undefined):
const keys = Object.keys(datum);
const missingKeys = allKeys.filter((key) => !keys.includes(key));
const missingValues: Record<string, number> = missingKeys.reduce((acc, key) => ({ ...acc, [key]: 0 }), {});
// capitalize first letter if xAxisProperty is PRIORITY or STATE_GROUPS and no groupByProperty is set
if (!groupByProperty && xAxisProperty && TO_CAPITALIZE_PROPERTIES.includes(xAxisProperty)) {
datum.name = capitalizeFirstLetter(datum.name);
}
return {
...datum,
...missingValues,
};
});
// capitalize first letter if groupByProperty is PRIORITY or STATE_GROUPS
const updatedSchema = schema;
if (groupByProperty && TO_CAPITALIZE_PROPERTIES.includes(groupByProperty)) {
Object.keys(updatedSchema).forEach((key) => {
updatedSchema[key] = capitalizeFirstLetter(updatedSchema[key]);
});
}
return {
data: updatedWidgetData,
schema,
schema: updatedSchema,
};
};

View File

@@ -6,7 +6,7 @@ import { useTheme } from "next-themes";
import { CHART_COLOR_PALETTES, DEFAULT_WIDGET_COLOR, EWidgetChartModels } from "@plane/constants";
import { TLineChartWidgetConfig, TLineItem } from "@plane/types";
// local imports
import { parseWidgetData, generateExtendedColors, TWidgetComponentProps } from ".";
import { generateExtendedColors, TWidgetComponentProps } from ".";
const LineChart = dynamic(() =>
import("@plane/propel/charts/line-chart").then((mod) => ({
@@ -15,12 +15,11 @@ const LineChart = dynamic(() =>
);
export const DashboardLineChartWidget: React.FC<TWidgetComponentProps> = observer((props) => {
const { widget } = props;
const { parsedData, widget } = props;
// derived values
const { chart_model, data } = widget ?? {};
const { chart_model } = widget ?? {};
const widgetConfig = widget?.config as TLineChartWidgetConfig | undefined;
const showLegends = !!widgetConfig?.show_legends;
const parsedData = parseWidgetData(data);
// next-themes
const { resolvedTheme } = useTheme();
// Get current palette colors and extend if needed

View File

@@ -57,14 +57,17 @@ const parsePieChartData = (
};
export const DashboardPieChartWidget: React.FC<TWidgetComponentProps> = observer((props) => {
const { widget } = props;
const { parsedData, widget } = props;
// derived values
const { data, height, width } = widget ?? {};
const { height, width } = widget ?? {};
const widgetConfig = widget?.config as TPieChartWidgetConfig | undefined;
const showLabels = !!widgetConfig?.show_values && height !== 1;
const showLegends = !!widgetConfig?.show_legends;
const legendPosition = (width ?? 1) >= (height ?? 1) ? "right" : "bottom";
const parsedData = useMemo(() => parsePieChartData(data?.data, widgetConfig), [data?.data, widgetConfig]);
const pieParsedData = useMemo(() => {
const secondParse = parsePieChartData(parsedData.data, widgetConfig);
return secondParse;
}, [parsedData, widgetConfig]);
// next-themes
const { resolvedTheme } = useTheme();
// Get current palette colors and extend if needed
@@ -73,8 +76,8 @@ export const DashboardPieChartWidget: React.FC<TWidgetComponentProps> = observer
];
const cells: TCellItem<string>[] = useMemo(() => {
const extendedColors = generateExtendedColors(baseColors ?? [], parsedData.length);
const parsedCells = parsedData.map((datum, index) => ({
const extendedColors = generateExtendedColors(baseColors ?? [], pieParsedData.length);
const parsedCells = pieParsedData.map((datum, index) => ({
key: datum.key,
className: "stroke-transparent",
fill: extendedColors[index],
@@ -83,7 +86,7 @@ export const DashboardPieChartWidget: React.FC<TWidgetComponentProps> = observer
if (widgetConfig?.group_thin_pieces) {
for (let i = 0; i < parsedCells.length; i++) {
const cellKey = parsedCells[i].key;
const doesKeyExist = parsedData.find((datum) => datum.key === cellKey);
const doesKeyExist = pieParsedData.find((datum) => datum.key === cellKey);
if (!doesKeyExist) {
parsedCells.splice(i, 1);
}
@@ -97,7 +100,7 @@ export const DashboardPieChartWidget: React.FC<TWidgetComponentProps> = observer
}
}
return parsedCells;
}, [baseColors, parsedData, widgetConfig]);
}, [baseColors, pieParsedData, widgetConfig]);
if (!widget) return null;
@@ -110,7 +113,7 @@ export const DashboardPieChartWidget: React.FC<TWidgetComponentProps> = observer
bottom: 20,
left: 16,
}}
data={parsedData}
data={pieParsedData}
dataKey="count"
cells={cells}
legend={
@@ -125,6 +128,12 @@ export const DashboardPieChartWidget: React.FC<TWidgetComponentProps> = observer
}
showTooltip={!!widgetConfig?.show_tooltip}
showLabel={showLabels}
customLabel={(val) => {
if (widgetConfig?.value_type === "percentage") {
return `${val}%`;
}
return val;
}}
/>
);
});

View File

@@ -1,4 +1,4 @@
import { useRef } from "react";
import { useMemo, useRef } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
@@ -15,7 +15,7 @@ import { DashboardWidgetHeader } from "./header";
import { DashboardLineChartWidget } from "./line-chart";
import { DashboardPieChartWidget } from "./pie-chart";
import { DashboardTextWidget } from "./text";
import { commonWidgetClassName, TWidgetComponentProps } from "./";
import { commonWidgetClassName, parseWidgetData, TWidgetComponentProps } from "./";
type Props = {
activeBreakpoint: EWidgetGridBreakpoints;
@@ -31,13 +31,22 @@ export const DashboardWidgetRoot: React.FC<Props> = observer((props) => {
const { getDashboardById } = useDashboards();
// derived values
const dashboardDetails = getDashboardById(dashboardId);
const { getWidgetById, isEditingWidget } = dashboardDetails?.widgetsStore ?? {};
const { isViewModeEnabled, widgetsStore } = dashboardDetails ?? {};
const { getWidgetById, isEditingWidget, toggleEditWidget } = widgetsStore ?? {};
const widget = getWidgetById?.(widgetId);
const { chart_type, data, fetchWidgetData, isConfigurationMissing } = widget ?? {};
const {
canCurrentUserEditWidget,
chart_type,
data,
fetchWidgetData,
isConfigurationMissing,
x_axis_property,
group_by,
} = widget ?? {};
const isWidgetSelected = isEditingWidget === widgetId;
const isWidgetConfigured = !isConfigurationMissing;
console.log("Re-rendering widget root");
const isEditingEnabled = !isViewModeEnabled && !!canCurrentUserEditWidget;
const parsedData = useMemo(() => parseWidgetData(data, x_axis_property, group_by), [data, group_by, x_axis_property]);
useSWR(
isWidgetConfigured && widgetId ? `WIDGET_DATA_${widgetId}` : null,
@@ -84,8 +93,15 @@ export const DashboardWidgetRoot: React.FC<Props> = observer((props) => {
<div
ref={widgetRef}
className={commonWidgetClassName({
isEditingEnabled,
isSelected: isWidgetSelected,
isResizingDisabled: !isEditingEnabled || activeBreakpoint === EWidgetGridBreakpoints.XXS,
})}
onClick={() => {
if (!isEditingEnabled || isEditingWidget === widgetId) return;
toggleEditWidget?.(widgetId);
}}
role={isEditingEnabled ? "button" : "none"}
>
<DashboardWidgetHeader dashboardId={dashboardId} widget={widget} widgetRef={widgetRef} />
<DashboardWidgetContent
@@ -95,7 +111,7 @@ export const DashboardWidgetRoot: React.FC<Props> = observer((props) => {
isDataEmpty={data?.data.length === 0}
widget={widget}
>
<WidgetComponent widget={widget} />
<WidgetComponent parsedData={parsedData} widget={widget} />
</DashboardWidgetContent>
</div>
);

View File

@@ -171,27 +171,44 @@ export class DashboardWidgetsStore implements IDashboardWidgetsStore {
});
get layoutItems() {
const widgetDetails = (this.allWidgetIds ?? []).map((widgetId) => {
const details = this.getWidgetById?.(widgetId);
return {
id: widgetId,
x: details?.x_axis_coord ?? 0,
y: details?.y_axis_coord ?? 0,
width: details?.width ?? 1,
height: details?.height ?? 1,
};
});
// sort widgets by y-axis first, then x-axis for XXS layout
const sortedWidgets = [...widgetDetails].sort((a, b) => {
if (a.y !== b.y) {
return a.y - b.y; // primary sort by y position
}
return a.x - b.x; // secondary sort by x position when y is the same
});
const layouts: Layouts = {
[EWidgetGridBreakpoints.XXS]: (this.allWidgetIds ?? []).map((widgetId, index) => ({
i: widgetId,
[EWidgetGridBreakpoints.XXS]: sortedWidgets.map((widget, index) => ({
i: widget.id,
x: 0,
y: index * 2,
w: 1,
h: 2,
resizeHandles: [],
})),
[EWidgetGridBreakpoints.MD]: (this.allWidgetIds ?? []).map((widgetId) => {
const widgetDetails = this.getWidgetById?.(widgetId);
return {
i: widgetId,
x: widgetDetails?.x_axis_coord ?? 0,
y: widgetDetails?.y_axis_coord ?? 0,
w: widgetDetails?.width ?? 1,
h: widgetDetails?.height ?? 1,
resizeHandles: ["nw", "ne", "se", "sw"],
};
}),
[EWidgetGridBreakpoints.MD]: widgetDetails.map((widget) => ({
i: widget.id,
x: widget.x,
y: widget.y,
w: widget.width,
h: widget.height,
resizeHandles: ["nw", "ne", "se", "sw"],
})),
};
return layouts;
}

View File

@@ -948,26 +948,26 @@ html.todesktop .header button {
transition: opacity 0.2s ease;
&.react-resizable-handle-nw {
top: 0 !important;
left: 0 !important;
top: 2px !important;
left: 2px !important;
transform: translate(-50%, -50%) !important;
cursor: nwse-resize !important;
}
&.react-resizable-handle-ne {
top: 0 !important;
right: 0 !important;
top: 2px !important;
right: 2px !important;
transform: translate(50%, -50%) !important;
cursor: nesw-resize !important;
}
&.react-resizable-handle-se {
bottom: 0 !important;
right: 0 !important;
bottom: 2px !important;
right: 2px !important;
transform: translate(50%, 50%) !important;
cursor: nwse-resize !important;
}
&.react-resizable-handle-sw {
bottom: 0 !important;
left: 0 !important;
bottom: 2px !important;
left: 2px !important;
transform: translate(-50%, 50%) !important;
cursor: nesw-resize !important;
}
@@ -983,7 +983,7 @@ html.todesktop .header button {
pointer-events: all;
}
&:not(.resizing) .dashboard-widget-item:not(.selected) {
&:not(.resizing) .dashboard-widget-item:not(.selected):not(.disabled) {
border-color: rgba(var(--color-border-400)) !important;
}
}