diff --git a/app/src/components/widget-editor-modal.tsx b/app/src/components/widget-editor-modal.tsx index a060b70b..d8ebd808 100644 --- a/app/src/components/widget-editor-modal.tsx +++ b/app/src/components/widget-editor-modal.tsx @@ -8,7 +8,6 @@ import React, { useMemo, useRef, } from "react"; -import { CardContainer } from "./card-container"; import { useQueryExecution } from "@/hooks/use-query-execution"; import type { DashboardWidget, @@ -23,13 +22,7 @@ import { findParameterCollisions, aggregateClickActionParamNames, } from "@/lib/parameter/collect-parameter-names"; -import { - AlertCircle, - AlertTriangle, - Info, - Play, - FlaskConical, -} from "lucide-react"; +import { AlertTriangle, Info, FlaskConical } from "lucide-react"; import { useWidgetTemplates, useCreateWidgetTemplate, @@ -55,14 +48,7 @@ import { DialogTitle, DialogFooter, Checkbox, - CodePreview, - MarkdownWidget, - IframeWidget, - Tooltip, - TooltipTrigger, - TooltipContent, } from "@neoboard/components"; -import type { ColorScaleConfig } from "@neoboard/components"; import { getCompatibleChartTypes, getChartConfig, @@ -71,10 +57,7 @@ import { getAllChartTypes, } from "@/lib/plugin/chart-helpers"; import type { ChartType } from "@/lib/plugin/chart-helpers"; -import { - type ConnectorType, - CONNECTOR_LANGUAGES, -} from "@/lib/connector/connector-types"; +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"; @@ -86,16 +69,13 @@ import { FormFieldsEditor } from "./widget-editor/form-fields-editor"; import { ParameterConfigSection, resolveInternalParamType, - reverseParamTypeMapping, } from "./widget-editor/parameter-config-section"; -// ParamUIType/DateSubType types used by the store, not directly in modal -import { ParameterPreview } from "./widget-editor/parameter-preview"; -import type { FormFieldDef } from "@/lib/widget/form-field-def"; import { ActionRulesEditor } from "./widget-editor/action-rules-editor"; import { StylingRulesEditor } from "./widget-editor/styling-rules-editor"; import { useWidgetEditorStore } from "@/stores/widget-editor-store"; -import { migrateColorThresholds } from "@/lib/dashboard/migrate-color-thresholds"; import { TransformEditor } from "./widget-editor/transform-editor"; +import { TemplateBrowser } from "./widget-editor/template-browser"; +import { WidgetPreviewPanel } from "./widget-editor/widget-preview-panel"; export interface WidgetEditorModalProps { open: boolean; @@ -141,23 +121,15 @@ export function WidgetEditorModal({ const connectionId = useWidgetEditorStore((s) => s.connectionId); const setConnectionId = useWidgetEditorStore((s) => s.setConnectionId); const query = useWidgetEditorStore((s) => s.query); - const setQuery = useWidgetEditorStore((s) => s.setQuery); const chartOptions = useWidgetEditorStore((s) => s.chartOptions); const setChartOptions = useWidgetEditorStore((s) => s.setChartOptions); const stylingRules = useWidgetEditorStore((s) => s.stylingRules); - const setStylingRules = useWidgetEditorStore((s) => s.setStylingRules); const actionRules = useWidgetEditorStore((s) => s.actionRules); - const setActionRules = useWidgetEditorStore((s) => s.setActionRules); const formFields = useWidgetEditorStore((s) => s.formFields); - const setFormFields = useWidgetEditorStore((s) => s.setFormFields); const paramUIType = useWidgetEditorStore((s) => s.paramUIType); - const setParamUIType = useWidgetEditorStore((s) => s.setParamUIType); const dateSub = useWidgetEditorStore((s) => s.dateSub); - const setDateSub = useWidgetEditorStore((s) => s.setDateSub); const multiSelect = useWidgetEditorStore((s) => s.multiSelect); - const setMultiSelect = useWidgetEditorStore((s) => s.setMultiSelect); const paramWidgetName = useWidgetEditorStore((s) => s.paramWidgetName); - const setParamWidgetName = useWidgetEditorStore((s) => s.setParamWidgetName); const transforms = useWidgetEditorStore((s) => s.transforms); const setTransforms = useWidgetEditorStore((s) => s.setTransforms); const transformsEnabled = useWidgetEditorStore((s) => s.transformsEnabled); @@ -165,75 +137,76 @@ export function WidgetEditorModal({ (s) => s.setTransformsEnabled, ); + // ── 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, + ); + // ── Initialize store when modal opens ──────────────────────────── // loadFromWidget / resetForAdd sets all store fields from the widget prop. // This replaces the old bidirectional sync approach. useEffect(() => { if (!open) return; - if (widget) { - useWidgetEditorStore.getState().loadFromWidget(widget); + const store = useWidgetEditorStore.getState(); + if (mode === "edit" && widget) { + store.loadFromWidget(widget); + } else if (mode === "lab-edit" && templateProp) { + // Initialize from template — reset first, then override with template data + store.resetForAdd(); + store.setChartType(templateProp.chartType); + store.setConnectionId(templateProp.connectionId ?? ""); + store.setQuery(templateProp.query ?? ""); + store.setTitle((templateProp.settings?.title as string) ?? ""); + store.setChartOptions( + (templateProp.settings?.chartOptions as Record) ?? + getDefaultChartSettings(templateProp.chartType), + ); + store.setLabName(templateProp.name); + store.setLabDescription(templateProp.description ?? ""); + store.setLabTagsInput((templateProp.tags ?? []).join(", ")); } else { - useWidgetEditorStore.getState().resetForAdd(); + // add or lab-create + store.resetForAdd(); + // "add" mode defaults to bar chart (more useful default than table) + if (mode === "add") { + store.setChartType("bar"); + store.setChartOptions(getDefaultChartSettings("bar")); + } + if (mode === "lab-create") { + store.setLabName(""); + store.setLabDescription(""); + store.setLabTagsInput(""); + } } - }, [open, widget]); - - // ── Local-only state ─────────────────────────────────────────────── - const [title, setTitle] = useState((widget?.settings?.title as string) ?? ""); - const [templateId, setTemplateId] = useState( - widget?.templateId, - ); - const [templateSyncedAt, setTemplateSyncedAt] = useState( - widget?.templateSyncedAt, - ); - // Click action state — these remain local because no sub-editor writes to them - const existingClickAction = widget?.settings?.clickAction as - | ClickAction - | undefined; - const existingParamMapping = existingClickAction?.parameterMapping; - const [clickActionEnabled, setClickActionEnabled] = - useState(!!existingClickAction); - const [clickActionType, setClickActionType] = useState( - existingClickAction?.type ?? "set-parameter", - ); - const [parameterName, setParameterName] = useState( - existingParamMapping?.parameterName ?? "", - ); - const [sourceField, setSourceField] = useState( - existingParamMapping?.sourceField ?? "", - ); - const [targetPageId, setTargetPageId] = useState( - existingClickAction?.targetPageId ?? "", - ); - const [clickableColumns, setClickableColumns] = useState( - existingClickAction?.clickableColumns ?? [], - ); - - // Styling toggle + color scales — local (not written by sub-editors) - const existingStylingConfig = widget?.settings?.stylingConfig as - | StylingConfig - | undefined; - const [stylingEnabled, setStylingEnabled] = useState( - !!existingStylingConfig?.enabled, - ); - const existingConditionalFormatting = widget?.settings - ?.conditionalFormatting as { colorScales?: ColorScaleConfig[] } | undefined; - const [colorScales, setColorScales] = useState( - existingConditionalFormatting?.colorScales ?? [], - ); - - const [dialogStep, setDialogStep] = useState< - "main" | "rules" | "styling-rules" | "templates" - >("main"); - const [templateSearch, setTemplateSearch] = useState(""); + }, [open, mode, widget, templateProp]); - // Lab-mode metadata state - const [labName, setLabName] = useState(templateProp?.name ?? ""); - const [labDescription, setLabDescription] = useState( - templateProp?.description ?? "", - ); - const [labTagsInput, setLabTagsInput] = useState( - (templateProp?.tags ?? []).join(", "), - ); + // ── Local-only state (not in store) ──────────────────────────────── // Lab-mode mutations const createTemplate = useCreateWidgetTemplate(); @@ -270,14 +243,6 @@ export function WidgetEditorModal({ ); const savedTimerRef = useRef | null>(null); - // Cache settings - const [enableCache, setEnableCache] = useState( - widget?.settings?.enableCache !== false, - ); - const [cacheTtlMinutes, setCacheTtlMinutes] = useState( - (widget?.settings?.cacheTtlMinutes as number | undefined) ?? 5, - ); - // 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( @@ -329,10 +294,6 @@ export function WidgetEditorModal({ }); }, [seedQueryExecution.data]); - // Track whether the connector was changed in edit mode so we can warn - // the user that their query may no longer be valid. - const [connectorChanged, setConnectorChanged] = useState(false); - const previewQuery = useQueryExecution(); const allParamValues = useParameterValues(); @@ -369,15 +330,18 @@ export function WidgetEditorModal({ function applyTemplate(t: WidgetTemplate) { applyingTemplateRef.current = true; - setTemplateId(t.id); + const store = useWidgetEditorStore.getState(); // API returns dates as ISO strings (JSON serialization), not Date objects - setTemplateSyncedAt( - t.updatedAt ? String(t.updatedAt) : new Date().toISOString(), - ); - setChartType(t.chartType); - setQuery(t.query ?? ""); - setTitle((t.settings?.title as string) ?? ""); - setChartOptions( + useWidgetEditorStore.setState({ + templateId: t.id, + templateSyncedAt: t.updatedAt + ? String(t.updatedAt) + : new Date().toISOString(), + }); + store.setChartType(t.chartType); + store.setQuery(t.query ?? ""); + store.setTitle((t.settings?.title as string) ?? ""); + store.setChartOptions( (t.settings?.chartOptions as Record) ?? getDefaultChartSettings(t.chartType), ); @@ -386,16 +350,15 @@ export function WidgetEditorModal({ if (!connectionId) { if (t.connectionId && connections.some((c) => c.id === t.connectionId)) { // Prefer the template's bound connection if it exists - setConnectionId(t.connectionId); + store.setConnectionId(t.connectionId); } else if (t.connectorType) { // Fall back to first connection of matching type const match = connections.find((c) => c.type === t.connectorType); - if (match) setConnectionId(match.id); + if (match) store.setConnectionId(match.id); } } - setTemplateSearch(""); - setDialogStep("main"); + store.setDialogStep("main"); } // Pass connector type directly — the language resolver registry maps it // to the right editor extension (e.g., "neo4j" → cypher, "postgresql" → sql). @@ -452,164 +415,15 @@ export function WidgetEditorModal({ [setChartType], ); - // Reset state when opening + // Reset local query execution state and track initial chart type for edit mode. + // All field initialization is handled by the store initialization effect above. useEffect(() => { if (open) { - if (mode === "add") { - setChartType("bar"); - setConnectionId(""); - setQuery(""); - setTitle(""); - setChartOptions(getDefaultChartSettings("bar")); - setClickActionEnabled(false); - setClickActionType("set-parameter"); - setParameterName(""); - setSourceField(""); - setTargetPageId(""); - setClickableColumns([]); - setEnableCache(true); - setCacheTtlMinutes(5); - setConnectorChanged(false); - setParamUIType("select"); - setDateSub("single"); - setMultiSelect(false); - setParamWidgetName(""); - setFormFields([]); - setRefreshWidgetIds([]); - setActionRules([]); - setStylingEnabled(false); - setStylingRules([]); - setColorScales([]); - setDialogStep("main"); - seedQueryExecution.reset(); - previewQuery.reset(); - } else if (widget) { - const s = widget.settings ?? {}; - const opts = (s.chartOptions as Record) ?? {}; - const ca = s.clickAction as ClickAction | undefined; - const caMapping = ca?.parameterMapping; - const sc = s.stylingConfig as StylingConfig | undefined; - const cf = s.conditionalFormatting as - | { colorScales?: ColorScaleConfig[] } - | undefined; - + if (mode === "edit" && widget) { editInitialChartTypeRef.current = widget.chartType; - setChartType(widget.chartType); - setConnectionId(widget.connectionId); - setQuery(widget.query); - setTitle((s.title as string) ?? ""); - setChartOptions( - Object.keys(opts).length > 0 - ? opts - : getDefaultChartSettings(widget.chartType), - ); - - // Click action - setClickActionEnabled(!!ca); - setClickActionType(ca?.type ?? "set-parameter"); - setParameterName(caMapping?.parameterName ?? ""); - setSourceField(caMapping?.sourceField ?? ""); - setTargetPageId(ca?.targetPageId ?? ""); - setClickableColumns(ca?.clickableColumns ?? []); - setActionRules(ca?.rules ?? []); - - // Styling rules (new format or migrated from legacy) - if (sc) { - setStylingEnabled(sc.enabled); - setStylingRules(sc.rules ?? []); - } else { - const legacyThresholds = opts.colorThresholds; - if (typeof legacyThresholds === "string" && legacyThresholds.trim()) { - const migrated = migrateColorThresholds(legacyThresholds); - setStylingEnabled(!!migrated?.enabled); - setStylingRules(migrated?.rules ?? []); - } else { - setStylingEnabled(false); - setStylingRules([]); - } - } - - // Color scales - setColorScales(cf?.colorScales ?? []); - - setDialogStep("main"); - setEnableCache(s.enableCache !== false); - setCacheTtlMinutes((s.cacheTtlMinutes as number | undefined) ?? 5); - setConnectorChanged(false); - setFormFields((s.formFields as FormFieldDef[] | undefined) ?? []); - setRefreshWidgetIds( - (opts.refreshWidgetIds as string[] | undefined) ?? [], - ); - - // Initialize parameter editor state from existing widget - if (widget.chartType === "parameter-select") { - const opts = widget.settings?.chartOptions as - | Record - | undefined; - const internalType = (opts?.parameterType as string) ?? "select"; - const mapped = reverseParamTypeMapping(internalType); - setParamUIType(mapped.uiType); - setDateSub(mapped.dateSub); - setMultiSelect(mapped.multi); - setParamWidgetName((opts?.parameterName as string) ?? ""); - } else { - setParamUIType("select"); - setDateSub("single"); - setMultiSelect(false); - setParamWidgetName(""); - } - - seedQueryExecution.reset(); - previewQuery.reset(); - } else if (mode === "lab-create") { - // Fresh lab-create: same as add but with metadata fields - setChartType("bar"); - setConnectionId(""); - setQuery(""); - setTitle(""); - setChartOptions(getDefaultChartSettings("bar")); - setClickActionEnabled(false); - setClickActionType("set-parameter"); - setParameterName(""); - setSourceField(""); - setTargetPageId(""); - setClickableColumns([]); - setEnableCache(true); - setCacheTtlMinutes(5); - setConnectorChanged(false); - setFormFields([]); - setActionRules([]); - setStylingEnabled(false); - setStylingRules([]); - setColorScales([]); - setLabName(""); - setLabDescription(""); - setLabTagsInput(""); - setDialogStep("main"); - seedQueryExecution.reset(); - previewQuery.reset(); - } else if (mode === "lab-edit" && templateProp) { - // Initialize from template - setChartType(templateProp.chartType); - setConnectionId(templateProp.connectionId ?? ""); - setQuery(templateProp.query ?? ""); - setTitle((templateProp.settings?.title as string) ?? ""); - setChartOptions( - (templateProp.settings?.chartOptions as Record) ?? - getDefaultChartSettings(templateProp.chartType), - ); - setLabName(templateProp.name); - setLabDescription(templateProp.description ?? ""); - setLabTagsInput((templateProp.tags ?? []).join(", ")); - setClickActionEnabled(false); - setStylingEnabled(false); - setStylingRules([]); - setColorScales([]); - setActionRules([]); - setDialogStep("main"); - seedQueryExecution.reset(); - previewQuery.reset(); } + seedQueryExecution.reset(); + previewQuery.reset(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, mode, widget, templateProp]); @@ -652,70 +466,18 @@ export function WidgetEditorModal({ // eslint-disable-next-line react-hooks/exhaustive-deps -- refs guard the reset; mode is not needed }, [chartType]); - // Build click action from current editor state - const buildClickAction = useCallback((): ClickAction | undefined => { - if (!clickActionEnabled || !chartSupportsClickAction(chartType)) - return undefined; - const needsParam = - clickActionType === "set-parameter" || - clickActionType === "set-parameter-and-navigate"; - const needsPage = - clickActionType === "navigate-to-page" || - clickActionType === "set-parameter-and-navigate"; - const trimmedParamName = parameterName.trim(); - const trimmedSourceField = sourceField.trim(); - const trimmedTargetPageId = targetPageId.trim(); - if (needsParam && !trimmedParamName) return undefined; - // For tables, sourceField is empty (cell-click provides the value directly) - const resolvedSourceField = chartType === "table" ? "" : trimmedSourceField; - if (needsParam && chartType !== "table" && !resolvedSourceField) - return undefined; - if (needsPage && !trimmedTargetPageId) return undefined; - // Validate targetPageId against current layout pages to prevent stale references - if (needsPage && layout) { - const validPageIds = new Set((layout.pages ?? []).map((p) => p.id)); - if (!validPageIds.has(trimmedTargetPageId)) return undefined; - } - return { - type: actionRules.length > 0 ? actionRules[0].type : clickActionType, - ...(needsParam && actionRules.length === 0 - ? { - parameterMapping: { - parameterName: trimmedParamName, - sourceField: resolvedSourceField, - }, - } - : {}), - ...(needsPage && actionRules.length === 0 - ? { targetPageId: trimmedTargetPageId } - : {}), - ...(chartType === "table" && - clickableColumns.length > 0 && - actionRules.length === 0 - ? { clickableColumns } - : {}), - ...(actionRules.length > 0 ? { rules: actionRules } : {}), - }; - }, [ - clickActionEnabled, - clickActionType, - parameterName, - sourceField, - chartType, - targetPageId, - layout, - clickableColumns, - actionRules, - ]); + // Build click action / styling config from the store + const buildClickAction = useCallback( + (): ClickAction | undefined => + useWidgetEditorStore.getState().buildClickAction(layout), + [layout], + ); - const buildStylingConfig = useCallback((): StylingConfig | undefined => { - if (!stylingEnabled || !chartSupportsStyling(chartType)) return undefined; - if (stylingRules.length === 0) return undefined; - return { - enabled: true, - rules: stylingRules, - }; - }, [stylingEnabled, chartType, stylingRules]); + const buildStylingConfig = useCallback( + (): StylingConfig | undefined => + useWidgetEditorStore.getState().buildStylingConfig(), + [], + ); const handlePreview = useCallback(() => { const cId = connectionIdRef.current; @@ -1052,106 +814,13 @@ export function WidgetEditorModal({ /> ) : null} {dialogStep === "templates" && ( - <> - - Browse Templates - -
- {!templatesLoading && templates && templates.length > 0 && ( - setTemplateSearch(e.target.value)} - className="mb-3 max-w-xs" - /> - )} - {templatesLoading && ( -
-

Loading templates...

-
- )} - {!templatesLoading && (!templates || templates.length === 0) && ( -
- -

- No templates available - {selectedConnectorType - ? ` for ${selectedConnectorType}` - : ""} - . -

-
- )} - {!templatesLoading && - templates && - templates.length > 0 && - (() => { - const filtered = templateSearch - ? templates.filter((t) => - t.name - .toLowerCase() - .includes(templateSearch.toLowerCase()), - ) - : templates; - return filtered.length === 0 ? ( -
-

- No templates match “{templateSearch}” -

-
- ) : ( -
- {filtered.map((t) => ( - - ))} -
- ); - })()} -
- - - - + setDialogStep("main")} + /> )} {dialogStep === "main" && ( <> @@ -1620,168 +1289,43 @@ export function WidgetEditorModal({ {/* Right column: preview */} -
-
- - {!isParamSelect && !isForm && !isContentOnly && ( - - )} - {!isParamSelect && - !isForm && - !isContentOnly && - previewQuery.isError && ( - - - - - -

Query failed

-

- {previewQuery.error.message} -

-
-
- )} -
- -
- {isMarkdown ? ( - - ) : isIframe ? ( - - ) : isParamSelect ? ( - - ) : isForm ? ( - formFields.length > 0 ? ( -
- {formFields.map((f) => ( -
- -
- {f.parameterType} -
-
- ))} - -
- ) : ( -
- Add fields in the Fields section below to see the form - preview -
- ) - ) : ( - <> - {previewQuery.isPending && ( -
-
-
- )} - {previewQuery.isError && - !previewQuery.data && - !initialPreviewData ? ( -
- -

- Query failed -

-

- {previewQuery.error.message} -

-
- ) : previewQuery.data || initialPreviewData ? ( - - ) : connectionId && - query.trim() && - !previewQuery.isError ? ( -
-
-
- ) : ( -
- Run a query to see the preview -
- )} - - )} -
-
+
diff --git a/app/src/components/widget-editor/__tests__/template-browser.test.tsx b/app/src/components/widget-editor/__tests__/template-browser.test.tsx new file mode 100644 index 00000000..94a010e2 --- /dev/null +++ b/app/src/components/widget-editor/__tests__/template-browser.test.tsx @@ -0,0 +1,163 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { WidgetTemplate } from "@/lib/db/schema"; + +vi.mock("@neoboard/components", () => ({ + Button: ({ + children, + onClick, + ...props + }: React.PropsWithChildren>) => ( + + ), + Input: (props: Record) => , + Badge: ({ + children, + ...props + }: React.PropsWithChildren>) => ( + {children} + ), + DialogHeader: ({ children }: React.PropsWithChildren) => ( +
{children}
+ ), + DialogTitle: ({ children }: React.PropsWithChildren) =>

{children}

, + DialogFooter: ({ children }: React.PropsWithChildren) => ( +
{children}
+ ), + CodePreview: ({ value }: { value: string }) =>
{value}
, +})); + +vi.mock("@/lib/plugin/chart-helpers", () => ({ + getChartConfig: (t: string) => ({ label: t }), +})); + +vi.mock("@/lib/connector/connector-types", () => ({ + CONNECTOR_LANGUAGES: { neo4j: "Cypher", postgresql: "SQL" }, +})); + +import { TemplateBrowser } from "../template-browser"; + +const sampleTemplate: WidgetTemplate = { + id: "t1", + name: "Movies Bar Chart", + description: "Top movies", + chartType: "bar", + connectorType: "neo4j", + connectionId: null, + query: "MATCH (m:Movie) RETURN m LIMIT 10", + settings: {}, + tags: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + userId: "u1", + tenantId: "t1", +} as unknown as WidgetTemplate; + +describe("TemplateBrowser", () => { + it("shows loading state", () => { + render( + , + ); + expect(screen.getByText(/Loading templates/)).toBeInTheDocument(); + }); + + it("shows empty state when no templates", () => { + render( + , + ); + expect(screen.getByText(/No templates available/)).toBeInTheDocument(); + expect(screen.getByText(/for neo4j/)).toBeInTheDocument(); + }); + + it("renders template cards when templates are present", () => { + render( + , + ); + expect(screen.getByText("Movies Bar Chart")).toBeInTheDocument(); + }); + + it("filters by search", () => { + render( + , + ); + const search = screen.getByPlaceholderText(/Search by name/); + fireEvent.change(search, { target: { value: "Movies" } }); + expect(screen.getByText("Movies Bar Chart")).toBeInTheDocument(); + expect(screen.queryByText("Users Table")).not.toBeInTheDocument(); + }); + + it("shows 'no match' when search has no results", () => { + render( + , + ); + const search = screen.getByPlaceholderText(/Search by name/); + fireEvent.change(search, { target: { value: "xyz-nothing" } }); + expect(screen.getByText(/No templates match/)).toBeInTheDocument(); + }); + + it("calls onApply when a template card is clicked", () => { + const onApply = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("Movies Bar Chart")); + expect(onApply).toHaveBeenCalledWith(sampleTemplate); + }); + + it("calls onBack when Back button is clicked", () => { + const onBack = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("Back")); + expect(onBack).toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/widget-editor/__tests__/widget-preview-panel.test.tsx b/app/src/components/widget-editor/__tests__/widget-preview-panel.test.tsx new file mode 100644 index 00000000..fece0501 --- /dev/null +++ b/app/src/components/widget-editor/__tests__/widget-preview-panel.test.tsx @@ -0,0 +1,199 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { Transform } from "@/lib/query/data-transforms"; + +vi.mock("@neoboard/components", () => ({ + Button: ({ + children, + onClick, + disabled, + ...props + }: React.PropsWithChildren>) => ( + + ), + Label: ({ + children, + ...props + }: React.PropsWithChildren>) => ( + + ), + Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, + TooltipTrigger: ({ children }: React.PropsWithChildren) => <>{children}, + TooltipContent: ({ children }: React.PropsWithChildren) => ( +
{children}
+ ), + MarkdownWidget: ({ content }: { content?: string }) => ( +
{content}
+ ), + IframeWidget: ({ url }: { url?: string }) => ( +