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:
15
ui/src/components/MustacheEditor/MustacheEditor.module.css
Normal file
15
ui/src/components/MustacheEditor/MustacheEditor.module.css
Normal 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);
|
||||
}
|
||||
34
ui/src/components/MustacheEditor/MustacheEditor.test.tsx
Normal file
34
ui/src/components/MustacheEditor/MustacheEditor.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
108
ui/src/components/MustacheEditor/MustacheEditor.tsx
Normal file
108
ui/src/components/MustacheEditor/MustacheEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user