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).
This commit is contained in:
hsiegeln
2026-04-20 13:41:46 +02:00
parent ac2a943feb
commit 019e79a362
3 changed files with 157 additions and 0 deletions

View File

@@ -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);
}

View File

@@ -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(
<MustacheEditor
value="Hello {{rule.name}}"
onChange={() => {}}
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(
<MustacheEditor
value=""
onChange={onChange}
kind="ROUTE_METRIC"
label="Title template"
/>,
);
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();
});
});

View File

@@ -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<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(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 (
<div className={css.wrapper}>
<label className={css.label}>{props.label}</label>
<div
ref={hostRef}
className={css.editor}
role="textbox"
aria-label={props.label}
style={{ minHeight: minH }}
data-placeholder={props.placeholder}
/>
</div>
);
}