From 019e79a362f66b368be74881e1efd59ccf0db412 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:41:46 +0200 Subject: [PATCH] feat(ui/alerts): MustacheEditor component (CM6 shell with completion + linter) Wires the mustache-completion source and mustache-linter into a CodeMirror 6 EditorView. Accepts kind (filters variables) and reducedContext (env-only for connection URLs). singleLine prevents newlines for URL/header fields. Host ref syncs when the parent replaces value (promotion prefill). --- .../MustacheEditor/MustacheEditor.module.css | 15 +++ .../MustacheEditor/MustacheEditor.test.tsx | 34 ++++++ .../MustacheEditor/MustacheEditor.tsx | 108 ++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 ui/src/components/MustacheEditor/MustacheEditor.module.css create mode 100644 ui/src/components/MustacheEditor/MustacheEditor.test.tsx create mode 100644 ui/src/components/MustacheEditor/MustacheEditor.tsx diff --git a/ui/src/components/MustacheEditor/MustacheEditor.module.css b/ui/src/components/MustacheEditor/MustacheEditor.module.css new file mode 100644 index 00000000..3d51d209 --- /dev/null +++ b/ui/src/components/MustacheEditor/MustacheEditor.module.css @@ -0,0 +1,15 @@ +.wrapper { display: flex; flex-direction: column; gap: 4px; } +.label { font-size: 12px; color: var(--muted); } +.editor { + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); +} +.editor :global(.cm-editor) { outline: none; } +.editor :global(.cm-editor.cm-focused) { border-color: var(--accent); } +.editor :global(.cm-content) { padding: 8px; font-family: var(--font-mono, ui-monospace, monospace); font-size: 13px; } +.editor :global(.cm-tooltip-autocomplete) { + border-radius: 6px; + border: 1px solid var(--border); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} diff --git a/ui/src/components/MustacheEditor/MustacheEditor.test.tsx b/ui/src/components/MustacheEditor/MustacheEditor.test.tsx new file mode 100644 index 00000000..c8641181 --- /dev/null +++ b/ui/src/components/MustacheEditor/MustacheEditor.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MustacheEditor } from './MustacheEditor'; + +describe('MustacheEditor', () => { + it('renders the initial value', () => { + render( + {}} + kind="ROUTE_METRIC" + label="Title template" + />, + ); + expect(screen.getByText(/Hello/)).toBeInTheDocument(); + }); + + it('renders a textbox and does not call onChange before user interaction', () => { + const onChange = vi.fn(); + render( + , + ); + const editor = screen.getByRole('textbox', { name: 'Title template' }); + expect(editor).toBeInTheDocument(); + // CM6 fires onChange via transactions, not DOM input events; without a real + // user interaction the callback must remain untouched. + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/MustacheEditor/MustacheEditor.tsx b/ui/src/components/MustacheEditor/MustacheEditor.tsx new file mode 100644 index 00000000..bf66c013 --- /dev/null +++ b/ui/src/components/MustacheEditor/MustacheEditor.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef } from 'react'; +import { EditorState, type Extension } from '@codemirror/state'; +import { EditorView, keymap, highlightSpecialChars, drawSelection, highlightActiveLine, lineNumbers } from '@codemirror/view'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; +import { lintKeymap, lintGutter } from '@codemirror/lint'; +import { mustacheCompletionSource } from './mustache-completion'; +import { mustacheLinter } from './mustache-linter'; +import { availableVariables } from './alert-variables'; +import type { ConditionKind } from '../../api/queries/alertRules'; +import css from './MustacheEditor.module.css'; + +export interface MustacheEditorProps { + value: string; + onChange: (value: string) => void; + kind?: ConditionKind; + reducedContext?: boolean; // connection URL editor uses env-only context + label: string; + placeholder?: string; + minHeight?: number; // default 80 + singleLine?: boolean; // used for header values / URL fields +} + +export function MustacheEditor(props: MustacheEditorProps) { + const hostRef = useRef(null); + const viewRef = useRef(null); + + // Keep a ref to the latest onChange so the EditorView effect doesn't re-create on every render. + const onChangeRef = useRef(props.onChange); + onChangeRef.current = props.onChange; + + useEffect(() => { + if (!hostRef.current) return; + const allowed = availableVariables(props.kind, { reducedContext: props.reducedContext }); + + const extensions: Extension[] = [ + history(), + drawSelection(), + highlightSpecialChars(), + highlightActiveLine(), + closeBrackets(), + autocompletion({ override: [mustacheCompletionSource(allowed)] }), + mustacheLinter(allowed), + lintGutter(), + EditorView.updateListener.of((u) => { + if (u.docChanged) onChangeRef.current(u.state.doc.toString()); + }), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...historyKeymap, + ...completionKeymap, + ...lintKeymap, + ]), + ]; + if (!props.singleLine) extensions.push(lineNumbers()); + if (props.singleLine) { + // Prevent Enter from inserting a newline on single-line fields. + extensions.push( + EditorState.transactionFilter.of((tr) => { + if (tr.newDoc.lines > 1) return []; + return tr; + }), + ); + } + + const view = new EditorView({ + parent: hostRef.current, + state: EditorState.create({ + doc: props.value, + extensions, + }), + }); + viewRef.current = view; + return () => { + view.destroy(); + viewRef.current = null; + }; + // Extensions built once per mount; prop-driven extensions rebuilt in the effect below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.kind, props.reducedContext, props.singleLine]); + + // If the parent replaces `value` externally (e.g. promotion prefill), sync the doc. + useEffect(() => { + const view = viewRef.current; + if (!view) return; + const current = view.state.doc.toString(); + if (current !== props.value) { + view.dispatch({ changes: { from: 0, to: current.length, insert: props.value } }); + } + }, [props.value]); + + const minH = props.minHeight ?? (props.singleLine ? 32 : 80); + + return ( +
+ +
+
+ ); +}