fix(dashboards): chart removal flow + add-charts dialog focus ring (ENG-901, ENG-903)

ENG-901: clicking Remove on a chart widget no longer enters dashboard edit mode
(which surfaced the rename input) and saving with the last widget removed no longer
surfaces a raw "widgets: Too small" zod error. Out of edit mode, Remove now goes
through a DeleteDialog -> dedicated removeWidgetFromDashboardAction. The batched
update path also allows an empty widgets array now that the lib already supports
deleting all widgets correctly.

ENG-903: add p-1 to AddExistingChartsDialog DialogBody so the focus ring on the
inner search input is no longer clipped by the body's overflow boundary.
This commit is contained in:
Dhruwang
2026-05-12 12:51:46 +05:30
parent 6777b284b3
commit ddd2d5e983
21 changed files with 173 additions and 17 deletions
+1
View File
@@ -1743,6 +1743,7 @@ checksums:
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
workspace/analysis/charts/update_chart: 7d9223335d9f0c5938ec30356d7034a9
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
workspace/analysis/dashboards/chart_removed: 1ce20b8ee0b56bcd7d6fea2b5c1ae9fd
workspace/analysis/dashboards/charts_add_failed: c4fda79ede798ab6747a989f083a0947
workspace/analysis/dashboards/charts_add_partial_failure: b1a9fc6fe18ab20fe16c16e91a05c195
workspace/analysis/dashboards/charts_added_to_dashboard: 917abab80231adf5af51e812352fc77b
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count} Diagramm(e) hinzufügen",
"chart_removed": "Diagramm vom Dashboard entfernt",
"charts_add_failed": "Diagramme konnten nicht zum Dashboard hinzugefügt werden",
"charts_add_partial_failure": "{count} Diagramm(e) konnten nicht hinzugefügt werden",
"charts_added_to_dashboard": "Diagramme zum Dashboard hinzugefügt",
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Add {count} chart(s)",
"chart_removed": "Chart removed from dashboard",
"charts_add_failed": "Failed to add charts to dashboard",
"charts_add_partial_failure": "Failed to add {count} chart(s)",
"charts_added_to_dashboard": "Charts added to dashboard",
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Añadir {count} gráfico(s)",
"chart_removed": "Gráfico eliminado del panel",
"charts_add_failed": "Error al añadir gráficos al panel",
"charts_add_partial_failure": "Error al añadir {count} gráfico(s)",
"charts_added_to_dashboard": "Gráficos añadidos al panel",
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Ajouter {count} graphique(s)",
"chart_removed": "Graphique retiré du tableau de bord",
"charts_add_failed": "Échec de l'ajout des graphiques au tableau de bord",
"charts_add_partial_failure": "Échec de l'ajout de {count} graphique(s)",
"charts_added_to_dashboard": "Graphiques ajoutés au tableau de bord",
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count} diagram hozzáadása",
"chart_removed": "A diagram eltávolítva a műszerfalról",
"charts_add_failed": "A diagramok műszerfalhoz való hozzáadása sikertelen",
"charts_add_partial_failure": "{count} diagram hozzáadása sikertelen",
"charts_added_to_dashboard": "Diagramok hozzáadva a műszerfalhoz",
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count}個のグラフを追加",
"chart_removed": "チャートがダッシュボードから削除されました",
"charts_add_failed": "ダッシュボードへのグラフの追加に失敗しました",
"charts_add_partial_failure": "{count}個のグラフの追加に失敗しました",
"charts_added_to_dashboard": "グラフをダッシュボードに追加しました",
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count} grafiek(en) toevoegen",
"chart_removed": "Grafiek verwijderd van dashboard",
"charts_add_failed": "Grafieken toevoegen aan dashboard mislukt",
"charts_add_partial_failure": "{count} grafiek(en) toevoegen mislukt",
"charts_added_to_dashboard": "Grafieken toegevoegd aan dashboard",
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Adicionar {count} gráfico(s)",
"chart_removed": "Gráfico removido do painel",
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Adicionar {count} gráfico(s)",
"chart_removed": "Gráfico removido do painel",
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Adaugă {count} grafic(e)",
"chart_removed": "Graficul a fost eliminat din tabloul de bord",
"charts_add_failed": "Nu s-au putut adăuga graficele la panoul de control",
"charts_add_partial_failure": "Nu s-au putut adăuga {count} grafic(e)",
"charts_added_to_dashboard": "Grafice adăugate la panoul de control",
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Добавить {count} график(ов)",
"chart_removed": "График удалён с панели",
"charts_add_failed": "Не удалось добавить графики на дашборд",
"charts_add_partial_failure": "Не удалось добавить {count} график(ов)",
"charts_added_to_dashboard": "Графики добавлены на дашборд",
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Lägg till {count} diagram",
"chart_removed": "Diagram borttaget från instrumentpanelen",
"charts_add_failed": "Misslyckades med att lägga till diagram i instrumentpanelen",
"charts_add_partial_failure": "Misslyckades med att lägga till {count} diagram",
"charts_added_to_dashboard": "Diagram tillagda i instrumentpanelen",
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count} grafik ekle",
"chart_removed": "Grafik gösterge panosundan kaldırıldı",
"charts_add_failed": "Grafikler panoya eklenemedi",
"charts_add_partial_failure": "{count} grafik eklenemedi",
"charts_added_to_dashboard": "Grafikler panoya eklendi",
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "添加 {count} 个图表",
"chart_removed": "图表已从仪表板中移除",
"charts_add_failed": "添加图表到仪表板失败",
"charts_add_partial_failure": "添加 {count} 个图表失败",
"charts_added_to_dashboard": "图表已添加到仪表板",
+1
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "新增 {count} 個圖表",
"chart_removed": "圖表已從儀表板移除",
"charts_add_failed": "無法將圖表新增至儀表板",
"charts_add_partial_failure": "無法新增 {count} 個圖表",
"charts_added_to_dashboard": "圖表已新增至儀表板",
@@ -18,6 +18,7 @@ import {
duplicateDashboard,
getDashboard,
getDashboards,
removeWidgetFromDashboard,
updateDashboard,
updateWidgetLayouts,
} from "./lib/dashboards";
@@ -111,15 +112,13 @@ export const updateDashboardAction = authenticatedActionClient.inputSchema(ZUpda
const ZUpdateWidgetLayoutsAction = z.object({
workspaceId: ZId,
dashboardId: ZId,
widgets: z
.array(
z.object({
id: ZId,
layout: ZWidgetLayout,
order: z.number().int().nonnegative(),
})
)
.min(1),
widgets: z.array(
z.object({
id: ZId,
layout: ZWidgetLayout,
order: z.number().int().nonnegative(),
})
),
});
export const updateWidgetLayoutsAction = authenticatedActionClient
@@ -325,3 +324,44 @@ export const addChartToDashboardAction = authenticatedActionClient
}
)
);
const ZRemoveWidgetFromDashboardAction = z.object({
workspaceId: ZId,
dashboardId: ZId,
widgetId: ZId,
});
export const removeWidgetFromDashboardAction = authenticatedActionClient
.inputSchema(ZRemoveWidgetFromDashboardAction)
.action(
withAuditLogging(
"deleted",
"dashboardWidget",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZRemoveWidgetFromDashboardAction>;
}) => {
const { organizationId, workspaceId } = await checkWorkspaceAccess(
ctx.user.id,
parsedInput.workspaceId,
"readWrite"
);
await checkDashboardsEnabled(organizationId);
const widget = await removeWidgetFromDashboard(
parsedInput.dashboardId,
workspaceId,
parsedInput.widgetId
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.workspaceId = workspaceId;
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
ctx.auditLoggingCtx.oldObject = widget;
return { success: true };
}
)
);
@@ -134,7 +134,7 @@ export function AddExistingChartsDialog({
<DialogTitle>{t("common.add_charts")}</DialogTitle>
<DialogDescription>{t("common.add_existing_chart_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<DialogBody className="p-1">
{isLoading ? (
<div className="flex items-center justify-center rounded-md border px-3 py-2">
<Loader2Icon className="h-5 w-5 animate-spin text-slate-400" />
@@ -17,10 +17,15 @@ import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/das
import { DashboardWidgetData } from "@/modules/ee/analysis/dashboards/components/dashboard-widget-data";
import { DashboardWidgetSkeleton } from "@/modules/ee/analysis/dashboards/components/dashboard-widget-skeleton";
import type { TChartDataRow, TDashboardDetail, TDashboardWidget } from "@/modules/ee/analysis/types/analysis";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { updateDashboardAction, updateWidgetLayoutsAction } from "../actions";
import {
removeWidgetFromDashboardAction,
updateDashboardAction,
updateWidgetLayoutsAction,
} from "../actions";
import type { TDashboardWidgetError } from "../lib/widget-errors";
const ROW_HEIGHT = 80;
@@ -163,6 +168,8 @@ export function DashboardDetailClient({
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [editingChartId, setEditingChartId] = useState<string | null>(null);
const [widgetIdToRemove, setWidgetIdToRemove] = useState<string | null>(null);
const [isRemovingWidget, setIsRemovingWidget] = useState(false);
const [, startTransition] = useTransition();
const [name, setName] = useState(dashboard.name);
@@ -207,17 +214,36 @@ export function DashboardDetailClient({
const handleRemoveWidgetFromMenu = useCallback(
(widgetId: string) => {
if (!isEditing) {
setDraftWidgets((current) => (current ?? dashboard.widgets).filter((w) => w.id !== widgetId));
setIsEditing(true);
if (isEditing) {
handleRemoveWidget(widgetId);
return;
}
handleRemoveWidget(widgetId);
setWidgetIdToRemove(widgetId);
},
[dashboard.widgets, handleRemoveWidget, isEditing]
[handleRemoveWidget, isEditing]
);
const handleConfirmRemoveWidget = useCallback(async () => {
if (!widgetIdToRemove) return;
setIsRemovingWidget(true);
try {
const result = await removeWidgetFromDashboardAction({
workspaceId,
dashboardId: dashboard.id,
widgetId: widgetIdToRemove,
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("workspace.analysis.dashboards.chart_removed"));
setWidgetIdToRemove(null);
startTransition(() => router.refresh());
} finally {
setIsRemovingWidget(false);
}
}, [widgetIdToRemove, workspaceId, dashboard.id, router, t, startTransition]);
const handleCancel = useCallback(() => {
setName(dashboard.name);
setDraftWidgets(null);
@@ -373,6 +399,17 @@ export function DashboardDetailClient({
aiUnavailableReason={aiUnavailableReason}
/>
)}
{!isReadOnly && (
<DeleteDialog
open={widgetIdToRemove !== null}
setOpen={(open) => {
if (!open) setWidgetIdToRemove(null);
}}
deleteWhat={t("common.chart")}
onDelete={handleConfirmRemoveWidget}
isDeleting={isRemovingWidget}
/>
)}
</PageContentWrapper>
);
}
@@ -18,9 +18,11 @@ var mockTxChart: { findFirst: ReturnType<typeof vi.fn> }; // NOSONAR / test code
var mockTxWidget: {
// NOSONAR / test code
aggregate: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
deleteMany: ReturnType<typeof vi.fn>;
};
@@ -29,9 +31,11 @@ vi.mock("@formbricks/database", () => {
const txChart = { findFirst: vi.fn() };
const txWidget = {
aggregate: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
};
mockTxDashboard = txDash;
@@ -672,4 +676,33 @@ describe("Dashboard Service", () => {
});
});
});
describe("removeWidgetFromDashboard", () => {
const mockWidgetId = "widget-abc-123";
test("deletes a widget that belongs to the dashboard", async () => {
const mockWidget = { id: mockWidgetId, dashboardId: mockDashboardId, chartId: mockChartId };
mockTxWidget.findFirst.mockResolvedValue(mockWidget);
mockTxWidget.delete.mockResolvedValue(mockWidget);
const { removeWidgetFromDashboard } = await import("./dashboards");
const result = await removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId);
expect(result).toEqual(mockWidget);
expect(mockTxWidget.findFirst).toHaveBeenCalledWith({
where: { id: mockWidgetId, dashboard: { id: mockDashboardId, workspaceId: mockWorkspaceId } },
});
expect(mockTxWidget.delete).toHaveBeenCalledWith({ where: { id: mockWidgetId } });
});
test("throws ResourceNotFoundError when the widget is not on the dashboard", async () => {
mockTxWidget.findFirst.mockResolvedValue(null);
const { removeWidgetFromDashboard } = await import("./dashboards");
await expect(
removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)
).rejects.toMatchObject({ name: "ResourceNotFoundError", resourceType: "DashboardWidget" });
expect(mockTxWidget.delete).not.toHaveBeenCalled();
});
});
});
@@ -301,6 +301,36 @@ export const updateWidgetLayouts = async (
}
};
export const removeWidgetFromDashboard = async (
dashboardId: string,
workspaceId: string,
widgetId: string
) => {
validateInputs([dashboardId, ZId], [workspaceId, ZId], [widgetId, ZId]);
try {
return await prisma.$transaction(async (tx) => {
const widget = await tx.dashboardWidget.findFirst({
where: { id: widgetId, dashboard: { id: dashboardId, workspaceId } },
});
if (!widget) {
throw new ResourceNotFoundError("DashboardWidget", widgetId);
}
return tx.dashboardWidget.delete({ where: { id: widgetId } });
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const addChartToDashboard = async (data: TAddWidgetInput) => {
validateInputs([data, ZAddWidgetInput]);