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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,26 @@ describe("WidgetPreviewPanel", () => {
expect(screen.queryByText("Run")).not.toBeInTheDocument();
});

it("keeps the Run button grouped with the error icon so it doesn't shift on error", () => {
render(
<WidgetPreviewPanel
{...makeProps({
previewQuery: {
isPending: false,
isError: true,
error: new Error("boom"),
data: undefined,
},
})}
/>,
);
const runButton = screen.getByText("Run").closest("button")!;
const errorButton = screen.getByLabelText(/Query failed: boom/);
// Both live in the same action group (not as separate justify-between
// children), so adding the error icon can't push the Run button inward.
expect(runButton.parentElement).toBe(errorButton.parentElement);
});

it("shows a waiting state instead of running an unbound-param query (#1055)", () => {
render(
<WidgetPreviewPanel
Expand Down
72 changes: 35 additions & 37 deletions app/src/components/widget-editor/widget-preview-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -307,45 +307,43 @@ export function WidgetPreviewPanel({
<div className="flex items-center justify-between">
<Label className="mb-0">Preview</Label>
{!isParamSelect && !isForm && !isContentOnly && (
<Button
variant="outline"
size="sm"
onClick={onRunPreview}
disabled={isRunDisabled(
connectionId,
query,
previewQuery.isPending,
<div className="flex items-center gap-2">
{!waitingForParams && previewQuery.isError && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center text-destructive"
aria-label={`Query failed: ${previewQuery.error?.message}`}
>
<AlertCircle className="h-4 w-4 shrink-0" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-sm text-xs">
<p className="font-medium">Query failed</p>
<p className="opacity-80">{previewQuery.error?.message}</p>
</TooltipContent>
</Tooltip>
)}
>
{previewQuery.isPending ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent mr-1.5" />
) : (
<Play className="h-3 w-3 mr-1.5" />
)}
Run
</Button>
<Button
variant="outline"
size="sm"
onClick={onRunPreview}
disabled={isRunDisabled(
connectionId,
query,
previewQuery.isPending,
)}
>
{previewQuery.isPending ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent mr-1.5" />
) : (
<Play className="h-3 w-3 mr-1.5" />
)}
Run
</Button>
</div>
)}
{!isParamSelect &&
!isForm &&
!isContentOnly &&
!waitingForParams &&
previewQuery.isError && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center text-destructive"
aria-label={`Query failed: ${previewQuery.error?.message}`}
>
<AlertCircle className="h-4 w-4 shrink-0" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-sm text-xs">
<p className="font-medium">Query failed</p>
<p className="opacity-80">{previewQuery.error?.message}</p>
</TooltipContent>
</Tooltip>
)}
</div>

<div
Expand Down
31 changes: 31 additions & 0 deletions component/src/components/composed/__tests__/query-editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@
expect(screen.getByText("Cypher")).toBeInTheDocument();
});

it("renders language label — sql → SQL", async () => {

Check warning on line 160 in component/src/components/composed/__tests__/query-editor.test.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace these 3 tests with a single Parameterized one.

See more on https://sonarcloud.io/project/issues?id=alfredo1996_neoboard&issues=AZ87sXYCLTv_5z3Wq34O&open=AZ87sXYCLTv_5z3Wq34O&pullRequest=1193
render(<QueryEditor language="sql" />);
await flushAsync();
expect(screen.getByText("SQL")).toBeInTheDocument();
Expand Down Expand Up @@ -345,6 +345,37 @@
});
});

// ---------------------------------------------------------------------------
// Placeholder switching — compartment reconfigure (regression: placeholder
// stayed on the Cypher example after switching the connection to SQL)
// ---------------------------------------------------------------------------

describe("QueryEditor — placeholder switching", () => {
it("reconfigures the placeholder compartment when the placeholder prop changes", async () => {
const { rerender } = render(
<QueryEditor
language="cypher"
placeholder="MATCH (n) RETURN n.name AS name LIMIT 10"
/>,
);
await flushAsync();
mockDispatch.mockClear();

rerender(
<QueryEditor language="sql" placeholder="SELECT * FROM users LIMIT 10" />,
);
await flushAsync();

const placeholderDispatch = mockDispatch.mock.calls.find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
([arg]: any[]) =>
arg?.effects?.ext?.type === "placeholder" &&
arg?.effects?.ext?.text === "SELECT * FROM users LIMIT 10",
);
expect(placeholderDispatch).toBeDefined();
});
});

