diff --git a/app/src/components/widget-editor-modal.tsx b/app/src/components/widget-editor-modal.tsx index 6db95725..ab517977 100644 --- a/app/src/components/widget-editor-modal.tsx +++ b/app/src/components/widget-editor-modal.tsx @@ -34,9 +34,7 @@ import { ChartSettingsPanel, getDefaultChartSettings, ColorScalePanel, - Badge, Button, - LoadingButton, Input, Label, Alert, @@ -46,12 +44,9 @@ import { DialogContent, DialogHeader, DialogTitle, - DialogFooter, - Checkbox, } from "@neoboard/components"; import { getCompatibleChartTypes, - getChartConfig, chartSupportsClickAction, chartSupportsStyling, getAllChartTypes, @@ -59,22 +54,26 @@ import { import type { ChartType } from "@/lib/plugin/chart-helpers"; import type { ConnectorType } from "@/lib/connector/connector-types"; import { useParameterValues } from "@/stores/parameter-store"; -import { extractReferencedParams } from "@/hooks/use-widget-query"; import { wrapWithPreviewLimit } from "@/lib/query/wrap-with-preview-limit"; export { wrapWithPreviewLimit }; import { ChartTypeSelector } from "./widget-editor/chart-type-selector"; +import { useBuildWidgetForSave } from "./widget-editor/use-widget-save"; import { QueryEditorPanel } from "./widget-editor/query-editor-panel"; import { FormFieldsEditor } from "./widget-editor/form-fields-editor"; -import { - ParameterConfigSection, - resolveInternalParamType, -} from "./widget-editor/parameter-config-section"; +import { ParameterConfigSection } from "./widget-editor/parameter-config-section"; import { ActionRulesEditor } from "./widget-editor/action-rules-editor"; import { StylingRulesEditor } from "./widget-editor/styling-rules-editor"; import { useWidgetEditorStore } from "@/stores/widget-editor-store"; import { TransformEditor } from "./widget-editor/transform-editor"; import { TemplateBrowser } from "./widget-editor/template-browser"; +import { useAutoPreview } from "./widget-editor/use-auto-preview"; +import { AdvancedCachingSection } from "./widget-editor/advanced-caching-section"; +import { AdvancedInteractivitySection } from "./widget-editor/advanced-interactivity-section"; +import { AdvancedStylingSection } from "./widget-editor/advanced-styling-section"; +import { AdvancedFormRefreshSection } from "./widget-editor/advanced-form-refresh-section"; +import { LabMetadataForm } from "./widget-editor/lab-metadata-form"; +import { ModalFooter } from "./widget-editor/modal-footer"; import { WidgetPreviewPanel } from "./widget-editor/widget-preview-panel"; export interface WidgetEditorModalProps { @@ -123,7 +122,6 @@ export function WidgetEditorModal({ const query = useWidgetEditorStore((s) => s.query); const chartOptions = useWidgetEditorStore((s) => s.chartOptions); const setChartOptions = useWidgetEditorStore((s) => s.setChartOptions); - const stylingRules = useWidgetEditorStore((s) => s.stylingRules); const actionRules = useWidgetEditorStore((s) => s.actionRules); const formFields = useWidgetEditorStore((s) => s.formFields); const paramUIType = useWidgetEditorStore((s) => s.paramUIType); @@ -133,8 +131,6 @@ export function WidgetEditorModal({ const transforms = useWidgetEditorStore((s) => s.transforms); const setTransforms = useWidgetEditorStore((s) => s.setTransforms); const transformsEnabled = useWidgetEditorStore((s) => s.transformsEnabled); - const queryHistory = useWidgetEditorStore((s) => s.queryHistory); - const addToQueryHistory = useWidgetEditorStore((s) => s.addToQueryHistory); const setTransformsEnabled = useWidgetEditorStore( (s) => s.setTransformsEnabled, ); @@ -142,29 +138,19 @@ export function WidgetEditorModal({ // ── Store-backed state (formerly local useState) ────────────────── const title = useWidgetEditorStore((s) => s.title); const setTitle = useWidgetEditorStore((s) => s.setTitle); - const templateId = useWidgetEditorStore((s) => s.templateId); - const templateSyncedAt = useWidgetEditorStore((s) => s.templateSyncedAt); const clickActionEnabled = useWidgetEditorStore((s) => s.clickActionEnabled); const setClickActionEnabled = useWidgetEditorStore( (s) => s.setClickActionEnabled, ); const parameterName = useWidgetEditorStore((s) => s.parameterName); - const stylingEnabled = useWidgetEditorStore((s) => s.stylingEnabled); const setStylingEnabled = useWidgetEditorStore((s) => s.setStylingEnabled); const colorScales = useWidgetEditorStore((s) => s.colorScales); const setColorScales = useWidgetEditorStore((s) => s.setColorScales); const dialogStep = useWidgetEditorStore((s) => s.dialogStep); const setDialogStep = useWidgetEditorStore((s) => s.setDialogStep); const labName = useWidgetEditorStore((s) => s.labName); - const setLabName = useWidgetEditorStore((s) => s.setLabName); const labDescription = useWidgetEditorStore((s) => s.labDescription); - const setLabDescription = useWidgetEditorStore((s) => s.setLabDescription); const labTagsInput = useWidgetEditorStore((s) => s.labTagsInput); - const setLabTagsInput = useWidgetEditorStore((s) => s.setLabTagsInput); - const enableCache = useWidgetEditorStore((s) => s.enableCache); - const setEnableCache = useWidgetEditorStore((s) => s.setEnableCache); - const cacheTtlMinutes = useWidgetEditorStore((s) => s.cacheTtlMinutes); - const setCacheTtlMinutes = useWidgetEditorStore((s) => s.setCacheTtlMinutes); const connectorChanged = useWidgetEditorStore((s) => s.connectorChanged); const setConnectorChanged = useWidgetEditorStore( (s) => s.setConnectorChanged, @@ -210,6 +196,9 @@ export function WidgetEditorModal({ // ── Local-only state (not in store) ──────────────────────────────── + // Build the widget object for saving — shared by handleSave and handleRunAndSave + const buildWidgetForSave = useBuildWidgetForSave(widget, layout); + // Lab-mode mutations const createTemplate = useCreateWidgetTemplate(); const updateTemplate = useUpdateWidgetTemplate(); @@ -239,12 +228,6 @@ export function WidgetEditorModal({ })); }, [layout, widget?.id]); - // Save status for visual feedback after CMD+Shift+Enter - const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">( - "idle", - ); - const savedTimerRef = useRef | null>(null); - // Widgets that already set the same parameter name (collision warning). // Use widget?.id ?? "" so new widgets (no id yet) still get collision checks. const paramSelectCollisions = useMemo( @@ -271,12 +254,6 @@ export function WidgetEditorModal({ return all; }, [layout, widget?.id, clickActionEnabled, parameterName, actionRules]); - // refreshWidgetIds — local since no sub-editor writes to it - const refreshWidgetIds = useWidgetEditorStore((s) => s.refreshWidgetIds); - const setRefreshWidgetIds = useWidgetEditorStore( - (s) => s.setRefreshWidgetIds, - ); - // Seed query preview options — populated when user clicks "Test Seed Query" const seedQueryExecution = useQueryExecution(); const seedPreviewOptions = useMemo(() => { @@ -305,20 +282,6 @@ export function WidgetEditorModal({ [connections, connectionId], ); - // Keep refs for values used inside handlePreview so that the callback - // identity stays stable and does not trigger the auto-preview effects - // on every render (fixes infinite preview loop — see #354). - const connectionIdRef = useRef(connectionId); - connectionIdRef.current = connectionId; - const queryRef = useRef(query); - queryRef.current = query; - const selectedConnectionRef = useRef(selectedConnection); - selectedConnectionRef.current = selectedConnection; - const allParamValuesRef = useRef(allParamValues); - allParamValuesRef.current = allParamValues; - const previewQueryRef = useRef(previewQuery); - previewQueryRef.current = previewQuery; - // Template picker — only used in add mode const selectedConnectorType = selectedConnection?.type ?? undefined; const { data: templates, isLoading: templatesLoading } = useWidgetTemplates( @@ -481,163 +444,20 @@ export function WidgetEditorModal({ [], ); - const handlePreview = useCallback(() => { - const cId = connectionIdRef.current; - const q = queryRef.current; - if (cId && q.trim()) { - const referenced = extractReferencedParams(q, allParamValuesRef.current); - const params = - Object.keys(referenced).length > 0 ? referenced : undefined; - const connectorType = selectedConnectionRef.current?.type ?? "neo4j"; - const previewQuery_ = wrapWithPreviewLimit(q, connectorType); - previewQueryRef.current.mutate({ - connectionId: cId, - query: previewQuery_, - params, - }); - } - }, []); - - // Auto-run preview when connection and query are present so column selectors - // are populated. For "add" mode a short debounce avoids firing on every - // keystroke while the user is still typing the query. - // Skip if initialPreviewData was provided (we already have data to show). - const autoPreviewTriggered = useRef(false); - useEffect(() => { - if (!open) { - autoPreviewTriggered.current = false; - return; - } - if (autoPreviewTriggered.current) return; - if (!connectionId || !query.trim()) return; - if (initialPreviewData) { - autoPreviewTriggered.current = true; - return; - } - autoPreviewTriggered.current = true; - // Short delay so state updates (connectionId, query) from modal - // initialization commit before handlePreview reads them. - const delay = mode === "add" ? 300 : 50; - const timer = setTimeout(() => { - handlePreview(); - }, delay); - return () => clearTimeout(timer); - }, [open, mode, connectionId, query, handlePreview, initialPreviewData]); - - // Auto-run preview when the query changes (debounced 800ms). - const prevQueryRef = useRef(query); - useEffect(() => { - if (!open) return; - if (prevQueryRef.current === query) return; - prevQueryRef.current = query; - if (!connectionId || !query.trim()) return; - const timer = setTimeout(() => { - handlePreview(); - }, 800); - return () => clearTimeout(timer); - }, [open, query, connectionId, handlePreview]); - - // Handles CMD+Shift+Enter (Mac) / Ctrl+Shift+Enter (Win/Linux): run query, then save on success. - const handleRunAndSave = useCallback(() => { - // Content-only widgets (markdown, iframe) don't have a query — skip the run+save shortcut. - if (chartType === "markdown" || chartType === "iframe") return; - if (!query.trim() || saveStatus === "saving") return; - setSaveStatus("saving"); - previewQueryRef.current.mutate( - { connectionId, query }, - { - onSuccess: () => { - if (savedTimerRef.current !== null) { - clearTimeout(savedTimerRef.current); - } - setSaveStatus("saved"); - savedTimerRef.current = setTimeout(() => { - setSaveStatus("idle"); - savedTimerRef.current = null; - }, 1500); - const id = widget?.id ?? crypto.randomUUID(); - // Record query in history before saving - if (query.trim()) addToQueryHistory(query); - // Read the updated history after adding - const updatedHistory = useWidgetEditorStore.getState().queryHistory; - onSave({ - id, - chartType, - connectionId, - query, - params: widget?.params, - settings: { - ...(widget?.settings ?? {}), - title: title || undefined, - chartOptions, - formFields: chartType === "form" ? formFields : undefined, - clickAction: buildClickAction(), - stylingConfig: buildStylingConfig(), - transforms: transforms.length ? transforms : undefined, - transformsEnabled, - conditionalFormatting: colorScales.length - ? { colorScales } - : undefined, - enableCache, - cacheTtlMinutes, - queryHistory: updatedHistory.length ? updatedHistory : undefined, - }, - templateId, - templateSyncedAt, - }); - onOpenChange(false); - }, - onError: () => { - setSaveStatus("idle"); - }, - }, - ); - }, [ - query, - saveStatus, + const { handlePreview, saveStatus } = useAutoPreview({ + open, + mode, connectionId, - widget, - buildClickAction, - buildStylingConfig, + query, chartType, - title, - chartOptions, - formFields, - transforms, - transformsEnabled, - enableCache, - cacheTtlMinutes, - colorScales, - addToQueryHistory, + allParamValues, + selectedConnection: selectedConnection ?? undefined, + initialPreviewData, + previewQuery, + buildWidgetForSave, onSave, onOpenChange, - templateId, - templateSyncedAt, - ]); - - // Register CMD+Shift+Enter / Ctrl+Shift+Enter on the dialog when it is open. - useEffect(() => { - if (!open) return; - const handler = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "Enter") { - e.preventDefault(); - handleRunAndSave(); - } - }; - document.addEventListener("keydown", handler); - return () => { - document.removeEventListener("keydown", handler); - }; - }, [open, handleRunAndSave]); - - // Clean up the "saved" feedback timer when the modal is closed. - useEffect(() => { - if (!open && savedTimerRef.current !== null) { - clearTimeout(savedTimerRef.current); - savedTimerRef.current = null; - setSaveStatus("idle"); - } - }, [open]); + }); // Derive available fields from preview query results const availableFields = useMemo(() => { @@ -685,80 +505,8 @@ export function WidgetEditorModal({ const isContentOnly = isMarkdown || isIframe; function handleSave() { - const id = widget?.id ?? crypto.randomUUID(); - // Record query in history before saving - if (query.trim() && !isParamSelect && !isContentOnly) { - addToQueryHistory(query); - } - const clickAction = buildClickAction(); - const stylingConfig = buildStylingConfig(); - const resolvedChartOptions = isParamSelect - ? { - ...chartOptions, - parameterType: resolveInternalParamType( - paramUIType, - dateSub, - multiSelect, - ), - parameterName: paramWidgetName, - seedQuery: - paramUIType === "select" - ? (chartOptions.seedQuery ?? "") - : undefined, - } - : chartOptions; - onSave({ - id, - chartType, - connectionId: - (isParamSelect && paramUIType !== "select") || isContentOnly - ? "" - : connectionId, - query: isParamSelect || isContentOnly ? "" : query, - params: widget?.params, - settings: { - ...(widget?.settings ?? {}), - title: title || undefined, - chartOptions: isForm - ? { - ...chartOptions, - refreshWidgetIds: - refreshWidgetIds.length > 0 ? refreshWidgetIds : undefined, - } - : resolvedChartOptions, - formFields: isForm ? formFields : undefined, - clickAction: - isParamSelect || isForm || isContentOnly ? undefined : clickAction, - stylingConfig: - isParamSelect || isForm || isContentOnly ? undefined : stylingConfig, - conditionalFormatting: - isParamSelect || isForm || isContentOnly - ? undefined - : colorScales.length - ? { colorScales } - : undefined, - enableCache: - isParamSelect || isForm || isContentOnly ? undefined : enableCache, - cacheTtlMinutes: - isParamSelect || isForm || isContentOnly - ? undefined - : cacheTtlMinutes, - transforms: - isParamSelect || isForm || isContentOnly - ? undefined - : transforms.length - ? transforms - : undefined, - queryHistory: - isParamSelect || isContentOnly - ? undefined - : queryHistory.length - ? queryHistory - : undefined, - }, - templateId, - templateSyncedAt, - }); + const widgetToSave = buildWidgetForSave(); + onSave(widgetToSave); onOpenChange(false); } @@ -871,50 +619,7 @@ export function WidgetEditorModal({ {/* Left column: tabs + settings */}
{/* Lab mode: template metadata */} - {isLabMode && ( -
-
- - setLabName(e.target.value)} - placeholder="My chart template" - /> -
-
- - setLabDescription(e.target.value)} - placeholder="What does this template do?" - /> -
-
- - setLabTagsInput(e.target.value)} - placeholder="e.g. neo4j, monitoring, kpi" - /> -
-
- )} + {isLabMode && } {/* Widget title — always visible above tabs */}
@@ -1074,232 +779,19 @@ export function WidgetEditorModal({ No advanced options for parameter widgets.

) : isForm ? ( -
-

- After Submit -

-

- Refresh these widgets when the form submits - successfully. -

- {otherWidgets && otherWidgets.length > 0 ? ( -
-
- - {refreshWidgetIds.length} of{" "} - {otherWidgets.length} selected - - -
- {otherWidgets.map((w) => ( -
- { - if (checked) { - setRefreshWidgetIds([ - ...refreshWidgetIds, - w.id, - ]); - } else { - setRefreshWidgetIds( - refreshWidgetIds.filter( - (id: string) => id !== w.id, - ), - ); - } - }} - /> - -
- ))} -
- ) : ( -

- No other widgets on this page. -

- )} -
+ ) : (
- {/* Caching */} -
-

- Caching -

-
- - setEnableCache(!!checked) - } - /> - -
- {enableCache && ( -
- - - setCacheTtlMinutes( - Math.max(1, Number(e.target.value)), - ) - } - className="w-24" - /> -

- Results are reused for up to {cacheTtlMinutes}{" "} - minute{cacheTtlMinutes !== 1 ? "s" : ""} before - re-querying. -

-
- )} -
- - {/* Interactivity — hidden for chart types that don't support click actions */} + {chartSupportsClickAction(chartType) && ( -
-

- Interactivity -

-
- - setClickActionEnabled(!!checked) - } - /> - -
- {clickActionEnabled && ( -
-

- {actionRules.length === 0 - ? "No action rules configured." - : `${actionRules.length} action rule(s) configured.`} -

- - {clickActionCollisions.length > 0 && ( - - - - Parameter name already in use - - - {clickActionCollisions.length === 1 - ? `A parameter set here is also set by: ${clickActionCollisions[0].title}.` - : `Parameters set here are also set by: ${clickActionCollisions.map((c) => c.title).join(", ")}.`}{" "} - Multiple widgets writing to the same - parameter may conflict. - - - )} -
- )} -
+ )} - - {/* Styling — row-level rules + cell-level formatting */} {chartSupportsStyling(chartType) && ( -
-

- Styling -

-
- - setStylingEnabled(!!checked) - } - /> - -
- {stylingEnabled && ( -
-

- {stylingRules.length === 0 - ? "No styling rules configured." - : `${stylingRules.length} styling rule(s) configured.`} -

- -
- )} -
+ )}
) @@ -1347,56 +839,16 @@ export function WidgetEditorModal({ />
- - {labError && ( -

{labError}

- )} - - {isLabMode ? ( - - {mode === "lab-edit" ? "Save Template" : "Create Template"} - - ) : ( - - {saveStatus === "saved" - ? "Saved!" - : mode === "edit" - ? "Save Changes" - : "Add Widget"} - - )} -
+ onOpenChange(false)} + onSave={handleSave} + onLabSave={handleLabSave} + /> )} diff --git a/app/src/components/widget-editor/__tests__/advanced-caching-section.test.tsx b/app/src/components/widget-editor/__tests__/advanced-caching-section.test.tsx new file mode 100644 index 00000000..9412e0bf --- /dev/null +++ b/app/src/components/widget-editor/__tests__/advanced-caching-section.test.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +vi.mock("@neoboard/components", () => ({ + Checkbox: ({ + id, + checked, + onCheckedChange, + }: { + id?: string; + checked?: boolean; + onCheckedChange?: (v: boolean) => void; + }) => ( + onCheckedChange?.(e.target.checked)} + data-testid={id} + /> + ), + Input: ({ + id, + value, + onChange, + ...props + }: React.InputHTMLAttributes) => ( + + ), + Label: ({ + children, + htmlFor, + }: React.PropsWithChildren<{ htmlFor?: string }>) => ( + + ), +})); + +const mockSetEnableCache = vi.fn(); +const mockSetCacheTtlMinutes = vi.fn(); + +vi.mock("@/stores/widget-editor-store", () => ({ + useWidgetEditorStore: (selector: (s: Record) => unknown) => + selector({ + enableCache: mockEnableCache, + setEnableCache: mockSetEnableCache, + cacheTtlMinutes: mockCacheTtlMinutes, + setCacheTtlMinutes: mockSetCacheTtlMinutes, + }), +})); + +let mockEnableCache = false; +let mockCacheTtlMinutes = 5; + +import { AdvancedCachingSection } from "../advanced-caching-section"; + +describe("AdvancedCachingSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockEnableCache = false; + mockCacheTtlMinutes = 5; + }); + + it("renders caching header and checkbox", () => { + render(); + expect(screen.getByText("Caching")).toBeInTheDocument(); + expect(screen.getByText("Cache query results")).toBeInTheDocument(); + }); + + it("does not show TTL input when caching is disabled", () => { + render(); + expect(screen.queryByTestId("cache-ttl")).not.toBeInTheDocument(); + }); + + it("shows TTL input when caching is enabled", () => { + mockEnableCache = true; + render(); + expect(screen.getByTestId("cache-ttl")).toBeInTheDocument(); + expect(screen.getByText(/Results are reused/)).toBeInTheDocument(); + }); + + it("calls setEnableCache when checkbox toggled", () => { + render(); + fireEvent.click(screen.getByTestId("enable-cache")); + expect(mockSetEnableCache).toHaveBeenCalledWith(true); + }); + + it("calls setCacheTtlMinutes when TTL changes", () => { + mockEnableCache = true; + render(); + fireEvent.change(screen.getByTestId("cache-ttl"), { + target: { value: "10" }, + }); + expect(mockSetCacheTtlMinutes).toHaveBeenCalledWith(10); + }); + + it("clamps TTL to minimum of 1", () => { + mockEnableCache = true; + render(); + fireEvent.change(screen.getByTestId("cache-ttl"), { + target: { value: "0" }, + }); + expect(mockSetCacheTtlMinutes).toHaveBeenCalledWith(1); + }); + + it("pluralizes minutes correctly", () => { + mockEnableCache = true; + mockCacheTtlMinutes = 1; + const { rerender } = render(); + expect(screen.getByText(/1 minute before/)).toBeInTheDocument(); + + mockCacheTtlMinutes = 5; + rerender(); + expect(screen.getByText(/5 minutes before/)).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/widget-editor/__tests__/advanced-form-refresh-section.test.tsx b/app/src/components/widget-editor/__tests__/advanced-form-refresh-section.test.tsx new file mode 100644 index 00000000..390e85c9 --- /dev/null +++ b/app/src/components/widget-editor/__tests__/advanced-form-refresh-section.test.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +vi.mock("@neoboard/components", () => ({ + Checkbox: ({ + id, + checked, + onCheckedChange, + }: { + id?: string; + checked?: boolean; + onCheckedChange?: (v: boolean) => void; + }) => ( + onCheckedChange?.(e.target.checked)} + data-testid={id} + /> + ), + Label: ({ + children, + htmlFor, + }: React.PropsWithChildren<{ htmlFor?: string }>) => ( + + ), + Button: ({ + children, + onClick, + ...props + }: React.PropsWithChildren>) => ( + + ), + Badge: ({ children }: React.PropsWithChildren) => {children}, +})); + +vi.mock("@/lib/plugin/chart-helpers", () => ({ + getChartConfig: (type: string) => ({ label: type.toUpperCase() }), +})); + +const mockSetRefreshWidgetIds = vi.fn(); +let mockRefreshWidgetIds: string[] = []; + +vi.mock("@/stores/widget-editor-store", () => ({ + useWidgetEditorStore: (selector: (s: Record) => unknown) => + selector({ + refreshWidgetIds: mockRefreshWidgetIds, + setRefreshWidgetIds: mockSetRefreshWidgetIds, + }), +})); + +import { AdvancedFormRefreshSection } from "../advanced-form-refresh-section"; + +const WIDGETS = [ + { id: "w1", title: "Sales Chart", chartType: "bar" }, + { id: "w2", title: "Users Table", chartType: "table" }, + { id: "w3", title: "", chartType: "line" }, +]; + +describe("AdvancedFormRefreshSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRefreshWidgetIds = []; + }); + + it("renders header and description", () => { + render(); + expect(screen.getByText("After Submit")).toBeInTheDocument(); + expect(screen.getByText(/Refresh these widgets/)).toBeInTheDocument(); + }); + + it("shows empty state when no other widgets", () => { + render(); + expect( + screen.getByText("No other widgets on this page."), + ).toBeInTheDocument(); + }); + + it("renders widget list with titles and chart type badges", () => { + render(); + expect(screen.getByText("Sales Chart")).toBeInTheDocument(); + expect(screen.getByText("Users Table")).toBeInTheDocument(); + expect(screen.getByText("(untitled)")).toBeInTheDocument(); + expect(screen.getByText("BAR")).toBeInTheDocument(); + expect(screen.getByText("TABLE")).toBeInTheDocument(); + }); + + it("shows selection count", () => { + mockRefreshWidgetIds = ["w1"]; + render(); + expect(screen.getByText("1 of 3 selected")).toBeInTheDocument(); + }); + + it("calls setRefreshWidgetIds when widget checkbox toggled on", () => { + render(); + fireEvent.click(screen.getByTestId("refresh-widget-w1")); + expect(mockSetRefreshWidgetIds).toHaveBeenCalledWith(["w1"]); + }); + + it("calls setRefreshWidgetIds when widget checkbox toggled off", () => { + mockRefreshWidgetIds = ["w1", "w2"]; + render(); + fireEvent.click(screen.getByTestId("refresh-widget-w1")); + expect(mockSetRefreshWidgetIds).toHaveBeenCalledWith(["w2"]); + }); + + it("select all selects all widgets", () => { + render(); + fireEvent.click(screen.getByText("Select all")); + expect(mockSetRefreshWidgetIds).toHaveBeenCalledWith(["w1", "w2", "w3"]); + }); + + it("deselect all clears selection when all selected", () => { + mockRefreshWidgetIds = ["w1", "w2", "w3"]; + render(); + fireEvent.click(screen.getByText("Deselect all")); + expect(mockSetRefreshWidgetIds).toHaveBeenCalledWith([]); + }); +}); diff --git a/app/src/components/widget-editor/__tests__/advanced-interactivity-section.test.tsx b/app/src/components/widget-editor/__tests__/advanced-interactivity-section.test.tsx new file mode 100644 index 00000000..836d5586 --- /dev/null +++ b/app/src/components/widget-editor/__tests__/advanced-interactivity-section.test.tsx @@ -0,0 +1,147 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +vi.mock("@neoboard/components", () => ({ + Checkbox: ({ + id, + checked, + onCheckedChange, + }: { + id?: string; + checked?: boolean; + onCheckedChange?: (v: boolean) => void; + }) => ( + onCheckedChange?.(e.target.checked)} + data-testid={id} + /> + ), + Label: ({ + children, + htmlFor, + }: React.PropsWithChildren<{ htmlFor?: string }>) => ( + + ), + Button: ({ + children, + onClick, + }: React.PropsWithChildren<{ onClick?: () => void }>) => ( + + ), + Alert: ({ + children, + ...props + }: React.PropsWithChildren>) => ( +
+ {children} +
+ ), + AlertTitle: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDescription: ({ children }: React.PropsWithChildren) => ( +
{children}
+ ), +})); + +vi.mock("lucide-react", () => ({ + Info: () => , +})); + +const mockSetClickActionEnabled = vi.fn(); +const mockSetDialogStep = vi.fn(); +let mockClickActionEnabled = false; +let mockActionRules: unknown[] = []; + +vi.mock("@/stores/widget-editor-store", () => ({ + useWidgetEditorStore: (selector: (s: Record) => unknown) => + selector({ + clickActionEnabled: mockClickActionEnabled, + setClickActionEnabled: mockSetClickActionEnabled, + actionRules: mockActionRules, + setDialogStep: mockSetDialogStep, + }), +})); + +import { AdvancedInteractivitySection } from "../advanced-interactivity-section"; + +describe("AdvancedInteractivitySection", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockClickActionEnabled = false; + mockActionRules = []; + }); + + it("renders header and checkbox", () => { + render(); + expect(screen.getByText("Interactivity")).toBeInTheDocument(); + expect(screen.getByText("Enable click action")).toBeInTheDocument(); + }); + + it("does not show rules info when disabled", () => { + render(); + expect( + screen.queryByText("No action rules configured."), + ).not.toBeInTheDocument(); + }); + + it("shows rules info and manage button when enabled", () => { + mockClickActionEnabled = true; + render(); + expect(screen.getByText("No action rules configured.")).toBeInTheDocument(); + expect(screen.getByText("Manage Action Rules")).toBeInTheDocument(); + }); + + it("shows rule count when rules exist", () => { + mockClickActionEnabled = true; + mockActionRules = [{ id: "1" }, { id: "2" }]; + render(); + expect( + screen.getByText("2 action rule(s) configured."), + ).toBeInTheDocument(); + }); + + it("calls setDialogStep when manage button clicked", () => { + mockClickActionEnabled = true; + render(); + fireEvent.click(screen.getByText("Manage Action Rules")); + expect(mockSetDialogStep).toHaveBeenCalledWith("rules"); + }); + + it("shows collision banner for single collision", () => { + mockClickActionEnabled = true; + render( + , + ); + expect( + screen.getByText(/A parameter set here is also set by: Widget A/), + ).toBeInTheDocument(); + }); + + it("shows collision banner for multiple collisions", () => { + mockClickActionEnabled = true; + render( + , + ); + expect( + screen.getByText( + /Parameters set here are also set by: Widget A, Widget B/, + ), + ).toBeInTheDocument(); + }); + + it("calls setClickActionEnabled when checkbox toggled", () => { + render(); + fireEvent.click(screen.getByTestId("click-action-enabled")); + expect(mockSetClickActionEnabled).toHaveBeenCalledWith(true); + }); +}); diff --git a/app/src/components/widget-editor/__tests__/advanced-styling-section.test.tsx b/app/src/components/widget-editor/__tests__/advanced-styling-section.test.tsx new file mode 100644 index 00000000..3ad0cbbc --- /dev/null +++ b/app/src/components/widget-editor/__tests__/advanced-styling-section.test.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +vi.mock("@neoboard/components", () => ({ + Checkbox: ({ + id, + checked, + onCheckedChange, + }: { + id?: string; + checked?: boolean; + onCheckedChange?: (v: boolean) => void; + }) => ( + onCheckedChange?.(e.target.checked)} + data-testid={id} + /> + ), + Label: ({ + children, + htmlFor, + }: React.PropsWithChildren<{ htmlFor?: string }>) => ( + + ), + Button: ({ + children, + onClick, + }: React.PropsWithChildren<{ onClick?: () => void }>) => ( + + ), +})); + +const mockSetStylingEnabled = vi.fn(); +const mockSetDialogStep = vi.fn(); +let mockStylingEnabled = false; +let mockStylingRules: unknown[] = []; + +vi.mock("@/stores/widget-editor-store", () => ({ + useWidgetEditorStore: (selector: (s: Record) => unknown) => + selector({ + stylingEnabled: mockStylingEnabled, + setStylingEnabled: mockSetStylingEnabled, + stylingRules: mockStylingRules, + setDialogStep: mockSetDialogStep, + }), +})); + +import { AdvancedStylingSection } from "../advanced-styling-section"; + +describe("AdvancedStylingSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockStylingEnabled = false; + mockStylingRules = []; + }); + + it("renders header and checkbox", () => { + render(); + expect(screen.getByText("Styling")).toBeInTheDocument(); + expect(screen.getByText("Enable rule-based styling")).toBeInTheDocument(); + }); + + it("does not show rules info when disabled", () => { + render(); + expect( + screen.queryByText("No styling rules configured."), + ).not.toBeInTheDocument(); + }); + + it("shows rules info and manage button when enabled", () => { + mockStylingEnabled = true; + render(); + expect( + screen.getByText("No styling rules configured."), + ).toBeInTheDocument(); + expect(screen.getByText("Manage Styling Rules")).toBeInTheDocument(); + }); + + it("shows rule count when rules exist", () => { + mockStylingEnabled = true; + mockStylingRules = [{ id: "1" }, { id: "2" }, { id: "3" }]; + render(); + expect( + screen.getByText("3 styling rule(s) configured."), + ).toBeInTheDocument(); + }); + + it("calls setDialogStep when manage button clicked", () => { + mockStylingEnabled = true; + render(); + fireEvent.click(screen.getByText("Manage Styling Rules")); + expect(mockSetDialogStep).toHaveBeenCalledWith("styling-rules"); + }); + + it("calls setStylingEnabled when checkbox toggled", () => { + render(); + fireEvent.click(screen.getByTestId("styling-enabled")); + expect(mockSetStylingEnabled).toHaveBeenCalledWith(true); + }); +}); diff --git a/app/src/components/widget-editor/__tests__/lab-metadata-form.test.tsx b/app/src/components/widget-editor/__tests__/lab-metadata-form.test.tsx new file mode 100644 index 00000000..9a764179 --- /dev/null +++ b/app/src/components/widget-editor/__tests__/lab-metadata-form.test.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +vi.mock("@neoboard/components", () => ({ + Input: ({ + id, + value, + onChange, + ...props + }: React.InputHTMLAttributes) => ( + + ), + Label: ({ + children, + htmlFor, + }: React.PropsWithChildren<{ htmlFor?: string }>) => ( + + ), +})); + +const mockSetLabName = vi.fn(); +const mockSetLabDescription = vi.fn(); +const mockSetLabTagsInput = vi.fn(); +let mockLabName = ""; +let mockLabDescription = ""; +let mockLabTagsInput = ""; + +vi.mock("@/stores/widget-editor-store", () => ({ + useWidgetEditorStore: (selector: (s: Record) => unknown) => + selector({ + labName: mockLabName, + setLabName: mockSetLabName, + labDescription: mockLabDescription, + setLabDescription: mockSetLabDescription, + labTagsInput: mockLabTagsInput, + setLabTagsInput: mockSetLabTagsInput, + }), +})); + +import { LabMetadataForm } from "../lab-metadata-form"; + +describe("LabMetadataForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLabName = ""; + mockLabDescription = ""; + mockLabTagsInput = ""; + }); + + it("renders all three fields", () => { + render(); + expect(screen.getByText("Template Name")).toBeInTheDocument(); + expect(screen.getByText("Description")).toBeInTheDocument(); + expect(screen.getByText("Tags")).toBeInTheDocument(); + }); + + it("shows required indicator on template name", () => { + render(); + expect(screen.getByText("*")).toBeInTheDocument(); + }); + + it("calls setLabName on name input change", () => { + render(); + fireEvent.change(screen.getByTestId("lab-template-name"), { + target: { value: "My Template" }, + }); + expect(mockSetLabName).toHaveBeenCalledWith("My Template"); + }); + + it("calls setLabDescription on description input change", () => { + render(); + fireEvent.change(screen.getByTestId("lab-template-desc"), { + target: { value: "A description" }, + }); + expect(mockSetLabDescription).toHaveBeenCalledWith("A description"); + }); + + it("calls setLabTagsInput on tags input change", () => { + render(); + fireEvent.change(screen.getByTestId("lab-template-tags"), { + target: { value: "neo4j, monitoring" }, + }); + expect(mockSetLabTagsInput).toHaveBeenCalledWith("neo4j, monitoring"); + }); + + it("displays current values from store", () => { + mockLabName = "Existing"; + mockLabDescription = "Desc"; + mockLabTagsInput = "tag1, tag2"; + render(); + expect(screen.getByTestId("lab-template-name")).toHaveValue("Existing"); + expect(screen.getByTestId("lab-template-desc")).toHaveValue("Desc"); + expect(screen.getByTestId("lab-template-tags")).toHaveValue("tag1, tag2"); + }); +}); diff --git a/app/src/components/widget-editor/__tests__/modal-footer.test.tsx b/app/src/components/widget-editor/__tests__/modal-footer.test.tsx new file mode 100644 index 00000000..aa0f0449 --- /dev/null +++ b/app/src/components/widget-editor/__tests__/modal-footer.test.tsx @@ -0,0 +1,170 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +vi.mock("@neoboard/components", () => ({ + Button: ({ + children, + onClick, + ...props + }: React.PropsWithChildren>) => ( + + ), + LoadingButton: ({ + children, + onClick, + disabled, + loading, + loadingText, + }: React.PropsWithChildren<{ + onClick?: () => void; + disabled?: boolean; + loading?: boolean; + loadingText?: string; + }>) => ( + + ), + DialogFooter: ({ children }: React.PropsWithChildren) => ( +
{children}
+ ), +})); + +let mockStoreState: Record = {}; + +vi.mock("@/stores/widget-editor-store", () => ({ + useWidgetEditorStore: (selector: (s: Record) => unknown) => + selector(mockStoreState), +})); + +import { ModalFooter } from "../modal-footer"; + +const baseProps = { + mode: "add" as const, + labError: null, + labSaving: false, + saveStatus: "idle" as const, + isContentOnly: false, + onCancel: vi.fn(), + onSave: vi.fn(), + onLabSave: vi.fn(), +}; + +describe("ModalFooter", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockStoreState = { + chartType: "bar", + connectionId: "conn-1", + query: "MATCH (n) RETURN n", + labName: "My Template", + paramWidgetName: "", + paramUIType: "text", + chartOptions: {}, + }; + }); + + it("renders cancel and save buttons in add mode", () => { + render(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.getByText("Add Widget")).toBeInTheDocument(); + }); + + it("shows 'Save Changes' in edit mode", () => { + render(); + expect(screen.getByText("Save Changes")).toBeInTheDocument(); + }); + + it("shows 'Saved!' when saveStatus is saved", () => { + render(); + expect(screen.getByText("Saved!")).toBeInTheDocument(); + }); + + it("shows 'Saving...' when saveStatus is saving", () => { + render(); + expect(screen.getByText("Saving...")).toBeInTheDocument(); + }); + + it("calls onCancel when cancel clicked", () => { + render(); + fireEvent.click(screen.getByText("Cancel")); + expect(baseProps.onCancel).toHaveBeenCalled(); + }); + + it("calls onSave when save clicked", () => { + render(); + fireEvent.click(screen.getByText("Add Widget")); + expect(baseProps.onSave).toHaveBeenCalled(); + }); + + it("disables save when query is empty", () => { + mockStoreState.query = ""; + render(); + expect(screen.getByText("Add Widget")).toBeDisabled(); + }); + + it("shows lab mode buttons in lab-create mode", () => { + render(); + expect(screen.getByText("Create Template")).toBeInTheDocument(); + }); + + it("shows lab mode buttons in lab-edit mode", () => { + render(); + expect(screen.getByText("Save Template")).toBeInTheDocument(); + }); + + it("calls onLabSave for lab mode save", () => { + render(); + fireEvent.click(screen.getByText("Create Template")); + expect(baseProps.onLabSave).toHaveBeenCalled(); + }); + + it("disables lab save when name is empty", () => { + mockStoreState.labName = ""; + render(); + expect(screen.getByText("Create Template")).toBeDisabled(); + }); + + it("shows lab error message", () => { + render( + , + ); + expect(screen.getByText("Save failed")).toBeInTheDocument(); + }); + + it("shows saving state for lab mode", () => { + render(); + expect(screen.getByText("Saving...")).toBeInTheDocument(); + }); + + it("content-only widgets don't require query", () => { + mockStoreState.query = ""; + render(); + expect(screen.getByText("Add Widget")).not.toBeDisabled(); + }); + + it("param-select requires param name", () => { + mockStoreState.chartType = "parameter-select"; + mockStoreState.paramWidgetName = ""; + render(); + expect(screen.getByText("Add Widget")).toBeDisabled(); + }); + + it("param-select with name is enabled", () => { + mockStoreState.chartType = "parameter-select"; + mockStoreState.paramWidgetName = "myParam"; + mockStoreState.paramUIType = "text"; + render(); + expect(screen.getByText("Add Widget")).not.toBeDisabled(); + }); + + it("form requires connection and query", () => { + mockStoreState.chartType = "form"; + mockStoreState.connectionId = ""; + render(); + expect(screen.getByText("Add Widget")).toBeDisabled(); + }); +}); diff --git a/app/src/components/widget-editor/__tests__/use-auto-preview.test.tsx b/app/src/components/widget-editor/__tests__/use-auto-preview.test.tsx new file mode 100644 index 00000000..af5b1245 --- /dev/null +++ b/app/src/components/widget-editor/__tests__/use-auto-preview.test.tsx @@ -0,0 +1,488 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useAutoPreview } from "../use-auto-preview"; + +// ── Mocks ────────────────────────────────────────────────────────────────── + +vi.mock("@/hooks/use-widget-query", () => ({ + extractReferencedParams: vi.fn( + (_q: string, allParams: Record) => { + // Simple mock: return params that match $param_ in query + const result: Record = {}; + const regex = /\$param_(\w+)/g; + let match; + while ((match = regex.exec(_q)) !== null) { + const name = match[1]; + if (name in allParams) { + result["param_" + name] = allParams[name]; + } + } + return result; + }, + ), +})); + +vi.mock("@/lib/query/wrap-with-preview-limit", () => ({ + wrapWithPreviewLimit: vi.fn((q: string) => q + " LIMIT 25"), +})); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function createDefaults( + overrides: Partial[0]> = {}, +) { + return { + open: true, + mode: "edit" as const, + connectionId: "conn-1", + query: "MATCH (n) RETURN n", + chartType: "bar", + allParamValues: {}, + selectedConnection: { id: "conn-1", type: "neo4j" } as Parameters< + typeof useAutoPreview + >[0]["selectedConnection"], + initialPreviewData: undefined, + previewQuery: { mutate: vi.fn() }, + buildWidgetForSave: vi.fn(() => ({ + id: "w1", + chartType: "bar", + connectionId: "conn-1", + query: "MATCH (n) RETURN n", + })), + onSave: vi.fn(), + onOpenChange: vi.fn(), + ...overrides, + }; +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe("useAutoPreview", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + // ── handlePreview ──────────────────────────────────────────────── + + describe("handlePreview", () => { + it("calls previewQuery.mutate with wrapped query and connectionId", () => { + const opts = createDefaults(); + const { result } = renderHook(() => useAutoPreview(opts)); + + act(() => { + result.current.handlePreview(); + }); + + expect(opts.previewQuery.mutate).toHaveBeenCalledWith({ + connectionId: "conn-1", + query: "MATCH (n) RETURN n LIMIT 25", + params: undefined, + }); + }); + + it("extracts referenced params when query contains $param_ tokens", () => { + const opts = createDefaults({ + query: "MATCH (n) WHERE n.id = $param_myId RETURN n", + allParamValues: { myId: 42 }, + }); + const { result } = renderHook(() => useAutoPreview(opts)); + + act(() => { + result.current.handlePreview(); + }); + + expect(opts.previewQuery.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + params: { param_myId: 42 }, + }), + ); + }); + + it("does not mutate when connectionId is empty", () => { + const opts = createDefaults({ connectionId: "" }); + const { result } = renderHook(() => useAutoPreview(opts)); + + act(() => { + result.current.handlePreview(); + }); + + expect(opts.previewQuery.mutate).not.toHaveBeenCalled(); + }); + + it("does not mutate when query is whitespace-only", () => { + const opts = createDefaults({ query: " " }); + const { result } = renderHook(() => useAutoPreview(opts)); + + act(() => { + result.current.handlePreview(); + }); + + expect(opts.previewQuery.mutate).not.toHaveBeenCalled(); + }); + }); + + // ── Auto-preview on open ───────────────────────────────────────── + + describe("auto-preview on open", () => { + it("triggers preview after delay when dialog opens in edit mode", () => { + const opts = createDefaults({ mode: "edit" }); + renderHook(() => useAutoPreview(opts)); + + // delay for edit mode is 50ms + act(() => { + vi.advanceTimersByTime(50); + }); + + expect(opts.previewQuery.mutate).toHaveBeenCalledTimes(1); + }); + + it("uses longer delay for add mode", () => { + const opts = createDefaults({ mode: "add" }); + renderHook(() => useAutoPreview(opts)); + + act(() => { + vi.advanceTimersByTime(50); + }); + expect(opts.previewQuery.mutate).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(250); + }); + expect(opts.previewQuery.mutate).toHaveBeenCalledTimes(1); + }); + + it("skips auto-preview when initialPreviewData is provided", () => { + const opts = createDefaults({ + initialPreviewData: { data: [], resultId: "r1" }, + }); + renderHook(() => useAutoPreview(opts)); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(opts.previewQuery.mutate).not.toHaveBeenCalled(); + }); + + it("skips auto-preview when dialog is closed", () => { + const opts = createDefaults({ open: false }); + renderHook(() => useAutoPreview(opts)); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(opts.previewQuery.mutate).not.toHaveBeenCalled(); + }); + + it("skips auto-preview when connectionId is empty", () => { + const opts = createDefaults({ connectionId: "" }); + renderHook(() => useAutoPreview(opts)); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(opts.previewQuery.mutate).not.toHaveBeenCalled(); + }); + + it("skips auto-preview when query is empty", () => { + const opts = createDefaults({ query: "" }); + renderHook(() => useAutoPreview(opts)); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(opts.previewQuery.mutate).not.toHaveBeenCalled(); + }); + + it("does not re-trigger on re-render once auto-preview has fired", () => { + const opts = createDefaults({ mode: "edit" }); + const { rerender } = renderHook(() => useAutoPreview(opts)); + + act(() => { + vi.advanceTimersByTime(50); + }); + expect(opts.previewQuery.mutate).toHaveBeenCalledTimes(1); + + rerender(); + act(() => { + vi.advanceTimersByTime(1000); + }); + // Still only 1 call from auto-preview (the debounced query-change effect + // won't fire because prevQueryRef === query) + expect(opts.previewQuery.mutate).toHaveBeenCalledTimes(1); + }); + }); + + // ── Query change debounce ──────────────────────────────────────── + + describe("query-change debounce", () => { + it("re-runs preview 800ms after query changes", () => { + const opts = createDefaults({ mode: "edit" }); + const { rerender } = renderHook((props) => useAutoPreview(props), { + initialProps: opts, + }); + + // auto-preview fires first + act(() => { + vi.advanceTimersByTime(50); + }); + expect(opts.previewQuery.mutate).toHaveBeenCalledTimes(1); + + // change query + const updated = { ...opts, query: "MATCH (m) RETURN m" }; + rerender(updated); + + act(() => { + vi.advanceTimersByTime(500); + }); + // not yet — debounce is 800ms + expect(opts.previewQuery.mutate).toHaveBeenCalledTimes(1); + + act(() => { + vi.advanceTimersByTime(300); + }); + expect(opts.previewQuery.mutate).toHaveBeenCalledTimes(2); + }); + }); + + // ── handleRunAndSave (CMD+Shift+Enter) ─────────────────────────── + + describe("handleRunAndSave", () => { + it("skips for markdown chart type", () => { + const opts = createDefaults({ chartType: "markdown" }); + renderHook(() => useAutoPreview(opts)); + + act(() => { + const event = new KeyboardEvent("keydown", { + key: "Enter", + metaKey: true, + shiftKey: true, + }); + document.dispatchEvent(event); + vi.advanceTimersByTime(100); + }); + + // mutate is called for auto-preview but NOT for run-and-save path + // Since markdown doesn't need a connection, auto-preview fires normally. + // The run-and-save should short-circuit before calling mutate with save callbacks. + // We verify by checking onSave was never called. + expect(opts.onSave).not.toHaveBeenCalled(); + }); + + it("skips for iframe chart type", () => { + const opts = createDefaults({ chartType: "iframe" }); + renderHook(() => useAutoPreview(opts)); + + act(() => { + const event = new KeyboardEvent("keydown", { + key: "Enter", + metaKey: true, + shiftKey: true, + }); + document.dispatchEvent(event); + vi.advanceTimersByTime(100); + }); + + expect(opts.onSave).not.toHaveBeenCalled(); + }); + + it("skips when query is empty", () => { + const opts = createDefaults({ query: " " }); + renderHook(() => useAutoPreview(opts)); + + act(() => { + const event = new KeyboardEvent("keydown", { + key: "Enter", + metaKey: true, + shiftKey: true, + }); + document.dispatchEvent(event); + vi.advanceTimersByTime(100); + }); + + expect(opts.onSave).not.toHaveBeenCalled(); + }); + + it("calls onSave and onOpenChange(false) on success", () => { + const mutate = vi.fn(); + const opts = createDefaults({ previewQuery: { mutate } }); + renderHook(() => useAutoPreview(opts)); + + // drain auto-preview + act(() => { + vi.advanceTimersByTime(50); + }); + mutate.mockClear(); + + // trigger CMD+Shift+Enter + act(() => { + const event = new KeyboardEvent("keydown", { + key: "Enter", + metaKey: true, + shiftKey: true, + }); + document.dispatchEvent(event); + }); + + expect(mutate).toHaveBeenCalledTimes(1); + const [, callbacks] = mutate.mock.calls[0]; + + // simulate success + act(() => { + callbacks.onSuccess(); + vi.advanceTimersByTime(1500); + }); + + expect(opts.buildWidgetForSave).toHaveBeenCalled(); + expect(opts.onSave).toHaveBeenCalled(); + expect(opts.onOpenChange).toHaveBeenCalledWith(false); + }); + + it("resets saveStatus to idle on error", () => { + const mutate = vi.fn(); + const opts = createDefaults({ previewQuery: { mutate } }); + const { result } = renderHook(() => useAutoPreview(opts)); + + act(() => { + vi.advanceTimersByTime(50); + }); + mutate.mockClear(); + + // trigger + act(() => { + const event = new KeyboardEvent("keydown", { + key: "Enter", + metaKey: true, + shiftKey: true, + }); + document.dispatchEvent(event); + }); + + expect(result.current.saveStatus).toBe("saving"); + + const [, callbacks] = mutate.mock.calls[0]; + act(() => { + callbacks.onError(); + }); + + expect(result.current.saveStatus).toBe("idle"); + }); + + it("responds to ctrlKey instead of metaKey", () => { + const mutate = vi.fn(); + const opts = createDefaults({ previewQuery: { mutate } }); + renderHook(() => useAutoPreview(opts)); + + act(() => { + vi.advanceTimersByTime(50); + }); + mutate.mockClear(); + + act(() => { + const event = new KeyboardEvent("keydown", { + key: "Enter", + ctrlKey: true, + shiftKey: true, + }); + document.dispatchEvent(event); + }); + + expect(mutate).toHaveBeenCalledTimes(1); + }); + + it("does not register keyboard shortcut when dialog is closed", () => { + const mutate = vi.fn(); + const opts = createDefaults({ open: false, previewQuery: { mutate } }); + renderHook(() => useAutoPreview(opts)); + + act(() => { + const event = new KeyboardEvent("keydown", { + key: "Enter", + metaKey: true, + shiftKey: true, + }); + document.dispatchEvent(event); + }); + + // No calls at all (no auto-preview, no run-and-save) + expect(mutate).not.toHaveBeenCalled(); + }); + }); + + // ── saveStatus lifecycle ───────────────────────────────────────── + + describe("saveStatus", () => { + it("returns idle initially", () => { + const opts = createDefaults(); + const { result } = renderHook(() => useAutoPreview(opts)); + expect(result.current.saveStatus).toBe("idle"); + }); + + it("transitions saving -> saved -> idle after success", () => { + const mutate = vi.fn(); + const opts = createDefaults({ previewQuery: { mutate } }); + const { result } = renderHook(() => useAutoPreview(opts)); + + act(() => { + vi.advanceTimersByTime(50); + }); + mutate.mockClear(); + + act(() => { + const event = new KeyboardEvent("keydown", { + key: "Enter", + metaKey: true, + shiftKey: true, + }); + document.dispatchEvent(event); + }); + + expect(result.current.saveStatus).toBe("saving"); + + const [, callbacks] = mutate.mock.calls[0]; + act(() => { + callbacks.onSuccess(); + }); + expect(result.current.saveStatus).toBe("saved"); + + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(result.current.saveStatus).toBe("idle"); + }); + }); + + // ── Cleanup on close ───────────────────────────────────────────── + + describe("cleanup on close", () => { + it("resets autoPreviewTriggered when dialog closes", () => { + const opts = createDefaults({ mode: "edit" }); + const { rerender } = renderHook((props) => useAutoPreview(props), { + initialProps: opts, + }); + + act(() => { + vi.advanceTimersByTime(50); + }); + expect(opts.previewQuery.mutate).toHaveBeenCalledTimes(1); + + // close + rerender({ ...opts, open: false }); + + // re-open => should trigger auto-preview again + (opts.previewQuery.mutate as ReturnType).mockClear(); + rerender({ ...opts, open: true }); + + act(() => { + vi.advanceTimersByTime(50); + }); + expect(opts.previewQuery.mutate).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/src/components/widget-editor/__tests__/use-widget-save.test.tsx b/app/src/components/widget-editor/__tests__/use-widget-save.test.tsx new file mode 100644 index 00000000..2b7c72a3 --- /dev/null +++ b/app/src/components/widget-editor/__tests__/use-widget-save.test.tsx @@ -0,0 +1,546 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useBuildWidgetForSave } from "../use-widget-save"; +import type { DashboardWidget } from "@/lib/db/schema"; + +// ── Mock store ───────────────────────────────────────────────────────────── + +let mockStoreState: Record = {}; + +vi.mock("@/stores/widget-editor-store", () => { + const store = { + useWidgetEditorStore: Object.assign( + (selector: (s: Record) => unknown) => + selector(mockStoreState), + { + getState: () => mockStoreState, + }, + ), + }; + return store; +}); + +vi.mock("../parameter-config-section", () => ({ + resolveInternalParamType: vi.fn( + (ui: string, dateSub: string, multi: boolean) => { + if (ui === "date") { + return dateSub === "range" + ? "date-range" + : dateSub === "relative" + ? "date-relative" + : "date"; + } + if (ui === "freetext") return "text"; + return multi ? "multi-select" : "select"; + }, + ), +})); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function setStoreState(overrides: Record = {}) { + mockStoreState = { + chartType: "bar", + connectionId: "conn-1", + query: "MATCH (n) RETURN n", + title: "My Widget", + chartOptions: {}, + formFields: [], + transforms: [], + transformsEnabled: false, + enableCache: false, + cacheTtlMinutes: 5, + colorScales: [], + refreshWidgetIds: [], + paramUIType: "select", + dateSub: "single", + multiSelect: false, + paramWidgetName: "", + templateId: undefined, + templateSyncedAt: undefined, + buildClickAction: vi.fn(() => undefined), + buildStylingConfig: vi.fn(() => undefined), + addToQueryHistory: vi.fn(), + queryHistory: [], + ...overrides, + }; +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe("useBuildWidgetForSave", () => { + beforeEach(() => { + setStoreState(); + vi.stubGlobal( + "crypto", + Object.assign({}, globalThis.crypto, { + randomUUID: () => "generated-uuid", + }), + ); + }); + + // ── Basic payload construction ─────────────────────────────────── + + describe("basic widget payload", () => { + it("builds a new widget with generated ID when no existing widget", () => { + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.id).toBe("generated-uuid"); + expect(widget.chartType).toBe("bar"); + expect(widget.connectionId).toBe("conn-1"); + expect(widget.query).toBe("MATCH (n) RETURN n"); + }); + + it("preserves existing widget ID", () => { + const existing: DashboardWidget = { + id: "existing-id", + chartType: "bar", + connectionId: "conn-1", + query: "old query", + params: { foo: "bar" }, + settings: { title: "Old Title" }, + }; + const { result } = renderHook(() => useBuildWidgetForSave(existing)); + const widget = result.current(); + + expect(widget.id).toBe("existing-id"); + expect(widget.params).toEqual({ foo: "bar" }); + }); + + it("sets title in settings", () => { + setStoreState({ title: "Dashboard Widget" }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.title).toBe("Dashboard Widget"); + }); + + it("sets title to undefined when empty string", () => { + setStoreState({ title: "" }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.title).toBeUndefined(); + }); + + it("records query in history for regular chart types", () => { + const addToQueryHistory = vi.fn(); + setStoreState({ addToQueryHistory }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + result.current(); + + expect(addToQueryHistory).toHaveBeenCalledWith("MATCH (n) RETURN n"); + }); + + it("includes templateId and templateSyncedAt when set", () => { + setStoreState({ + templateId: "tmpl-1", + templateSyncedAt: "2026-01-01T00:00:00Z", + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.templateId).toBe("tmpl-1"); + expect(widget.templateSyncedAt).toBe("2026-01-01T00:00:00Z"); + }); + }); + + // ── Parameter-select widget ────────────────────────────────────── + + describe("parameter-select widget", () => { + it("resolves parameterType and sets parameterName in chartOptions", () => { + setStoreState({ + chartType: "parameter-select", + paramUIType: "select", + dateSub: "single", + multiSelect: false, + paramWidgetName: "myParam", + chartOptions: { seedQuery: "RETURN 1" }, + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.chartOptions).toEqual( + expect.objectContaining({ + parameterType: "select", + parameterName: "myParam", + seedQuery: "RETURN 1", + }), + ); + }); + + it("uses multi-select type when multiSelect is true", () => { + setStoreState({ + chartType: "parameter-select", + paramUIType: "select", + multiSelect: true, + paramWidgetName: "multi", + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.chartOptions).toEqual( + expect.objectContaining({ parameterType: "multi-select" }), + ); + }); + + it("clears seedQuery when paramUIType is not select", () => { + setStoreState({ + chartType: "parameter-select", + paramUIType: "date", + dateSub: "range", + chartOptions: { seedQuery: "RETURN 1" }, + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect( + (widget.settings?.chartOptions as Record)?.seedQuery, + ).toBeUndefined(); + }); + + it("sets empty connectionId for non-select paramUIType", () => { + setStoreState({ + chartType: "parameter-select", + paramUIType: "freetext", + connectionId: "conn-1", + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.connectionId).toBe(""); + }); + + it("preserves connectionId for select paramUIType", () => { + setStoreState({ + chartType: "parameter-select", + paramUIType: "select", + connectionId: "conn-1", + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.connectionId).toBe("conn-1"); + }); + + it("sets query to empty string", () => { + setStoreState({ + chartType: "parameter-select", + query: "some query", + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.query).toBe(""); + }); + + it("does not record query in history", () => { + const addToQueryHistory = vi.fn(); + setStoreState({ + chartType: "parameter-select", + addToQueryHistory, + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + result.current(); + + expect(addToQueryHistory).not.toHaveBeenCalled(); + }); + + it("skips clickAction, stylingConfig, conditionalFormatting, cache, transforms", () => { + const buildClickAction = vi.fn(() => ({ rules: [] })); + const buildStylingConfig = vi.fn(() => ({ enabled: true, rules: [] })); + setStoreState({ + chartType: "parameter-select", + buildClickAction, + buildStylingConfig, + enableCache: true, + transforms: [{ id: "t1" }], + colorScales: [{ id: "cs1" }], + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.clickAction).toBeUndefined(); + expect(widget.settings?.stylingConfig).toBeUndefined(); + expect(widget.settings?.conditionalFormatting).toBeUndefined(); + expect(widget.settings?.enableCache).toBeUndefined(); + expect(widget.settings?.transforms).toBeUndefined(); + }); + }); + + // ── Form widget ────────────────────────────────────────────────── + + describe("form widget", () => { + it("includes formFields and refreshWidgetIds in settings", () => { + setStoreState({ + chartType: "form", + formFields: [{ id: "f1", label: "Name" }], + refreshWidgetIds: ["w1", "w2"], + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.formFields).toEqual([ + { id: "f1", label: "Name" }, + ]); + expect( + (widget.settings?.chartOptions as Record) + ?.refreshWidgetIds, + ).toEqual(["w1", "w2"]); + }); + + it("omits refreshWidgetIds when empty", () => { + setStoreState({ + chartType: "form", + formFields: [], + refreshWidgetIds: [], + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect( + (widget.settings?.chartOptions as Record) + ?.refreshWidgetIds, + ).toBeUndefined(); + }); + + it("skips clickAction, stylingConfig for form widgets", () => { + setStoreState({ chartType: "form" }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.clickAction).toBeUndefined(); + expect(widget.settings?.stylingConfig).toBeUndefined(); + }); + }); + + // ── Content-only widgets (markdown / iframe) ───────────────────── + + describe("content-only widgets", () => { + it.each(["markdown", "iframe"])( + "sets empty connectionId and query for %s", + (type) => { + setStoreState({ + chartType: type, + connectionId: "conn-1", + query: "some query", + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.connectionId).toBe(""); + expect(widget.query).toBe(""); + }, + ); + + it.each(["markdown", "iframe"])( + "does not record query in history for %s", + (type) => { + const addToQueryHistory = vi.fn(); + setStoreState({ + chartType: type, + addToQueryHistory, + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + result.current(); + + expect(addToQueryHistory).not.toHaveBeenCalled(); + }, + ); + + it.each(["markdown", "iframe"])("skips settings extras for %s", (type) => { + setStoreState({ + chartType: type, + enableCache: true, + colorScales: [{ id: "cs1" }], + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.clickAction).toBeUndefined(); + expect(widget.settings?.enableCache).toBeUndefined(); + expect(widget.settings?.conditionalFormatting).toBeUndefined(); + }); + }); + + // ── Click action & styling ─────────────────────────────────────── + + describe("click action and styling config", () => { + it("includes clickAction when buildClickAction returns a value", () => { + const action = { + rules: [{ id: "r1", type: "set-parameter" }], + }; + setStoreState({ buildClickAction: vi.fn(() => action) }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.clickAction).toEqual(action); + }); + + it("includes stylingConfig when buildStylingConfig returns a value", () => { + const styling = { enabled: true, rules: [] }; + setStoreState({ buildStylingConfig: vi.fn(() => styling) }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.stylingConfig).toEqual(styling); + }); + }); + + // ── Conditional formatting ─────────────────────────────────────── + + describe("conditional formatting", () => { + it("includes colorScales when non-empty", () => { + setStoreState({ + colorScales: [{ id: "cs1", column: "val", min: 0, max: 100 }], + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.conditionalFormatting).toEqual({ + colorScales: [{ id: "cs1", column: "val", min: 0, max: 100 }], + }); + }); + + it("omits conditionalFormatting when colorScales is empty", () => { + setStoreState({ colorScales: [] }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.conditionalFormatting).toBeUndefined(); + }); + }); + + // ── Caching ────────────────────────────────────────────────────── + + describe("caching", () => { + it("includes cache settings for regular widgets", () => { + setStoreState({ enableCache: true, cacheTtlMinutes: 10 }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.enableCache).toBe(true); + expect(widget.settings?.cacheTtlMinutes).toBe(10); + }); + }); + + // ── Transforms ─────────────────────────────────────────────────── + + describe("transforms", () => { + it("includes transforms when non-empty", () => { + setStoreState({ + transforms: [{ id: "t1", type: "sort" }], + transformsEnabled: true, + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.transforms).toEqual([{ id: "t1", type: "sort" }]); + expect(widget.settings?.transformsEnabled).toBe(true); + }); + + it("omits transforms when empty", () => { + setStoreState({ transforms: [] }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.transforms).toBeUndefined(); + }); + }); + + // ── Query history ──────────────────────────────────────────────── + + describe("query history", () => { + it("includes query history when non-empty", () => { + setStoreState({ + queryHistory: [{ query: "MATCH (n) RETURN n", timestamp: 123 }], + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.queryHistory).toEqual([ + { query: "MATCH (n) RETURN n", timestamp: 123 }, + ]); + }); + + it("omits query history when empty", () => { + setStoreState({ queryHistory: [] }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.queryHistory).toBeUndefined(); + }); + + it("omits query history for parameter-select widgets", () => { + setStoreState({ + chartType: "parameter-select", + queryHistory: [{ query: "q", timestamp: 1 }], + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.queryHistory).toBeUndefined(); + }); + + it("omits query history for content-only widgets", () => { + setStoreState({ + chartType: "markdown", + queryHistory: [{ query: "q", timestamp: 1 }], + }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + const widget = result.current(); + + expect(widget.settings?.queryHistory).toBeUndefined(); + }); + }); + + // ── Existing settings merge ────────────────────────────────────── + + describe("existing settings merge", () => { + it("merges new settings with existing widget settings", () => { + const existing: DashboardWidget = { + id: "w1", + chartType: "bar", + connectionId: "conn-1", + query: "old", + settings: { customProp: "keep-me" }, + }; + const { result } = renderHook(() => useBuildWidgetForSave(existing)); + const widget = result.current(); + + // Existing settings spread + new settings overwrite + expect(widget.settings?.customProp).toBe("keep-me"); + expect(widget.settings?.title).toBe("My Widget"); + }); + }); + + // ── Layout passed to buildClickAction ──────────────────────────── + + describe("layout", () => { + it("passes layout to buildClickAction", () => { + const buildClickAction = vi.fn(() => undefined); + setStoreState({ buildClickAction }); + const layout = { pages: [] }; + const { result } = renderHook(() => + useBuildWidgetForSave(undefined, layout as never), + ); + result.current(); + + expect(buildClickAction).toHaveBeenCalledWith(layout); + }); + }); + + // ── Empty query skips history ──────────────────────────────────── + + describe("empty query", () => { + it("does not add whitespace-only query to history", () => { + const addToQueryHistory = vi.fn(); + setStoreState({ query: " ", addToQueryHistory }); + const { result } = renderHook(() => useBuildWidgetForSave(undefined)); + result.current(); + + expect(addToQueryHistory).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/src/components/widget-editor/advanced-caching-section.tsx b/app/src/components/widget-editor/advanced-caching-section.tsx new file mode 100644 index 00000000..e7b76649 --- /dev/null +++ b/app/src/components/widget-editor/advanced-caching-section.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useWidgetEditorStore } from "@/stores/widget-editor-store"; +import { Checkbox, Input, Label } from "@neoboard/components"; + +export function AdvancedCachingSection() { + const enableCache = useWidgetEditorStore((s) => s.enableCache); + const setEnableCache = useWidgetEditorStore((s) => s.setEnableCache); + const cacheTtlMinutes = useWidgetEditorStore((s) => s.cacheTtlMinutes); + const setCacheTtlMinutes = useWidgetEditorStore((s) => s.setCacheTtlMinutes); + + return ( +
+

