Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions app/e2e/chart-export.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,19 @@ test.describe("Chart export actions (issue #872)", () => {
await echartsCard.hover();
await echartsCard.getByRole("button", { name: "Widget actions" }).click();

// Both export actions present
await expect(
page.getByRole("menuitem", { name: "Export PNG" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Export SVG" }),
).toBeVisible();
// #912: Export formats now live inside an "Export ▸" submenu.
// Open the submenu via the parent menuitem.
const exportTrigger = page.getByRole("menuitem", { name: "Export" });
await expect(exportTrigger).toBeVisible();
await exportTrigger.hover();

// Clicking Export PNG triggers a .png download
// Both formats render as children of the submenu
await expect(page.getByRole("menuitem", { name: "PNG" })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "SVG" })).toBeVisible();

// Clicking PNG triggers a .png download
const downloadPromise = page.waitForEvent("download");
await page.getByRole("menuitem", { name: "Export PNG" }).click();
await page.getByRole("menuitem", { name: "PNG" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.png$/);
});
Expand Down
131 changes: 17 additions & 114 deletions app/e2e/widget-library.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,24 @@ test.describe("Widget Library", () => {
test("can save a widget as a template and see it in Widget Library", async ({
page,
}) => {
// Open widget actions menu → "Save to Widget Library"
// #913: Save-as-template moved into the widget editor modal.
// Flow: widget actions → Edit Widget → modal footer "Save as new template" → fill dialog.
const widgetCard = page.locator("[data-testid='widget-card']").first();
await widgetCard.hover();
await widgetCard.getByRole("button", { name: "Widget actions" }).click();
await page
.getByRole("menuitem", { name: "Save to Widget Library" })
await page.getByRole("menuitem", { name: "Edit Widget" }).click();

// Editor modal opens
const editorModal = page.getByRole("dialog", { name: "Edit Widget" });
await expect(editorModal).toBeVisible();

// Click the footer "Save as new template" button
await editorModal
.getByRole("button", { name: "Save as new template" })
.click();

// Save Template dialog should appear
// Editor closes; SaveTemplateDialog opens
await expect(editorModal).not.toBeVisible();
const saveDialog = page.getByRole("dialog", {
name: "Save to Widget Library",
});
Expand Down Expand Up @@ -635,116 +644,10 @@ test.describe("Widget Library", () => {
});
});

// ── Save to Widget Library from view mode ─────────────────────────────

test.describe("Save to Widget Library from view mode", () => {
test("action is visible on widget menu in view mode", async ({
authPage,
page,
}) => {
await authPage.login(ALICE.email, ALICE.password);

// Navigate to Movie Analytics dashboard (view mode, not edit)
const res = await page.request.get("/api/dashboards");
const dashboards = (await res.json()).data;
const movieAnalytics = (
dashboards as { id: string; name: string }[]
).find((d) => d.name === "Movie Analytics");
expect(movieAnalytics).toBeTruthy();
await page.goto(`/${movieAnalytics!.id}`);

// Open widget actions menu
const widgetCard = page.locator("[data-testid='widget-card']").first();
await expect(widgetCard).toBeVisible({ timeout: 15_000 });
await widgetCard.hover();
await widgetCard.getByRole("button", { name: "Widget actions" }).click();

await expect(
page.getByRole("menuitem", { name: "Save to Widget Library" }),
).toBeVisible();
});

test("can save a widget from view mode and see it in Widget Library", async ({
authPage,
page,
}) => {
test.setTimeout(60_000);
await authPage.login(ALICE.email, ALICE.password);

const res = await page.request.get("/api/dashboards");
const dashboards = (await res.json()).data;
const movieAnalytics = (
dashboards as { id: string; name: string }[]
).find((d) => d.name === "Movie Analytics");
expect(movieAnalytics).toBeTruthy();
await page.goto(`/${movieAnalytics!.id}`);

// Open widget actions → Save to Widget Library
const widgetCard = page.locator("[data-testid='widget-card']").first();
await expect(widgetCard).toBeVisible({ timeout: 15_000 });
await widgetCard.hover();
await widgetCard.getByRole("button", { name: "Widget actions" }).click();
await page
.getByRole("menuitem", { name: "Save to Widget Library" })
.click();

// Fill and submit
const saveDialog = page.getByRole("dialog", {
name: "Save to Widget Library",
});
await expect(saveDialog).toBeVisible();

const templateName = `View Mode Template ${Date.now()}`;
await saveDialog.getByLabel("Name").fill(templateName);
await saveDialog.getByRole("button", { name: "Save Template" }).click();
await expect(saveDialog).not.toBeVisible();

// Verify in Widget Library
await page.goto("/widget-library");
await expect(page.getByText(templateName)).toBeVisible({
timeout: 10_000,
});

// Clean up
const templatesRes = await page.request.get("/api/widget-templates");
const templates = (await templatesRes.json()).data;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const saved = templates.find((t: any) => t.name === templateName);
if (saved) {
await page.request.delete(`/api/widget-templates/${saved.id}`);
}
});

test("reader role does not see Save to Widget Library action", async ({
authPage,
page,
}) => {
test.setTimeout(60_000);
await authPage.login(CAROL.email, CAROL.password);

// Navigate to Movie Analytics (shared/public dashboard)
const res = await page.request.get("/api/dashboards");
const dashboards = (await res.json()).data;
const movieAnalytics = (
dashboards as { id: string; name: string }[]
).find((d) => d.name === "Movie Analytics");
expect(movieAnalytics).toBeTruthy();
await page.goto(`/${movieAnalytics!.id}`);

const widgetCard = page.locator("[data-testid='widget-card']").first();
await expect(widgetCard).toBeVisible({ timeout: 15_000 });
await widgetCard.hover();
await widgetCard.getByRole("button", { name: "Widget actions" }).click();

// Export CSV should be visible, but Save to Widget Library should NOT
await expect(
page.getByRole("menuitem", { name: "Export CSV" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Save to Widget Library" }),
).not.toBeVisible();
});
});
// #913: "Save to Widget Library" moved from the widget action dropdown into
// the widget editor modal footer. The old "from view mode" tests are no
// longer applicable — view-mode users must enter Edit to access the action.
// Reader role still can't reach it: they can't open the editor either.

// ── Widget Library consumption: duplicate, filter, search ───────────────

Expand Down
3 changes: 2 additions & 1 deletion app/src/app/(dashboard)/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,8 @@ export default function DashboardEditorPage({
editorMode === "edit" ? cachedPreviewData : undefined
}
canWrite={session?.user?.canWrite !== false}
// #913: opens SaveTemplateDialog from the modal footer.
onSaveAsTemplate={(w) => setTemplateWidget(w)}
/>

{templateWidget &&
Expand Down Expand Up @@ -625,7 +627,6 @@ export default function DashboardEditorPage({
updateWidget(widgetId, { ...target, settings });
},
onNavigateToPage: handleNavigateToPage,
onSaveAsTemplate: setTemplateWidget,
onSyncWidget: handleSyncWidget,
onDetachWidget: handleDetachWidget,
}}
Expand Down
32 changes: 2 additions & 30 deletions app/src/app/(dashboard)/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import React, {
useTransition,
} from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import {
ArrowLeft,
Filter,
Expand All @@ -26,9 +25,6 @@ import { scrollToWidgetWhenReady } from "@/lib/widget/scroll-to-widget";
import { parseUrlParams, buildUrlParams } from "@/lib/shared/url-params";
import { DashboardContainer } from "@/components/dashboard-container";
import { DashboardErrorBoundary } from "@/components/dashboard-error-boundary";
import { SaveTemplateDialog } from "@/components/save-template-dialog";
import { useConnections } from "@/hooks/use-connections";
import type { DashboardWidget } from "@/lib/db/schema";
import { PageTabs } from "@/components/page-tabs";
import { migrateLayout } from "@/lib/dashboard/migrate-layout";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
Expand Down Expand Up @@ -78,8 +74,6 @@ export default function DashboardViewerPage({
}) {
const { id } = use(params);
const router = useRouter();
const { data: session } = useSession();
const canWrite = session?.user?.canWrite !== false;
const saveToDashboard = useParameterStore((s) => s.saveToDashboard);
const restoreFromDashboard = useParameterStore((s) => s.restoreFromDashboard);
const prevDashboardId = useRef<string | null>(null);
Expand Down Expand Up @@ -179,10 +173,8 @@ export default function DashboardViewerPage({
// null = auto mode (show when params exist), boolean = user override
const [barOverride, setBarOverride] = useState<boolean | null>(null);
const effectiveShowBar = barOverride !== null ? barOverride : hasParameters;
const [templateWidget, setTemplateWidget] = useState<
DashboardWidget | undefined
>();
const { data: connectionsData } = useConnections();
// Save-as-template moved to the widget editor modal (#913) — view mode
// no longer fetches connections to power its old SaveTemplateDialog.
const [activePageIndex, setActivePageIndex] = useState(0);
const [visitedPages, setVisitedPages] = useState<Set<number>>(
() => new Set([0]),
Expand Down Expand Up @@ -572,7 +564,6 @@ export default function DashboardViewerPage({
refetchInterval={refetchInterval}
actions={{
onNavigateToPage: handleNavigateToPage,
...(canWrite && { onSaveAsTemplate: setTemplateWidget }),
}}
showParameterBar={effectiveShowBar}
parameterSourceMap={parameterSourceMap}
Expand All @@ -582,25 +573,6 @@ export default function DashboardViewerPage({
})}
</div>
</DashboardErrorBoundary>

{templateWidget &&
(() => {
const conn = (connectionsData ?? []).find(
(c: { id: string }) => c.id === templateWidget.connectionId,
);
const connectorType = (conn?.type ??
"neo4j") as import("@/lib/connector/connector-types").ConnectorType;
return (
<SaveTemplateDialog
open
onOpenChange={(open) => {
if (!open) setTemplateWidget(undefined);
}}
widget={templateWidget}
connectorType={connectorType}
/>
);
})()}
</div>
);
}
38 changes: 22 additions & 16 deletions app/src/components/__tests__/dashboard-container-branches.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,22 @@ vi.mock("@neoboard/components", () => ({
}: {
children: React.ReactNode;
title: string;
actions?: Array<{ label: string; onClick?: () => void }>;
actions?: Array<{
label: string;
onClick?: () => void;
children?: Array<{ label: string; onClick?: () => void }>;
}>;
onRefresh?: () => void;
headerExtra?: React.ReactNode;
}) => {
widgetCardProps.push({ title, actions, onRefresh });
// Flatten parent + submenu children into a single set of buttons so tests
// can `getByTestId("action-csv")` regardless of whether CSV is a flat
// entry ("Export CSV") or a submenu child ("Export ▸ CSV"). The testid
// is derived from the *leaf* label only.
const flatActions = (actions ?? []).flatMap((a) =>
a.children && a.children.length > 0 ? a.children : [a],
);
return (
<div data-testid="widget-card-inner" data-title={title}>
<div data-testid="widget-header-extra">{headerExtra}</div>
Expand All @@ -42,7 +53,7 @@ vi.mock("@neoboard/components", () => ({
refresh
</button>
)}
{actions?.map((a) => (
{flatActions.map((a) => (
<button
key={a.label}
data-testid={`action-${a.label.toLowerCase().replace(/\s+/g, "-")}`}
Expand Down Expand Up @@ -301,15 +312,15 @@ describe("DashboardContainer — buildActions", () => {
renderWithProviders(
<DashboardContainer page={makePage()} editable={false} />,
);
expect(screen.queryByTestId("action-export-csv")).toBeNull();
expect(screen.queryByTestId("action-csv")).toBeNull();
});

it("adds Export CSV for data widgets", () => {
mockIsDataWidget.mockReturnValue(true);
renderWithProviders(
<DashboardContainer page={makePage()} editable={false} />,
);
expect(screen.getByTestId("action-export-csv")).toBeDefined();
expect(screen.getByTestId("action-csv")).toBeDefined();
});

it("omits Edit/Duplicate/Remove when editable=false", () => {
Expand Down Expand Up @@ -348,17 +359,12 @@ describe("DashboardContainer — buildActions", () => {
expect(onRemoveWidget).toHaveBeenCalledWith("w-1");
});

it("includes 'Save to Widget Library' when onSaveAsTemplate is provided", () => {
const onSaveAsTemplate = vi.fn();
it("no longer offers 'Save to Widget Library' in the action menu (#913)", () => {
// The save-as-template action moved to the widget editor modal footer.
renderWithProviders(
<DashboardContainer
page={makePage()}
editable={false}
actions={{ onSaveAsTemplate }}
/>,
<DashboardContainer page={makePage()} editable={false} />,
);
fireEvent.click(screen.getByTestId("action-save-to-widget-library"));
expect(onSaveAsTemplate).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId("action-save-to-widget-library")).toBeNull();
});

it("adds Sync/Detach actions only when widget.templateId + outdated", () => {
Expand Down Expand Up @@ -484,7 +490,7 @@ describe("DashboardContainer — CSV export", () => {
mockBuildExportData.mockReturnValue([{ a: 1 }, { a: 2 }]);
renderWithProviders(<DashboardContainer page={makePage()} />);

fireEvent.click(screen.getByTestId("action-export-csv"));
fireEvent.click(screen.getByTestId("action-csv"));

expect(mockBuildExportData).toHaveBeenCalled();
expect(mockBuildCsv).toHaveBeenCalledWith([{ a: 1 }, { a: 2 }]);
Expand All @@ -504,7 +510,7 @@ describe("DashboardContainer — CSV export", () => {
mockBuildExportData.mockReturnValue([]);
renderWithProviders(<DashboardContainer page={makePage()} />);

fireEvent.click(screen.getByTestId("action-export-csv"));
fireEvent.click(screen.getByTestId("action-csv"));

expect(mockBuildCsv).not.toHaveBeenCalled();
expect(mockTriggerDownload).not.toHaveBeenCalled();
Expand All @@ -518,7 +524,7 @@ describe("DashboardContainer — CSV export", () => {
page={makePage([makeWidget({ settings: {}, chartType: "pie" })])}
/>,
);
fireEvent.click(screen.getByTestId("action-export-csv"));
fireEvent.click(screen.getByTestId("action-csv"));
expect(mockBuildFilename).toHaveBeenCalledWith(
"pie",
"csv",
Expand Down
Loading
Loading