// ---------------------------------------------------------------------------
// readOnly prop
// ---------------------------------------------------------------------------
Expand Down
41 changes: 38 additions & 3 deletions component/src/components/composed/query-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ async function buildExtensions(
langCompartmentExt: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CM Compartment-wrapped extension
readOnlyCompartmentExt: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- opaque CM Compartment
placeholderCompartment: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic CM types
): Promise<any[]> {
const [
Expand Down Expand Up @@ -114,7 +116,7 @@ async function buildExtensions(
runKeymap,
langCompartmentExt,
autocompletion(),
cmPlaceholder(placeholder),
placeholderCompartment.of(cmPlaceholder(placeholder)),
oneDark,
baseTheme,
updateListener,
Expand Down Expand Up @@ -161,6 +163,7 @@ function QueryEditor({
const languageRef = React.useRef(language);
const readOnlyRef = React.useRef(readOnly);
const schemaRef = React.useRef(schema);
const placeholderRef = React.useRef(placeholder);
React.useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
Expand All @@ -182,6 +185,9 @@ function QueryEditor({
React.useEffect(() => {
schemaRef.current = schema;
}, [schema]);
React.useEffect(() => {
placeholderRef.current = placeholder;
}, [placeholder]);

// Shared abort signal so any new initEditor call cancels the previous one.
const initAbortRef = React.useRef<{ aborted: boolean }>({ aborted: false });
Expand All @@ -195,6 +201,8 @@ function QueryEditor({
const languageCompartmentRef = React.useRef<any>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- opaque CM Compartment
const readOnlyCompartmentRef = React.useRef<any>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- opaque CM Compartment
const placeholderCompartmentRef = React.useRef<any>(null);
// Cache EditorState after first load so the readOnly effect can reconfigure
// the compartment synchronously (no extra async import tick).
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- opaque CM EditorState
Expand Down Expand Up @@ -244,8 +252,10 @@ function QueryEditor({

const langCompartment = new Compartment();
const readOnlyCompartment = new Compartment();
const placeholderCompartment = new Compartment();
languageCompartmentRef.current = langCompartment;
readOnlyCompartmentRef.current = readOnlyCompartment;
placeholderCompartmentRef.current = placeholderCompartment;

// Capture the schema at the start — it may change while we await imports.
const initialSchema = schemaRef.current;
Expand All @@ -270,11 +280,12 @@ function QueryEditor({
};

const extensions = await buildExtensions(
placeholder,
placeholderRef.current,
onUpdate,
onRunCallback,
langCompartment.of(langExts),
readOnlyCompartment.of(EditorState.readOnly.of(readOnlyRef.current)),
placeholderCompartment,
);

if (abortSignal.aborted || !containerRef.current) return;
Expand Down Expand Up @@ -309,7 +320,7 @@ function QueryEditor({
(containerRef.current as any).__cmView = viewRef.current;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[placeholder],
[],
);

// Initial mount
Expand Down Expand Up @@ -352,6 +363,30 @@ function QueryEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [language]);

// Placeholder change: reconfigure the placeholder compartment in place.
// Switching language (Cypher ↔ SQL) swaps the example query passed as the
// placeholder prop; without this the initial-mount placeholder stayed stale.
React.useEffect(() => {
if (!viewRef.current || !placeholderCompartmentRef.current) return;
let cancelled = false;
import("@codemirror/view")
.then(({ placeholder: cmPlaceholder }) => {
if (!cancelled && viewRef.current) {
viewRef.current.dispatch({
effects: placeholderCompartmentRef.current.reconfigure(
cmPlaceholder(placeholder),
),
});
}
})
.catch(() => {
// Defensive: dynamic import can fail
});
return () => {
cancelled = true;
};
}, [placeholder]);

// readOnly: synchronous compartment reconfigure — one path for all languages
React.useEffect(() => {
const view = viewRef.current;
Expand Down
Loading