+ Caching +

+
+ setEnableCache(!!checked)} + /> + +
+ {enableCache && ( +
+ + + setCacheTtlMinutes(Math.max(1, Number(e.target.value))) + } + className="w-24" + /> +

+ Results are reused for up to {cacheTtlMinutes} minute + {cacheTtlMinutes !== 1 ? "s" : ""} before re-querying. +

+
+ )} +
+ ); +} diff --git a/app/src/components/widget-editor/advanced-form-refresh-section.tsx b/app/src/components/widget-editor/advanced-form-refresh-section.tsx new file mode 100644 index 00000000..f4523699 --- /dev/null +++ b/app/src/components/widget-editor/advanced-form-refresh-section.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useWidgetEditorStore } from "@/stores/widget-editor-store"; +import { getChartConfig } from "@/lib/plugin/chart-helpers"; +import { Checkbox, Label, Button, Badge } from "@neoboard/components"; + +export interface AdvancedFormRefreshSectionProps { + otherWidgets: { id: string; title: string; chartType: string }[]; +} + +export function AdvancedFormRefreshSection({ + otherWidgets, +}: AdvancedFormRefreshSectionProps) { + const refreshWidgetIds = useWidgetEditorStore((s) => s.refreshWidgetIds); + const setRefreshWidgetIds = useWidgetEditorStore( + (s) => s.setRefreshWidgetIds, + ); + + return ( +
+

+ After Submit +

+

+ Refresh these widgets when the form submits successfully. +

+ {otherWidgets.length > 0 ? ( +
+
+ + {refreshWidgetIds.length} of {otherWidgets.length} selected + + +
+ {otherWidgets.map((w) => ( +
+ { + if (checked) { + setRefreshWidgetIds([...refreshWidgetIds, w.id]); + } else { + setRefreshWidgetIds( + refreshWidgetIds.filter((id: string) => id !== w.id), + ); + } + }} + /> + +
+ ))} +
+ ) : ( +

+ No other widgets on this page. +

+ )} +
+ ); +} diff --git a/app/src/components/widget-editor/advanced-interactivity-section.tsx b/app/src/components/widget-editor/advanced-interactivity-section.tsx new file mode 100644 index 00000000..5dc6532b --- /dev/null +++ b/app/src/components/widget-editor/advanced-interactivity-section.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useWidgetEditorStore } from "@/stores/widget-editor-store"; +import { + Checkbox, + Label, + Button, + Alert, + AlertTitle, + AlertDescription, +} from "@neoboard/components"; +import { Info } from "lucide-react"; + +export interface AdvancedInteractivitySectionProps { + clickActionCollisions: { widgetId: string; title: string }[]; +} + +export function AdvancedInteractivitySection({ + clickActionCollisions, +}: AdvancedInteractivitySectionProps) { + const clickActionEnabled = useWidgetEditorStore((s) => s.clickActionEnabled); + const setClickActionEnabled = useWidgetEditorStore( + (s) => s.setClickActionEnabled, + ); + const actionRules = useWidgetEditorStore((s) => s.actionRules); + const setDialogStep = useWidgetEditorStore((s) => s.setDialogStep); + + return ( +
+

+ Interactivity +

+
+ setClickActionEnabled(!!checked)} + /> + +
+ {clickActionEnabled && ( +
+

+ {actionRules.length === 0 + ? "No action rules configured." + : `${actionRules.length} action rule(s) configured.`} +

+ + {clickActionCollisions.length > 0 && ( + + + + Parameter name already in use + + + {clickActionCollisions.length === 1 + ? `A parameter set here is also set by: ${clickActionCollisions[0].title}.` + : `Parameters set here are also set by: ${clickActionCollisions.map((c) => c.title).join(", ")}.`}{" "} + Multiple widgets writing to the same parameter may conflict. + + + )} +
+ )} +
+ ); +} diff --git a/app/src/components/widget-editor/advanced-styling-section.tsx b/app/src/components/widget-editor/advanced-styling-section.tsx new file mode 100644 index 00000000..0d1c5306 --- /dev/null +++ b/app/src/components/widget-editor/advanced-styling-section.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useWidgetEditorStore } from "@/stores/widget-editor-store"; +import { Checkbox, Label, Button } from "@neoboard/components"; + +export function AdvancedStylingSection() { + const stylingEnabled = useWidgetEditorStore((s) => s.stylingEnabled); + const setStylingEnabled = useWidgetEditorStore((s) => s.setStylingEnabled); + const stylingRules = useWidgetEditorStore((s) => s.stylingRules); + const setDialogStep = useWidgetEditorStore((s) => s.setDialogStep); + + return ( +
+

+ Styling +

+
+ setStylingEnabled(!!checked)} + /> + +
+ {stylingEnabled && ( +
+

+ {stylingRules.length === 0 + ? "No styling rules configured." + : `${stylingRules.length} styling rule(s) configured.`} +

+ +
+ )} +
+ ); +} diff --git a/app/src/components/widget-editor/lab-metadata-form.tsx b/app/src/components/widget-editor/lab-metadata-form.tsx new file mode 100644 index 00000000..1da90d07 --- /dev/null +++ b/app/src/components/widget-editor/lab-metadata-form.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useWidgetEditorStore } from "@/stores/widget-editor-store"; +import { Input, Label } from "@neoboard/components"; + +export function LabMetadataForm() { + const labName = useWidgetEditorStore((s) => s.labName); + const setLabName = useWidgetEditorStore((s) => s.setLabName); + const labDescription = useWidgetEditorStore((s) => s.labDescription); + const setLabDescription = useWidgetEditorStore((s) => s.setLabDescription); + const labTagsInput = useWidgetEditorStore((s) => s.labTagsInput); + const setLabTagsInput = useWidgetEditorStore((s) => s.setLabTagsInput); + + return ( +
+
+ + setLabName(e.target.value)} + placeholder="My chart template" + /> +
+
+ + setLabDescription(e.target.value)} + placeholder="What does this template do?" + /> +
+
+ + setLabTagsInput(e.target.value)} + placeholder="e.g. neo4j, monitoring, kpi" + /> +
+
+ ); +} diff --git a/app/src/components/widget-editor/modal-footer.tsx b/app/src/components/widget-editor/modal-footer.tsx new file mode 100644 index 00000000..048f4809 --- /dev/null +++ b/app/src/components/widget-editor/modal-footer.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useWidgetEditorStore } from "@/stores/widget-editor-store"; +import { Button, LoadingButton, DialogFooter } from "@neoboard/components"; + +export interface ModalFooterProps { + mode: "add" | "edit" | "lab-edit" | "lab-create"; + labError: string | null; + labSaving: boolean; + saveStatus: "idle" | "saving" | "saved"; + isContentOnly: boolean; + onCancel: () => void; + onSave: () => void; + onLabSave: () => void; +} + +export function ModalFooter({ + mode, + labError, + labSaving, + saveStatus, + isContentOnly, + onCancel, + onSave, + onLabSave, +}: ModalFooterProps) { + const chartType = useWidgetEditorStore((s) => s.chartType); + const connectionId = useWidgetEditorStore((s) => s.connectionId); + const query = useWidgetEditorStore((s) => s.query); + const labName = useWidgetEditorStore((s) => s.labName); + const paramWidgetName = useWidgetEditorStore((s) => s.paramWidgetName); + const paramUIType = useWidgetEditorStore((s) => s.paramUIType); + const chartOptions = useWidgetEditorStore((s) => s.chartOptions); + + const isParamSelect = chartType === "parameter-select"; + const isForm = chartType === "form"; + const isLabMode = mode === "lab-edit" || mode === "lab-create"; + + return ( + + {labError && ( +

{labError}

+ )} + + {isLabMode ? ( + + {mode === "lab-edit" ? "Save Template" : "Create Template"} + + ) : ( + + {saveStatus === "saved" + ? "Saved!" + : mode === "edit" + ? "Save Changes" + : "Add Widget"} + + )} +
+ ); +} diff --git a/app/src/components/widget-editor/use-auto-preview.ts b/app/src/components/widget-editor/use-auto-preview.ts new file mode 100644 index 00000000..11787c79 --- /dev/null +++ b/app/src/components/widget-editor/use-auto-preview.ts @@ -0,0 +1,189 @@ +"use client"; + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import type { ConnectionListItem } from "@/hooks/use-connections"; +import { extractReferencedParams } from "@/hooks/use-widget-query"; +import { wrapWithPreviewLimit } from "@/lib/query/wrap-with-preview-limit"; +import type { DashboardWidget } from "@/lib/db/schema"; + +interface UseAutoPreviewOptions { + open: boolean; + mode: "add" | "edit" | "lab-edit" | "lab-create"; + connectionId: string; + query: string; + chartType: string; + allParamValues: Record; + selectedConnection: ConnectionListItem | undefined; + /** Pre-existing preview data — skip auto-preview when provided */ + initialPreviewData?: { data: unknown; resultId: string }; + /** Mutation object from useQueryExecution */ + previewQuery: { + mutate: ( + args: { + connectionId: string; + query: string; + params?: Record; + }, + options?: { + onSuccess?: () => void; + onError?: () => void; + }, + ) => void; + }; + buildWidgetForSave: () => DashboardWidget; + onSave: (widget: DashboardWidget) => void; + onOpenChange: (open: boolean) => void; +} + +export function useAutoPreview({ + open, + mode, + connectionId, + query, + chartType, + allParamValues, + selectedConnection, + initialPreviewData, + previewQuery, + buildWidgetForSave, + onSave, + onOpenChange, +}: UseAutoPreviewOptions) { + const connectionIdRef = useRef(connectionId); + const queryRef = useRef(query); + const allParamValuesRef = useRef(allParamValues); + const selectedConnectionRef = useRef(selectedConnection); + const previewQueryRef = useRef(previewQuery); + const savedTimerRef = useRef | null>(null); + + useLayoutEffect(() => { + connectionIdRef.current = connectionId; + queryRef.current = query; + allParamValuesRef.current = allParamValues; + selectedConnectionRef.current = selectedConnection; + previewQueryRef.current = previewQuery; + }); + + const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">( + "idle", + ); + + const handlePreview = useCallback(() => { + const cId = connectionIdRef.current; + const q = queryRef.current; + if (cId && q.trim()) { + const referenced = extractReferencedParams(q, allParamValuesRef.current); + const params = + Object.keys(referenced).length > 0 ? referenced : undefined; + const connectorType = selectedConnectionRef.current?.type ?? "neo4j"; + const previewQuery_ = wrapWithPreviewLimit(q, connectorType); + previewQueryRef.current.mutate({ + connectionId: cId, + query: previewQuery_, + params, + }); + } + }, []); + + // Auto-run preview when connection and query are present so column selectors + // are populated. Skip if initialPreviewData was provided. + const autoPreviewTriggered = useRef(false); + useEffect(() => { + if (!open) { + autoPreviewTriggered.current = false; + return; + } + if (autoPreviewTriggered.current) return; + if (!connectionId || !query.trim()) return; + if (initialPreviewData) { + autoPreviewTriggered.current = true; + return; + } + autoPreviewTriggered.current = true; + const delay = mode === "add" ? 300 : 50; + const timer = setTimeout(() => { + handlePreview(); + }, delay); + return () => clearTimeout(timer); + }, [open, mode, connectionId, query, handlePreview, initialPreviewData]); + + // Auto-run preview when the query changes (debounced 800ms). + const prevQueryRef = useRef(query); + useEffect(() => { + if (!open) return; + if (prevQueryRef.current === query) return; + prevQueryRef.current = query; + if (!connectionId || !query.trim()) return; + const timer = setTimeout(() => { + handlePreview(); + }, 800); + return () => clearTimeout(timer); + }, [open, query, connectionId, handlePreview]); + + // CMD+Shift+Enter: run query, then save on success. + const handleRunAndSave = useCallback(() => { + if (chartType === "markdown" || chartType === "iframe") return; + if (!query.trim() || saveStatus === "saving") return; + setSaveStatus("saving"); + previewQueryRef.current.mutate( + { connectionId, query }, + { + onSuccess: () => { + if (savedTimerRef.current !== null) { + clearTimeout(savedTimerRef.current); + } + setSaveStatus("saved"); + savedTimerRef.current = setTimeout(() => { + setSaveStatus("idle"); + savedTimerRef.current = null; + }, 1500); + const widgetToSave = buildWidgetForSave(); + onSave(widgetToSave); + onOpenChange(false); + }, + onError: () => { + setSaveStatus("idle"); + }, + }, + ); + }, [ + query, + saveStatus, + connectionId, + chartType, + buildWidgetForSave, + onSave, + onOpenChange, + ]); + + // Register keyboard shortcut + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "Enter") { + e.preventDefault(); + handleRunAndSave(); + } + }; + document.addEventListener("keydown", handler); + return () => { + document.removeEventListener("keydown", handler); + }; + }, [open, handleRunAndSave]); + + // Clean up the "saved" feedback timer when the modal is closed. + useEffect(() => { + if (!open && savedTimerRef.current !== null) { + clearTimeout(savedTimerRef.current); + savedTimerRef.current = null; + } + }, [open]); + + return { handlePreview, saveStatus }; +} diff --git a/app/src/components/widget-editor/use-widget-save.ts b/app/src/components/widget-editor/use-widget-save.ts new file mode 100644 index 00000000..bc471c72 --- /dev/null +++ b/app/src/components/widget-editor/use-widget-save.ts @@ -0,0 +1,140 @@ +"use client"; + +import { useCallback } from "react"; +import { useWidgetEditorStore } from "@/stores/widget-editor-store"; +import type { DashboardWidget, DashboardLayoutV2 } from "@/lib/db/schema"; +import { resolveInternalParamType } from "./parameter-config-section"; + +/** + * Builds a DashboardWidget object from the current widget editor store state. + * Encapsulates the settings construction logic shared by all save paths. + */ +export function useBuildWidgetForSave( + existingWidget: DashboardWidget | undefined, + layout?: DashboardLayoutV2, +): () => DashboardWidget { + const chartType = useWidgetEditorStore((s) => s.chartType); + const connectionId = useWidgetEditorStore((s) => s.connectionId); + const query = useWidgetEditorStore((s) => s.query); + const title = useWidgetEditorStore((s) => s.title); + const chartOptions = useWidgetEditorStore((s) => s.chartOptions); + const formFields = useWidgetEditorStore((s) => s.formFields); + const transforms = useWidgetEditorStore((s) => s.transforms); + const transformsEnabled = useWidgetEditorStore((s) => s.transformsEnabled); + const enableCache = useWidgetEditorStore((s) => s.enableCache); + const cacheTtlMinutes = useWidgetEditorStore((s) => s.cacheTtlMinutes); + const colorScales = useWidgetEditorStore((s) => s.colorScales); + const refreshWidgetIds = useWidgetEditorStore((s) => s.refreshWidgetIds); + const paramUIType = useWidgetEditorStore((s) => s.paramUIType); + const dateSub = useWidgetEditorStore((s) => s.dateSub); + const multiSelect = useWidgetEditorStore((s) => s.multiSelect); + const paramWidgetName = useWidgetEditorStore((s) => s.paramWidgetName); + const templateId = useWidgetEditorStore((s) => s.templateId); + const templateSyncedAt = useWidgetEditorStore((s) => s.templateSyncedAt); + const buildClickAction = useWidgetEditorStore((s) => s.buildClickAction); + const buildStylingConfig = useWidgetEditorStore((s) => s.buildStylingConfig); + const addToQueryHistory = useWidgetEditorStore((s) => s.addToQueryHistory); + + return useCallback(() => { + const isParamSelect = chartType === "parameter-select"; + const isForm = chartType === "form"; + const isContentOnly = chartType === "markdown" || chartType === "iframe"; + + // Record query in history + if (query.trim() && !isParamSelect && !isContentOnly) { + addToQueryHistory(query); + } + + const clickAction = buildClickAction(layout); + const stylingConfig = buildStylingConfig(); + const updatedHistory = useWidgetEditorStore.getState().queryHistory; + + const resolvedChartOptions = isParamSelect + ? { + ...chartOptions, + parameterType: resolveInternalParamType( + paramUIType, + dateSub, + multiSelect, + ), + parameterName: paramWidgetName, + seedQuery: + paramUIType === "select" + ? (chartOptions.seedQuery ?? "") + : undefined, + } + : isForm + ? { + ...chartOptions, + refreshWidgetIds: + refreshWidgetIds.length > 0 ? refreshWidgetIds : undefined, + } + : chartOptions; + + const skipSettings = isParamSelect || isForm || isContentOnly; + + return { + id: existingWidget?.id ?? crypto.randomUUID(), + chartType, + connectionId: + (isParamSelect && paramUIType !== "select") || isContentOnly + ? "" + : connectionId, + query: isParamSelect || isContentOnly ? "" : query, + params: existingWidget?.params, + settings: { + ...(existingWidget?.settings ?? {}), + title: title || undefined, + chartOptions: resolvedChartOptions, + formFields: isForm ? formFields : undefined, + clickAction: skipSettings ? undefined : clickAction, + stylingConfig: skipSettings ? undefined : stylingConfig, + conditionalFormatting: skipSettings + ? undefined + : colorScales.length + ? { colorScales } + : undefined, + enableCache: skipSettings ? undefined : enableCache, + cacheTtlMinutes: skipSettings ? undefined : cacheTtlMinutes, + transforms: skipSettings + ? undefined + : transforms.length + ? transforms + : undefined, + transformsEnabled: skipSettings ? undefined : transformsEnabled, + queryHistory: + isParamSelect || isContentOnly + ? undefined + : updatedHistory.length + ? updatedHistory + : undefined, + }, + templateId, + templateSyncedAt, + }; + }, [ + existingWidget, + layout, + chartType, + connectionId, + query, + title, + chartOptions, + formFields, + transforms, + transformsEnabled, + enableCache, + cacheTtlMinutes, + colorScales, + refreshWidgetIds, + paramUIType, + dateSub, + multiSelect, + paramWidgetName, + templateId, + templateSyncedAt, + buildClickAction, + buildStylingConfig, + addToQueryHistory, + ]); +}