Revert "Text: New editor (#129150)" (#129493)

This reverts commit bfe238b458.
This commit is contained in:
Paul Marbach
2026-07-28 18:01:43 -04:00
committed by GitHub
parent d85b493ee8
commit 09a088f843
23 changed files with 55 additions and 1722 deletions
-1
View File
@@ -1059,7 +1059,6 @@ playwright.storybook.config.ts @grafana/grafana-frontend-platform
/public/app/plugins/panel/text/ @grafana/dataviz-squad
/public/app/plugins/panel/textng/ @grafana/dataviz-squad
/public/app/plugins/panel/welcome/ @grafana/grafana-frontend-navigation
/public/app/plugins/schemas/ @grafana/dataviz-squad
/public/app/plugins/panel/xychart/ @grafana/dataviz-squad
/public/app/routes/ @grafana/grafana-frontend-navigation
/public/app/store/ @grafana/grafana-frontend-platform
@@ -1,59 +0,0 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
//
// Generated by:
// public/app/plugins/gen.go
// Using jennies:
// TSTypesJenny
// PluginTsTypesJenny
//
// Run 'make gen-cue' from repository root to regenerate.
// Generated from public/app/plugins/panel/textng/panelcfg.cue file.
export const pluginVersion = "13.2.0-pre";
export enum TextMode {
Code = 'code',
HTML = 'html',
Markdown = 'markdown',
}
export enum CodeLanguage {
Go = 'go',
Html = 'html',
Json = 'json',
Markdown = 'markdown',
Plaintext = 'plaintext',
Sql = 'sql',
Typescript = 'typescript',
Xml = 'xml',
Yaml = 'yaml',
}
export const defaultCodeLanguage: CodeLanguage = CodeLanguage.Plaintext;
export interface CodeOptions {
/**
* The language passed to the CodeMirror editor
*/
language: CodeLanguage;
showLineNumbers: boolean;
}
export const defaultCodeOptions: Partial<CodeOptions> = {
language: CodeLanguage.Plaintext,
showLineNumbers: false,
};
export interface Options {
code?: CodeOptions;
content: string;
mode: TextMode;
}
export const defaultOptions: Partial<Options> = {
content: `# Title
For markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)`,
mode: TextMode.Markdown,
};
-6
View File
@@ -64,14 +64,8 @@
],
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.1",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.0",
@@ -6,16 +6,7 @@ import { CodeEditor } from './CodeEditor';
import mdx from './CodeEditor.mdx';
import type { CodeMirrorCompletionSource, CodeMirrorEditorLanguage } from './types';
const languageOptions: CodeMirrorEditorLanguage[] = [
'go',
'html',
'json',
'markdown',
'sql',
'typescript',
'xml',
'yaml',
];
const languageOptions: CodeMirrorEditorLanguage[] = ['sql', 'json'];
const keywordCompletionSource: CodeMirrorCompletionSource = (context) => {
const word = context.matchBefore(/\w*/);
@@ -96,8 +96,6 @@ export const CodeEditor = memo(function CodeEditor({
theme: themeOverride,
basicSetup,
indentWithTab = true,
readOnly = false,
lineWrapping = false,
}: CodeMirrorEditorProps) {
const theme = useTheme2();
const { extension: languageExtension, error: languageExtensionError } = useLanguageExtension(language, sqlDialect);
@@ -109,18 +107,9 @@ export const CodeEditor = memo(function CodeEditor({
...getAccessibilityExtensions(ariaLabel, ariaLabelledby),
...(languageExtension ? [languageExtension] : []),
...getCompletionExtensions(completionSources, completionMode),
...(lineWrapping ? [EditorView.lineWrapping] : []),
...(additionalExtensions ?? []),
],
[
ariaLabel,
ariaLabelledby,
languageExtension,
completionSources,
completionMode,
lineWrapping,
additionalExtensions,
]
[ariaLabel, ariaLabelledby, languageExtension, completionSources, completionMode, additionalExtensions]
);
return (
<>
@@ -140,7 +129,6 @@ export const CodeEditor = memo(function CodeEditor({
onChange={onChange}
basicSetup={basicSetup}
indentWithTab={indentWithTab}
readOnly={readOnly}
/>
</>
);
@@ -21,11 +21,7 @@ export function CodeMirrorEditor(props: CodeMirrorEditorProps) {
style="page"
>
<Suspense
fallback={
props.loadingFallback ?? (
<LoadingPlaceholder text={t('grafana-ui.code-mirror.loading-placeholder', 'Loading editor')} />
)
}
fallback={<LoadingPlaceholder text={t('grafana-ui.code-mirror.loading-placeholder', 'Loading editor')} />}
>
<CodeEditor {...props} />
</Suspense>
@@ -81,31 +81,4 @@ describe('loadLanguageExtension', () => {
expect(standardAgain).toBe(standard);
});
});
it.each(['go', 'html', 'json', 'markdown', 'typescript', 'xml', 'yaml'] as const)(
'loads and memoizes the %s extension',
async (language) => {
await jest.isolateModulesAsync(async () => {
const { loadLanguageExtension } = await import('./languageLoader');
const { Language } = await import('@codemirror/language');
const extension = await loadLanguageExtension(language);
const again = await loadLanguageExtension(language);
expect(extension).toHaveProperty('language', expect.any(Language));
expect(again).toBe(extension);
});
}
);
it('configures the typescript loader for TypeScript syntax', async () => {
await jest.isolateModulesAsync(async () => {
const { loadLanguageExtension } = await import('./languageLoader');
const { typescriptLanguage } = await import('@codemirror/lang-javascript');
const extension = await loadLanguageExtension('typescript');
expect(extension).toHaveProperty('language', typescriptLanguage);
});
});
});
@@ -4,29 +4,9 @@ import { type CodeMirrorEditorLanguage, type CodeMirrorExtension, type CodeMirro
const DEFAULT_SQL_DIALECT: CodeMirrorSqlDialect = 'standardSql';
const loadGo = async (): Promise<CodeMirrorExtension> =>
(await import(/* webpackChunkName: "codemirror-lang-go" */ '@codemirror/lang-go')).go();
const loadHtml = async (): Promise<CodeMirrorExtension> =>
(await import(/* webpackChunkName: "codemirror-lang-html" */ '@codemirror/lang-html')).html();
const loadJson = async (): Promise<CodeMirrorExtension> =>
(await import(/* webpackChunkName: "codemirror-lang-json" */ '@codemirror/lang-json')).json();
const loadMarkdown = async (): Promise<CodeMirrorExtension> =>
(await import(/* webpackChunkName: "codemirror-lang-markdown" */ '@codemirror/lang-markdown')).markdown();
const loadTypescript = async (): Promise<CodeMirrorExtension> =>
(await import(/* webpackChunkName: "codemirror-lang-javascript" */ '@codemirror/lang-javascript')).javascript({
typescript: true,
});
const loadXml = async (): Promise<CodeMirrorExtension> =>
(await import(/* webpackChunkName: "codemirror-lang-xml" */ '@codemirror/lang-xml')).xml();
const loadYaml = async (): Promise<CodeMirrorExtension> =>
(await import(/* webpackChunkName: "codemirror-lang-yaml" */ '@codemirror/lang-yaml')).yaml();
const loadSql = async (dialect: CodeMirrorSqlDialect): Promise<CodeMirrorExtension> => {
const [{ sql, StandardSQL, MySQL }, { foldByIndentation }] = await Promise.all([
import(/* webpackChunkName: "codemirror-lang-sql" */ '@codemirror/lang-sql'),
@@ -52,24 +32,12 @@ const resolveLoad = (
options: LoadLanguageOptions
): { cacheKey: string; load: () => Promise<CodeMirrorExtension> } => {
switch (language) {
case 'go':
return { cacheKey: 'go', load: loadGo };
case 'html':
return { cacheKey: 'html', load: loadHtml };
case 'json':
return { cacheKey: 'json', load: loadJson };
case 'markdown':
return { cacheKey: 'markdown', load: loadMarkdown };
case 'sql': {
const dialect = options.sqlDialect ?? DEFAULT_SQL_DIALECT;
return { cacheKey: `sql:${dialect}`, load: () => loadSql(dialect) };
}
case 'typescript':
return { cacheKey: 'typescript', load: loadTypescript };
case 'xml':
return { cacheKey: 'xml', load: loadXml };
case 'yaml':
return { cacheKey: 'yaml', load: loadYaml };
}
};
@@ -1,7 +1,6 @@
import type { Completion, CompletionContext, CompletionResult, CompletionSource } from '@codemirror/autocomplete';
import { type EditorState, type Extension } from '@codemirror/state';
import { type BasicSetupOptions } from '@uiw/react-codemirror';
import { type ReactNode } from 'react';
export type CodeMirrorCompletion = Completion;
export type CodeMirrorCompletionContext = CompletionContext;
@@ -19,7 +18,7 @@ export type CodeMirrorCompletionMode = 'override' | 'merge';
*/
export type CodeMirrorBasicSetup = boolean | BasicSetupOptions;
export type CodeMirrorEditorLanguage = 'go' | 'html' | 'json' | 'markdown' | 'sql' | 'typescript' | 'xml' | 'yaml';
export type CodeMirrorEditorLanguage = 'json' | 'sql';
/**
* SQL dialect used for syntax highlighting and keyword completion when
@@ -161,18 +160,4 @@ export interface CodeMirrorEditorProps {
* element instead of being captured as indentation (avoids a keyboard trap).
*/
indentWithTab?: boolean;
/**
* Rejects all edits while keeping the text selectable.
*/
readOnly?: boolean;
/**
* Wraps long lines instead of scrolling horizontally.
*/
lineWrapping?: boolean;
/**
* Rendered while the editor bundle is being lazily loaded. Defaults to a
* loading placeholder; pass a styled preview of the content to avoid a
* visual flash when the editor appears.
*/
loadingFallback?: ReactNode;
}
@@ -1,75 +0,0 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { type GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useStyles2 } from '@grafana/ui';
import { CodeMirrorEditor } from '@grafana/ui/unstable';
import { type CodeLanguage } from '../../schemas/textng/panelcfg.gen';
import { getCodeMirrorLanguage } from './utils';
export interface TextNGCodeViewProps {
content: string;
language?: CodeLanguage;
showLineNumbers: boolean;
}
/**
* Read-only, syntax-highlighted rendering of code-mode content
*/
export function TextNGCodeView({ content, language, showLineNumbers }: TextNGCodeViewProps) {
const styles = useStyles2(getStyles);
const basicSetup = useMemo(
() => ({
lineNumbers: showLineNumbers,
foldGutter: false,
highlightActiveLine: false,
highlightActiveLineGutter: false,
bracketMatching: false,
closeBrackets: false,
autocompletion: false,
highlightSelectionMatches: false,
history: false,
indentOnInput: false,
allowMultipleSelections: false,
rectangularSelection: false,
crosshairCursor: false,
dropCursor: false,
}),
[showLineNumbers]
);
return (
<CodeMirrorEditor
value={content}
onChange={() => {}}
language={getCodeMirrorLanguage(language)}
readOnly
lineWrapping
basicSetup={basicSetup}
height="100%"
aria-label={t('textng.code-view.aria-label-code-content', 'Code content')}
loadingFallback={<pre className={styles.loadingFallback}>{content}</pre>}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
// Mirrors the CodeMirror theme
loadingFallback: css({
margin: 0,
padding: '4px 2px 4px 6px',
height: '100%',
overflow: 'auto',
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.code.fontSize,
lineHeight: theme.typography.code.lineHeight,
color: theme.components.input.text,
backgroundColor: theme.components.input.background,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}),
});
@@ -1,290 +1,59 @@
import { render, screen } from '@testing-library/react';
import { CoreApp, dateTime, LoadingState, EventBusSrv } from '@grafana/data';
import { PanelContextProvider, type PanelContext } from '@grafana/ui';
import { type DataFrame, toDataFrame } from '@grafana/data';
import { CodeLanguage, TextMode } from '../../schemas/textng/panelcfg.gen';
import { getPanelProps } from '../test-utils';
import { type Props, TextNGPanel } from './TextNGPanel';
import { TextNGPanel } from './TextNGPanel';
// Stub the heavy lazy CodeMirror bundle used by the inline editor and the
// read-only code view.
jest.mock('@grafana/ui/unstable', () => ({
__esModule: true,
CodeMirrorEditor: ({
value,
basicSetup,
'aria-label': ariaLabel,
}: {
value: string;
basicSetup?: { lineNumbers?: boolean };
'aria-label'?: string;
}) => (
<textarea
aria-label={ariaLabel}
value={value}
data-line-numbers={String(Boolean(basicSetup?.lineNumbers))}
readOnly
/>
),
}));
function buildProps(series?: DataFrame[]) {
const props = getPanelProps(undefined);
if (series) {
props.data = { ...props.data, series };
}
return props;
}
const replaceVariablesMock = jest.fn();
const defaultProps: Props = {
id: 1,
data: {
state: LoadingState.Done,
series: [
{
fields: [],
length: 0,
},
],
timeRange: {
from: dateTime('2022-01-01T15:55:00Z'),
to: dateTime('2022-07-12T15:55:00Z'),
raw: {
from: 'now-15m',
to: 'now',
},
},
},
timeRange: {
from: dateTime('2022-07-11T15:55:00Z'),
to: dateTime('2022-07-12T15:55:00Z'),
raw: {
from: 'now-15m',
to: 'now',
},
},
timeZone: 'utc',
transparent: false,
width: 120,
height: 120,
fieldConfig: {
defaults: {},
overrides: [],
},
renderCounter: 1,
title: 'Test Text Panel',
eventBus: new EventBusSrv(),
options: { content: '', mode: TextMode.Markdown },
onOptionsChange: jest.fn(),
onFieldConfigChange: jest.fn(),
replaceVariables: replaceVariablesMock,
onChangeTimeRange: jest.fn(),
};
const setup = (props: Props = defaultProps, app?: CoreApp) => {
const ui = <TextNGPanel {...props} />;
render(app ? <PanelContextProvider value={{ app } as PanelContext}>{ui}</PanelContextProvider> : ui);
};
function renderTextNGPanel(series?: DataFrame[]) {
return render(<TextNGPanel {...buildProps(series)} />);
}
describe('TextNGPanel', () => {
it('should render panel without content', () => {
expect(() => setup()).not.toThrow();
it('renders the panel container', () => {
renderTextNGPanel();
expect(screen.getByTestId('TextNGPanel')).toBeInTheDocument();
});
it('should not throw an error when interpolating variables results in empty content', () => {
const contentTest = '${__all_variables}';
const props = Object.assign({}, defaultProps, {
options: { content: contentTest, mode: TextMode.HTML },
});
it('renders the placeholder text', () => {
renderTextNGPanel();
expect(() => setup(props)).not.toThrow();
expect(screen.getByText(/New text panel/)).toBeInTheDocument();
});
it('sanitizes content in html mode', () => {
const contentTest = '<form><p>Form tags are sanitized.</p></form>\n<script>Script tags are sanitized.</script>';
replaceVariablesMock.mockReturnValueOnce(contentTest);
const props = Object.assign({}, defaultProps, {
options: { content: contentTest, mode: TextMode.HTML },
});
it('shows a series count of 0 when there is no data', () => {
renderTextNGPanel([]);
setup(props);
expect(screen.getByTestId('TextNGPanel-converted-content').innerHTML).toEqual(
'&lt;form&gt;<p>Form tags are sanitized.</p>&lt;/form&gt;\n&lt;script&gt;Script tags are sanitized.&lt;/script&gt;'
);
expect(screen.getByTestId('TextNGPanel')).toHaveTextContent('New text panel (0)');
});
it('sanitizes content in markdown mode', () => {
const contentTest = '<form><p>Form tags are sanitized.</p></form>\n<script>Script tags are sanitized.</script>';
replaceVariablesMock.mockReturnValueOnce(contentTest);
it('shows the number of series in the data', () => {
renderTextNGPanel([
toDataFrame({ fields: [{ name: 'A', values: [1] }] }),
toDataFrame({ fields: [{ name: 'B', values: [2] }] }),
toDataFrame({ fields: [{ name: 'C', values: [3] }] }),
]);
const props = Object.assign({}, defaultProps, {
options: { content: contentTest, mode: TextMode.Markdown },
});
setup(props);
expect(screen.getByTestId('TextNGPanel-converted-content').innerHTML).toEqual(
'&lt;form&gt;<p>Form tags are sanitized.</p>&lt;/form&gt;\n&lt;script&gt;Script tags are sanitized.&lt;/script&gt;'
);
expect(screen.getByTestId('TextNGPanel')).toHaveTextContent('New text panel (3)');
});
it('converts content to markdown when in markdown mode', async () => {
const contentTest = 'We begin by a simple sentence.\n```code block```';
replaceVariablesMock.mockReturnValueOnce(contentTest);
it('updates the series count when data changes', () => {
const { rerender } = renderTextNGPanel([toDataFrame({ fields: [] })]);
const props = Object.assign({}, defaultProps, {
options: { content: contentTest, mode: TextMode.Markdown },
});
expect(screen.getByTestId('TextNGPanel')).toHaveTextContent('New text panel (1)');
setup(props);
rerender(<TextNGPanel {...buildProps([toDataFrame({ fields: [] }), toDataFrame({ fields: [] })])} />);
const waited = await screen.getByTestId('TextNGPanel-converted-content');
expect(waited.innerHTML).toEqual('<p>We begin by a simple sentence.\n<code>code block</code></p>\n');
});
it('interpolates variables before content is converted to markdown', async () => {
const contentTest = '${myVariable}';
replaceVariablesMock.mockImplementationOnce((str) => {
return str.replace('${myVariable}', '_hello_');
});
const props = Object.assign({}, defaultProps, {
options: { content: contentTest, mode: TextMode.Markdown },
});
setup(props);
const waited = await screen.getByTestId('TextNGPanel-converted-content');
expect(waited.innerHTML).toEqual('<p><em>hello</em></p>\n');
});
it('interpolates variables correctly so they can be used in markdown urls', async () => {
const contentTest = '[Example: ${__url_time_range}](https://example.com/?${__url_time_range})';
replaceVariablesMock.mockImplementationOnce((str) => {
return str.replace(/\${__url_time_range}/g, 'from=now-6h&to=now');
});
const props = Object.assign({}, defaultProps, {
options: { content: contentTest, mode: TextMode.Markdown },
});
setup(props);
const waited = await screen.getByTestId('TextNGPanel-converted-content');
expect(waited.innerHTML).toEqual(
'<p><a href="https://example.com/?from=now-6h&amp;to=now">Example: from=now-6h&amp;to=now</a></p>\n'
);
});
it('converts content to html when in html mode', () => {
const contentTest = 'We begin by a simple sentence.\n```This is a code block\n```';
replaceVariablesMock.mockReturnValueOnce(contentTest);
const props = Object.assign({}, defaultProps, {
options: { content: contentTest, mode: TextMode.HTML },
});
setup(props);
expect(screen.getByTestId('TextNGPanel-converted-content').innerHTML).toEqual(
'We begin by a simple sentence.\n```This is a code block\n```'
);
});
it('renders code mode as an unrendered, syntax-highlighted block', async () => {
const contentTest = '{\n "a": 1\n}';
replaceVariablesMock.mockReturnValueOnce(contentTest);
const props = Object.assign({}, defaultProps, {
options: { content: contentTest, mode: TextMode.Code },
});
setup(props);
expect(screen.getByTestId('TextNGPanel-code')).toBeInTheDocument();
// The lazily-loaded read-only code view gets the raw, uninterpreted content.
expect(await screen.findByRole('textbox')).toHaveValue('{\n "a": 1\n}');
expect(screen.queryByTestId('TextNGPanel-converted-content')).not.toBeInTheDocument();
});
it('passes showLineNumbers to the code view', async () => {
const contentTest = '{\n "a": 1\n}';
replaceVariablesMock.mockReturnValueOnce(contentTest);
const props = Object.assign({}, defaultProps, {
options: {
content: contentTest,
mode: TextMode.Code,
code: { language: CodeLanguage.Json, showLineNumbers: true },
},
});
setup(props);
expect(await screen.findByRole('textbox')).toHaveAttribute('data-line-numbers', 'true');
});
describe('edit mode', () => {
// Must be the first edit-mode render in this file: once the lazy editor
// module is loaded, later mounts no longer suspend and the fallback
// never shows.
it('shows the rendered content while the editor is loading', async () => {
replaceVariablesMock.mockImplementation((str: string) => str);
const props = Object.assign({}, defaultProps, {
options: { content: '# Hello', mode: TextMode.Markdown },
});
setup(props, CoreApp.PanelEditor);
// The lazy editor chunk has not resolved yet: the fallback must show the
// rendered panel content instead of a blank body.
expect(screen.getByTestId('TextNGPanel-converted-content').innerHTML).toContain('Hello');
expect(await screen.findByTestId('TextNGEditor')).toBeInTheDocument();
expect(screen.queryByTestId('TextNGPanel-converted-content')).not.toBeInTheDocument();
replaceVariablesMock.mockReset();
});
it('renders the inline editor in the panel area when the panel is being edited', async () => {
const props = Object.assign({}, defaultProps, {
options: { content: '# Hello', mode: TextMode.Markdown },
});
setup(props, CoreApp.PanelEditor);
expect(await screen.findByTestId('TextNGEditor')).toBeInTheDocument();
expect(screen.queryByTestId('TextNGPanel-converted-content')).not.toBeInTheDocument();
});
it('does not render the inline editor in view mode', () => {
const props = Object.assign({}, defaultProps, {
options: { content: '# Hello', mode: TextMode.Markdown },
});
setup(props, CoreApp.Dashboard);
expect(screen.queryByTestId('TextNGEditor')).not.toBeInTheDocument();
expect(screen.getByTestId('TextNGPanel-converted-content')).toBeInTheDocument();
});
it('shows the edited content immediately after leaving edit mode', async () => {
replaceVariablesMock.mockImplementation((str: string) => str);
const props = Object.assign({}, defaultProps, {
options: { content: '# Hello', mode: TextMode.Markdown },
});
const { rerender } = render(
<PanelContextProvider value={{ app: CoreApp.PanelEditor } as PanelContext}>
<TextNGPanel {...props} />
</PanelContextProvider>
);
expect(await screen.findByTestId('TextNGEditor')).toBeInTheDocument();
// Content was edited while the inline editor owned rendering; going back
// to the dashboard must show it right away, not after the debounce.
const edited = Object.assign({}, props, {
options: { content: '# Edited', mode: TextMode.Markdown },
});
rerender(
<PanelContextProvider value={{ app: CoreApp.Dashboard } as PanelContext}>
<TextNGPanel {...edited} />
</PanelContextProvider>
);
expect(screen.getByTestId('TextNGPanel-converted-content').innerHTML).toContain('Edited');
replaceVariablesMock.mockReset();
});
expect(screen.getByTestId('TextNGPanel')).toHaveTextContent('New text panel (2)');
});
});
+9 -140
View File
@@ -1,153 +1,22 @@
import { css, cx } from '@emotion/css';
import DangerouslySetHtmlContent from 'dangerously-set-html-content';
import { lazy, Suspense, useMemo, useState } from 'react';
import { useDebounce } from 'react-use';
import { css } from '@emotion/css';
import { CoreApp, type GrafanaTheme2, type PanelProps, type InterpolateFunction } from '@grafana/data';
import { ScrollContainer, usePanelContext, useStyles2 } from '@grafana/ui';
import config from 'app/core/config';
import { type GrafanaTheme2, type PanelProps } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { useStyles2 } from '@grafana/ui';
import { defaultCodeOptions, defaultOptions, type Options, TextMode } from '../../schemas/textng/panelcfg.gen';
import { TextNGCodeView } from './TextNGCodeView';
import { getInterpolateFormat, transformContent } from './utils';
const TextNGEditor = lazy(() => import('./editor/TextNGEditor').then((m) => ({ default: m.TextNGEditor })));
export interface Props extends PanelProps<Options> {}
export function TextNGPanel(props: Props) {
const { app } = usePanelContext();
const { options, onOptionsChange, replaceVariables } = props;
const isEditing = app === CoreApp.PanelEditor;
const content = options.content ?? defaultOptions.content ?? '';
const interpolatedContent = isEditing ? '' : interpolateContent(options, replaceVariables);
const [processed, setProcessed] = useState<Options>(() => ({
mode: options.mode,
content: transformContent(options.mode, interpolatedContent, config.disableSanitizeHtml),
}));
// Recompute synchronously when leaving edit mode so pre-edit content never flashes.
const [wasEditing, setWasEditing] = useState(isEditing);
if (wasEditing !== isEditing) {
setWasEditing(isEditing);
if (!isEditing) {
setProcessed({
mode: options.mode,
content: transformContent(options.mode, interpolatedContent, config.disableSanitizeHtml),
});
}
}
// Batches bursts of interpolated-content changes (data/variable refresh) so
// the markdown/sanitize pass runs once per burst, not per intermediate value.
useDebounce(
() => {
if (isEditing) {
return;
}
const next = transformContent(options.mode, interpolatedContent, config.disableSanitizeHtml);
if (next !== processed.content || options.mode !== processed.mode) {
setProcessed({
mode: options.mode,
content: next,
});
}
},
100,
[isEditing, interpolatedContent, options.mode]
);
if (isEditing) {
return (
// Show the rendered content while the editor chunk loads; the editor
// opens in Preview view, so the content stays in place.
<Suspense fallback={<EditorLoadingFallback options={options} replaceVariables={replaceVariables} />}>
<TextNGEditor
content={content}
mode={options.mode}
showLineNumbers={options.code?.showLineNumbers ?? false}
codeLanguage={options.code?.language}
replaceVariables={replaceVariables}
onChange={(next) => onOptionsChange({ ...options, content: next })}
/>
</Suspense>
);
}
return <TextNGView mode={processed.mode} content={processed.content} code={options.code} />;
}
interface TextNGViewProps {
mode: TextMode;
content: string;
code: Options['code'];
}
function TextNGView({ mode, content, code }: TextNGViewProps) {
export function TextNGPanel({ data }: PanelProps) {
const styles = useStyles2(getStyles);
if (mode === TextMode.Code) {
const codeOptions = code ?? defaultCodeOptions;
return (
<div className={styles.codeContainer} data-testid="TextNGPanel-code">
<TextNGCodeView
content={content}
language={codeOptions.language}
showLineNumbers={codeOptions.showLineNumbers ?? false}
/>
</div>
);
}
return (
<div className={styles.containStrict}>
<ScrollContainer minHeight="100%">
<DangerouslySetHtmlContent
allowRerender
html={content}
className={cx('markdown-html', styles.markdownHtml)}
data-testid="TextNGPanel-converted-content"
/>
</ScrollContainer>
<div className={styles.container} data-testid="TextNGPanel">
<Trans i18nKey="textng.placeholder">New text panel</Trans> ({data.series.length})
</div>
);
}
// Only mounted while the lazy editor chunk loads, so the extra processing runs
// at most once per edit session.
function EditorLoadingFallback({
options,
replaceVariables,
}: {
options: Options;
replaceVariables: InterpolateFunction;
}) {
const content = useMemo(
() => transformContent(options.mode, interpolateContent(options, replaceVariables), config.disableSanitizeHtml),
[options, replaceVariables]
);
return <TextNGView mode={options.mode} content={content} code={options.code} />;
}
function interpolateContent(options: Options, interpolate: InterpolateFunction): string {
return interpolate(options.content ?? '', {}, getInterpolateFormat(options.code?.language));
}
const getStyles = (theme: GrafanaTheme2) => ({
containStrict: css({
contain: 'strict',
container: css({
height: '100%',
display: 'flex',
}),
markdownHtml: css({
height: '100%',
}),
codeContainer: css({
height: '100%',
overflow: 'hidden',
padding: theme.spacing(1),
}),
});
@@ -1,284 +0,0 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import config from 'app/core/config';
import { CodeLanguage, TextMode } from '../../../schemas/textng/panelcfg.gen';
import { PREVIEW_TEST_ID, TextNGEditor } from './TextNGEditor';
// The real CodeMirrorEditor pulls in a heavy, lazily-loaded CodeMirror bundle;
// stub it with a plain textarea so these tests stay fast and deterministic.
jest.mock('@grafana/ui/unstable', () => ({
__esModule: true,
CodeMirrorEditor: ({
value,
onChange,
basicSetup,
'aria-label': ariaLabel,
}: {
value: string;
onChange: (value: string) => void;
basicSetup?: { lineNumbers?: boolean };
'aria-label'?: string;
}) => (
<textarea
aria-label={ariaLabel}
value={value}
data-line-numbers={String(Boolean(basicSetup?.lineNumbers))}
onChange={(e) => onChange(e.target.value)}
/>
),
}));
function ControlledEditor({
initialValue,
mode,
showLineNumbers = false,
codeLanguage,
replaceVariables = (value: string) => value,
onChange,
}: {
initialValue: string;
mode: TextMode;
showLineNumbers?: boolean;
codeLanguage?: CodeLanguage;
replaceVariables?: (value: string) => string;
onChange: (value: string) => void;
}) {
const [value, setValue] = useState(initialValue);
return (
<TextNGEditor
content={value}
mode={mode}
showLineNumbers={showLineNumbers}
codeLanguage={codeLanguage}
replaceVariables={replaceVariables}
onChange={(next) => {
setValue(next);
onChange(next);
}}
/>
);
}
const setup = (
value: string,
mode: TextMode,
onChange = jest.fn(),
showLineNumbers = false,
codeLanguage?: CodeLanguage,
replaceVariables: (value: string) => string = (v) => v
) => {
render(
<ControlledEditor
initialValue={value}
mode={mode}
showLineNumbers={showLineNumbers}
codeLanguage={codeLanguage}
replaceVariables={replaceVariables}
onChange={onChange}
/>
);
return { onChange };
};
const enterWriteMode = () => userEvent.click(screen.getByRole('radio', { name: 'Write' }));
describe('TextNGEditor', () => {
describe('default (view-first) state', () => {
it('lands on the rendered preview, not the editor', () => {
setup('# Hello', TextMode.Markdown);
expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML).toContain('<h1');
expect(screen.getByRole('radio', { name: 'Preview' })).toBeChecked();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
it('opens straight into the editor when content is empty', () => {
setup('', TextMode.Markdown);
expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Write' })).toBeChecked();
});
it('reveals the editor after selecting Write', async () => {
setup('# Hello', TextMode.Markdown);
await enterWriteMode();
expect(screen.getByRole('textbox')).toHaveValue('# Hello');
expect(screen.queryByTestId(PREVIEW_TEST_ID)).not.toBeInTheDocument();
});
});
describe('views', () => {
it('shows only the rendered preview in Preview view', () => {
setup('# Hello', TextMode.Markdown);
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML).toContain('<h1');
});
it('shows editor and preview side by side in Split view', async () => {
setup('# Hello', TextMode.Markdown);
await userEvent.click(screen.getByRole('radio', { name: 'Split' }));
expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getByTestId(PREVIEW_TEST_ID)).toBeInTheDocument();
});
it('sanitizes script tags in the HTML mode preview', () => {
setup('<script>alert(1)</script><p>safe</p>', TextMode.HTML);
const preview = screen.getByTestId(PREVIEW_TEST_ID);
expect(preview.innerHTML).not.toContain('<script>');
expect(preview.innerHTML).toContain('safe');
});
it('skips sanitization in the preview when disableSanitizeHtml is set, matching the panel', () => {
const original = config.disableSanitizeHtml;
config.disableSanitizeHtml = true;
try {
setup('<form><p>kept</p></form>', TextMode.HTML);
const preview = screen.getByTestId(PREVIEW_TEST_ID);
expect(preview.innerHTML).toContain('<form>');
} finally {
config.disableSanitizeHtml = original;
}
});
it('renders code mode preview as a raw, unrendered code view', () => {
setup('# Not a heading in code mode', TextMode.Code);
const preview = screen.getByTestId(PREVIEW_TEST_ID);
// The preview reuses the read-only code view, so the content stays raw.
expect(within(preview).getByRole('textbox')).toHaveValue('# Not a heading in code mode');
expect(preview.innerHTML).not.toContain('<h1');
});
it('passes language and line numbers to the code mode preview', () => {
setup('{\n "a": 1\n}', TextMode.Code, jest.fn(), true, CodeLanguage.Json);
const preview = screen.getByTestId(PREVIEW_TEST_ID);
expect(within(preview).getByRole('textbox')).toHaveAttribute('data-line-numbers', 'true');
});
it('interpolates variables in the preview but keeps the raw template in the editor', async () => {
const replaceVariables = (value: string) => value.replace('$datacenter', 'A, B, C');
setup('# Data center = $datacenter', TextMode.Markdown, jest.fn(), false, undefined, replaceVariables);
expect(screen.getByTestId(PREVIEW_TEST_ID)).toHaveTextContent('Data center = A, B, C');
await userEvent.click(screen.getByRole('radio', { name: 'Write' }));
expect(screen.getByRole('textbox')).toHaveValue('# Data center = $datacenter');
});
it('does not render a formatting toolbar', async () => {
setup('hello', TextMode.Markdown);
await enterWriteMode();
expect(screen.queryByRole('button', { name: 'Insert variable' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Bold' })).not.toBeInTheDocument();
});
it('forwards editor changes via a debounced onChange', async () => {
const { onChange } = setup('initial', TextMode.Markdown);
await enterWriteMode();
const editor = screen.getByRole('textbox');
await userEvent.clear(editor);
await userEvent.type(editor, 'updated');
// The commit is debounced so the dashboard is not re-rendered per keystroke.
await waitFor(() => expect(onChange).toHaveBeenLastCalledWith('updated'));
});
it('commits a pending draft when the editor loses focus', async () => {
const { onChange } = setup('initial', TextMode.Markdown);
await enterWriteMode();
const editor = screen.getByRole('textbox');
await userEvent.clear(editor);
await userEvent.type(editor, 'updated');
// Moving focus out (e.g. clicking Save/Apply) must persist the draft
// synchronously instead of waiting out the commit debounce.
await userEvent.tab();
expect(onChange).toHaveBeenLastCalledWith('updated');
});
it('commits the latest draft when the last change and the blur happen in the same event turn', async () => {
const { onChange } = setup('initial', TextMode.Markdown);
await enterWriteMode();
const editor = screen.getByRole('textbox');
// One batch: the blur handler runs before React re-renders with the new draft.
act(() => {
fireEvent.change(editor, { target: { value: 'updated' } });
fireEvent.blur(editor);
});
expect(onChange).toHaveBeenCalledWith('updated');
});
it('does not commit a pending draft on unmount, so Discard is not overwritten', async () => {
const onChange = jest.fn();
const { unmount } = render(
<ControlledEditor initialValue="initial" mode={TextMode.Markdown} onChange={onChange} />
);
await enterWriteMode();
const editor = screen.getByRole('textbox');
await userEvent.clear(editor);
await userEvent.type(editor, 'updated');
unmount();
// A flush here could overwrite a Discard.
expect(onChange).not.toHaveBeenCalledWith('updated');
});
it('interpolates with the json format when code language is json, regardless of mode', () => {
const replaceVariables = jest.fn((value: string) => value);
setup('# Hello', TextMode.Markdown, jest.fn(), false, CodeLanguage.Json, replaceVariables);
// Matches the panel render path, which keys the interpolation format off
// code.language alone.
expect(replaceVariables).toHaveBeenCalledWith('# Hello', {}, 'json');
});
});
describe('line numbers', () => {
it('never shows line numbers in Markdown mode', async () => {
setup('# Hello', TextMode.Markdown, jest.fn(), true);
await enterWriteMode();
expect(screen.getByRole('textbox')).toHaveAttribute('data-line-numbers', 'false');
});
it('never shows line numbers in HTML mode', async () => {
setup('<p>Hello</p>', TextMode.HTML, jest.fn(), true);
await enterWriteMode();
expect(screen.getByRole('textbox')).toHaveAttribute('data-line-numbers', 'false');
});
it('shows line numbers in Code mode when showLineNumbers is enabled', async () => {
setup('const a = 1;', TextMode.Code, jest.fn(), true, CodeLanguage.Json);
await enterWriteMode();
expect(screen.getByRole('textbox')).toHaveAttribute('data-line-numbers', 'true');
});
it('hides line numbers in Code mode when showLineNumbers is disabled', async () => {
setup('const a = 1;', TextMode.Code, jest.fn(), false, CodeLanguage.Json);
await enterWriteMode();
expect(screen.getByRole('textbox')).toHaveAttribute('data-line-numbers', 'false');
});
});
});
@@ -1,197 +0,0 @@
import { css, cx } from '@emotion/css';
import DangerouslySetHtmlContent from 'dangerously-set-html-content';
import { useMemo, useRef, useState } from 'react';
import { useDebounce } from 'react-use';
import { type GrafanaTheme2, type InterpolateFunction } from '@grafana/data';
import { t } from '@grafana/i18n';
import { RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { CodeMirrorEditor, type CodeMirrorEditorLanguage } from '@grafana/ui/unstable';
import config from 'app/core/config';
import { CodeLanguage, TextMode } from '../../../schemas/textng/panelcfg.gen';
import { TextNGCodeView } from '../TextNGCodeView';
import { getInterpolateFormat, transformContent, getCodeMirrorLanguage } from '../utils';
type ViewMode = 'write' | 'split' | 'preview';
export const PREVIEW_TEST_ID = 'TextNGEditor-preview';
export interface TextNGEditorProps {
content: string;
mode: TextMode;
showLineNumbers: boolean;
codeLanguage?: CodeLanguage;
replaceVariables: InterpolateFunction;
onChange: (content: string) => void;
}
const COMMIT_DEBOUNCE_MS = 250;
export function TextNGEditor({
content,
mode,
showLineNumbers,
codeLanguage,
replaceVariables,
onChange,
}: TextNGEditorProps) {
const styles = useStyles2(getStyles);
const [view, setView] = useState<ViewMode>(() => (content.trim().length === 0 ? 'write' : 'preview'));
const [draft, setDraft] = useState(content);
// a blur can fire before React re-renders with the new draft.
const draftRef = useRef(content);
const committedContent = useRef(content);
const [prevContent, setPrevContent] = useState(content);
if (content !== prevContent) {
setPrevContent(content);
if (content !== committedContent.current) {
committedContent.current = content;
draftRef.current = content;
setDraft(content);
}
}
const handleDraftChange = (next: string) => {
draftRef.current = next;
setDraft(next);
};
const commitDraft = () => {
const next = draftRef.current;
if (next !== committedContent.current) {
committedContent.current = next;
onChange(next);
}
};
// No unmount flush: exits blur (and commit) first, and flushing here could
// overwrite externally reverted options (e.g. Discard).
useDebounce(commitDraft, COMMIT_DEBOUNCE_MS, [draft]);
const format = getInterpolateFormat(codeLanguage);
const interpolatedContent = view === 'write' ? '' : replaceVariables(draft, {}, format);
const previewHtml = useMemo(
() => (mode === TextMode.Code ? '' : transformContent(mode, interpolatedContent, config.disableSanitizeHtml)),
[mode, interpolatedContent]
);
let editorLanguage: CodeMirrorEditorLanguage | undefined;
if (mode === TextMode.Markdown) {
editorLanguage = getCodeMirrorLanguage(CodeLanguage.Markdown);
} else if (mode === TextMode.HTML) {
editorLanguage = getCodeMirrorLanguage(CodeLanguage.Html);
} else if (mode === TextMode.Code) {
editorLanguage = getCodeMirrorLanguage(codeLanguage);
}
const basicSetup = useMemo(
() => ({ lineNumbers: mode === TextMode.Code ? showLineNumbers : false }),
[mode, showLineNumbers]
);
const viewOptions = [
{ label: t('textng.editor.view-preview', 'Preview'), value: 'preview' as const },
{ label: t('textng.editor.view-split', 'Split'), value: 'split' as const },
{ label: t('textng.editor.view-write', 'Write'), value: 'write' as const },
];
const showEditor = view !== 'preview';
const showPreview = view !== 'write';
const renderOutput = (testId: string) =>
mode === TextMode.Code ? (
<div className={styles.codeView} data-testid={testId}>
<TextNGCodeView content={interpolatedContent} language={codeLanguage} showLineNumbers={showLineNumbers} />
</div>
) : (
<DangerouslySetHtmlContent
allowRerender
html={previewHtml}
className={cx('markdown-html', styles.markdownHtml)}
data-testid={testId}
/>
);
return (
<div className={styles.wrapper} data-testid="TextNGEditor">
<div className={styles.toolbar}>
<RadioButtonGroup options={viewOptions} value={view} onChange={setView} size="sm" />
</div>
<div className={cx(styles.body, view === 'split' && styles.splitBody)}>
{showEditor && (
// Outside interactions (Save, Apply, Back) blur the editor on mousedown,
// so a pending draft is committed before anything reads the options.
<div className={cx(styles.pane, styles.editorPane)} onBlur={commitDraft}>
<CodeMirrorEditor
value={draft}
onChange={handleDraftChange}
language={editorLanguage}
lineWrapping
basicSetup={basicSetup}
height="100%"
aria-label={t('textng.editor.aria-label-content', 'Text content')}
/>
</div>
)}
{showPreview && <div className={cx(styles.pane, styles.previewPane)}>{renderOutput(PREVIEW_TEST_ID)}</div>}
</div>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
label: 'textNGEditor',
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
}),
toolbar: css({
display: 'flex',
alignItems: 'center',
marginBottom: theme.spacing(1),
}),
body: css({
display: 'flex',
flex: 1,
width: '100%',
minHeight: 0,
}),
splitBody: css({
gap: theme.spacing(1),
}),
pane: css({
flex: 1,
minWidth: 0,
overflow: 'hidden',
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
}),
editorPane: css({
display: 'flex',
flexDirection: 'column',
// Give CodeMirror a bounded height so it scrolls internally instead of growing.
'& > *': {
flex: 1,
minHeight: 0,
overflow: 'auto',
},
}),
previewPane: css({
overflow: 'auto',
padding: theme.spacing(1, 2),
background: theme.colors.background.primary,
}),
markdownHtml: css({
height: '100%',
}),
codeView: css({
height: '100%',
}),
});
+1 -59
View File
@@ -1,63 +1,5 @@
import { PanelPlugin } from '@grafana/data';
import { t } from '@grafana/i18n';
import {
CodeLanguage,
defaultCodeOptions,
defaultOptions,
type Options,
TextMode,
} from '../../schemas/textng/panelcfg.gen';
import { TextNGPanel } from './TextNGPanel';
import { textPanelMigrationHandler } from './textPanelMigrationHandler';
export const plugin = new PanelPlugin<Options>(TextNGPanel)
.setPanelOptions((builder) => {
const category = [t('textng.category-text', 'Text')];
builder
.addRadio({
path: 'mode',
name: t('textng.name-mode', 'Mode'),
category,
settings: {
options: [
{ value: TextMode.Markdown, label: t('textng.mode-options.label-markdown', 'Markdown') },
{ value: TextMode.HTML, label: t('textng.mode-options.label-html', 'HTML') },
{ value: TextMode.Code, label: t('textng.mode-options.label-code', 'Code') },
],
},
defaultValue: defaultOptions.mode,
})
.addSelect({
path: 'code.language',
name: t('textng.name-language', 'Language'),
category,
settings: {
options: Object.values(CodeLanguage).map((v) => ({
value: v,
label: v,
})),
},
defaultValue: defaultCodeOptions.language,
showIf: (v) => v.mode === TextMode.Code,
})
.addBooleanSwitch({
path: 'code.showLineNumbers',
name: t('textng.name-show-line-numbers', 'Show line numbers'),
category,
defaultValue: defaultCodeOptions.showLineNumbers,
showIf: (v) => v.mode === TextMode.Code,
})
.addCustomEditor({
id: 'content',
path: 'content',
name: '',
category,
editor: () => null,
defaultValue: defaultOptions.content,
showIf: () => false,
});
})
.setMigrationHandler(textPanelMigrationHandler)
.setSuggestionsSupplier(() => []);
export const plugin = new PanelPlugin(TextNGPanel);
@@ -1,103 +0,0 @@
import { type FieldConfigSource, type PanelModel } from '@grafana/data';
import { TextMode, type Options } from '../../schemas/textng/panelcfg.gen';
import { textPanelMigrationHandler } from './textPanelMigrationHandler';
describe('textPanelMigrationHandler', () => {
describe('when invoked and previous version was old Angular text panel', () => {
it('then should migrate options', () => {
const panel = {
content: '<span>Hello World<span>',
mode: 'html',
options: {},
};
const result = textPanelMigrationHandler(panel as unknown as PanelModel);
expect(result.content).toEqual('<span>Hello World<span>');
expect(result.mode).toEqual('html');
expect(panel.content).toBeUndefined();
expect(panel.mode).toBeUndefined();
});
it('then should keep the content when the legacy mode is missing', () => {
const panel = {
content: '<span>Hello World<span>',
mode: undefined,
options: {},
};
const result = textPanelMigrationHandler(panel as unknown as PanelModel);
expect(result.content).toEqual('<span>Hello World<span>');
expect(result.mode).toEqual(TextMode.Markdown);
});
it('then should not throw when options are missing', () => {
const panel = {
content: '<span>Hello World<span>',
mode: 'html',
};
const result = textPanelMigrationHandler(panel as unknown as PanelModel);
expect(result.content).toEqual('<span>Hello World<span>');
expect(result.mode).toEqual('html');
});
});
describe('when invoked and previous version 7.1 or later', () => {
it('then not migrate options', () => {
const panel = {
content: '<span>Hello World<span>',
mode: 'html',
options: { content: 'New content', mode: TextMode.Markdown },
pluginVersion: '7.1.0',
};
const result = textPanelMigrationHandler(panel as unknown as PanelModel);
expect(result.content).toEqual('New content');
});
});
describe('when invoked and previous version was not old Angular text panel', () => {
it('then should just pass options through', () => {
const panel: PanelModel<Options> = {
id: 1,
type: 'textng',
fieldConfig: {} as unknown as FieldConfigSource,
options: {
content: '# Title',
mode: TextMode.Markdown,
},
};
const result = textPanelMigrationHandler(panel);
expect(result.content).toEqual('# Title');
expect(result.mode).toEqual('markdown');
});
});
describe('when invoked and previous version was using text mode', () => {
it('then should switch to markdown', () => {
const mode = 'text' as unknown as TextMode;
const panel: PanelModel<Options> = {
id: 1,
type: 'textng',
fieldConfig: {} as unknown as FieldConfigSource,
options: {
content: '# Title',
mode,
},
};
const result = textPanelMigrationHandler(panel);
expect(result.content).toEqual('# Title');
expect(result.mode).toEqual('markdown');
});
});
});
@@ -1,33 +0,0 @@
import { type PanelModel } from '@grafana/data';
import { TextMode, type Options } from '../../schemas/textng/panelcfg.gen';
type LegacyTextPanel = PanelModel<Options> & { content?: string; mode?: TextMode };
export const textPanelMigrationHandler = (panel: LegacyTextPanel): Partial<Options> => {
const previousVersion = parseFloat(panel.pluginVersion || '6.1');
let options: Partial<Options> = panel.options ?? {};
// Migrates old Angular based text panel props to new props
if (panel.hasOwnProperty('content') && panel.hasOwnProperty('mode')) {
const content = panel.content;
const mode = panel.mode;
delete panel.content;
delete panel.mode;
if (previousVersion < 7.1) {
// Always adopt the legacy content once the top-level props are deleted,
// otherwise the user's content would be silently lost.
options = { content: content ?? '', mode: mode ?? TextMode.Markdown };
}
}
// The 'text' mode has been removed so we need to update any panels still using it to markdown
const modes = [TextMode.Code, TextMode.HTML, TextMode.Markdown];
if (!modes.find((f) => f === options.mode)) {
options = { ...options, mode: TextMode.Markdown };
}
return options;
};
-57
View File
@@ -1,57 +0,0 @@
import { renderTextPanelMarkdown, textUtil } from '@grafana/data';
import { type CodeMirrorEditorLanguage } from '@grafana/ui/unstable';
import { CodeLanguage, TextMode } from '../../schemas/textng/panelcfg.gen';
export function getInterpolateFormat(codeLanguage?: CodeLanguage): 'json' | 'html' {
return codeLanguage === CodeLanguage.Json ? 'json' : 'html';
}
/** Shared by the panel and the edit-time preview so they can't diverge. */
export function transformContent(mode: TextMode, content: string, disableSanitizeHtml: boolean): string {
if (!content) {
return ' ';
}
switch (mode) {
case TextMode.Code:
break;
case TextMode.HTML:
if (!disableSanitizeHtml) {
content = textUtil.sanitizeTextPanelContent(content);
}
break;
case TextMode.Markdown:
default:
content = renderTextPanelMarkdown(content, {
noSanitize: disableSanitizeHtml,
});
}
return content;
}
/** Maps the panel's CodeLanguage option to CodeMirrorEditor's lazy-loaded language names. */
export function getCodeMirrorLanguage(codeLanguage?: CodeLanguage): CodeMirrorEditorLanguage | undefined {
switch (codeLanguage) {
case CodeLanguage.Go:
return 'go';
case CodeLanguage.Html:
return 'html';
case CodeLanguage.Json:
return 'json';
case CodeLanguage.Markdown:
return 'markdown';
case CodeLanguage.Sql:
return 'sql';
case CodeLanguage.Typescript:
return 'typescript';
case CodeLanguage.Xml:
return 'xml';
case CodeLanguage.Yaml:
return 'yaml';
case CodeLanguage.Plaintext:
default:
return undefined;
}
}
@@ -1,47 +0,0 @@
// Copyright 2021 Grafana Labs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package grafanaplugin
composableKinds: PanelCfg: {
maturity: "experimental"
lineage: {
schemas: [{
version: [0, 0]
schema: {
TextMode: "html" | "markdown" | "code" @cuetsy(kind="enum",memberNames="HTML|Markdown|Code")
CodeLanguage: "json" | "yaml" | "xml" | "typescript" | "sql" | "go" | "markdown" | "html" | *"plaintext" @cuetsy(kind="enum")
CodeOptions: {
// The language passed to the CodeMirror editor
language: CodeLanguage
showLineNumbers: bool | *false
} @cuetsy(kind="interface")
Options: {
mode: TextMode & (*"markdown" | _)
code?: CodeOptions
content: string | *"""
# Title
For markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)
"""
} @cuetsy(kind="interface")
}
}]
lenses: []
}
}
-57
View File
@@ -1,57 +0,0 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
//
// Generated by:
// public/app/plugins/gen.go
// Using jennies:
// TSTypesJenny
// PluginTsTypesJenny
//
// Run 'make gen-cue' from repository root to regenerate.
// Generated from public/app/plugins/panel/textng/panelcfg.cue file.
export enum TextMode {
Code = 'code',
HTML = 'html',
Markdown = 'markdown',
}
export enum CodeLanguage {
Go = 'go',
Html = 'html',
Json = 'json',
Markdown = 'markdown',
Plaintext = 'plaintext',
Sql = 'sql',
Typescript = 'typescript',
Xml = 'xml',
Yaml = 'yaml',
}
export const defaultCodeLanguage: CodeLanguage = CodeLanguage.Plaintext;
export interface CodeOptions {
/**
* The language passed to the CodeMirror editor
*/
language: CodeLanguage;
showLineNumbers: boolean;
}
export const defaultCodeOptions: Partial<CodeOptions> = {
language: CodeLanguage.Plaintext,
showLineNumbers: false,
};
export interface Options {
code?: CodeOptions;
content: string;
mode: TextMode;
}
export const defaultOptions: Partial<Options> = {
content: `# Title
For markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)`,
mode: TextMode.Markdown,
};
@@ -1,19 +0,0 @@
{
"type": "panel",
"name": "Text NG",
"id": "text",
"suggestions": true,
"skipDataQuery": true,
"info": {
"description": "Text panel v2 panel schema.",
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"logos": {
"small": "img/icn-text-panel.svg",
"large": "img/icn-text-panel.svg"
}
}
}
+1 -18
View File
@@ -15871,24 +15871,7 @@
"name-show-mini-map": "Show mini map"
},
"textng": {
"category-text": "Text",
"code-view": {
"aria-label-code-content": "Code content"
},
"editor": {
"aria-label-content": "Text content",
"view-preview": "Preview",
"view-split": "Split",
"view-write": "Write"
},
"mode-options": {
"label-code": "Code",
"label-html": "HTML",
"label-markdown": "Markdown"
},
"name-language": "Language",
"name-mode": "Mode",
"name-show-line-numbers": "Show line numbers"
"placeholder": "New text panel"
},
"theme-playground": {
"label-base-theme": "Base theme",
+5 -198
View File
@@ -635,7 +635,7 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/autocomplete@npm:6.20.3, @codemirror/autocomplete@npm:^6.0.0, @codemirror/autocomplete@npm:^6.20.1, @codemirror/autocomplete@npm:^6.7.1":
"@codemirror/autocomplete@npm:6.20.3, @codemirror/autocomplete@npm:^6.0.0, @codemirror/autocomplete@npm:^6.20.1":
version: 6.20.3
resolution: "@codemirror/autocomplete@npm:6.20.3"
dependencies:
@@ -659,64 +659,6 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/lang-css@npm:^6.0.0":
version: 6.3.1
resolution: "@codemirror/lang-css@npm:6.3.1"
dependencies:
"@codemirror/autocomplete": "npm:^6.0.0"
"@codemirror/language": "npm:^6.0.0"
"@codemirror/state": "npm:^6.0.0"
"@lezer/common": "npm:^1.0.2"
"@lezer/css": "npm:^1.1.7"
checksum: 10/709994b0a787fe06ebac7a47c6a6a92c9680fe2b4479bbe2a72b27ad4d863953ad64a61b36f15098d00bd9a655bc9b3a3ecf2877354351ff873a01186fb38386
languageName: node
linkType: hard
"@codemirror/lang-go@npm:^6.0.1":
version: 6.0.1
resolution: "@codemirror/lang-go@npm:6.0.1"
dependencies:
"@codemirror/autocomplete": "npm:^6.0.0"
"@codemirror/language": "npm:^6.6.0"
"@codemirror/state": "npm:^6.0.0"
"@lezer/common": "npm:^1.0.0"
"@lezer/go": "npm:^1.0.0"
checksum: 10/6e361bddb35683b225e1367807f598044b861c6858c9a011227fb73a872735985141746b3c410dcd8ef11b4c0e54819e720c5e663201a6a5e69ba8a9519fa287
languageName: node
linkType: hard
"@codemirror/lang-html@npm:^6.0.0, @codemirror/lang-html@npm:^6.4.11":
version: 6.4.11
resolution: "@codemirror/lang-html@npm:6.4.11"
dependencies:
"@codemirror/autocomplete": "npm:^6.0.0"
"@codemirror/lang-css": "npm:^6.0.0"
"@codemirror/lang-javascript": "npm:^6.0.0"
"@codemirror/language": "npm:^6.4.0"
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.17.0"
"@lezer/common": "npm:^1.0.0"
"@lezer/css": "npm:^1.1.0"
"@lezer/html": "npm:^1.3.12"
checksum: 10/9731a9732ba8025ef0bd144094b8172e4e68e5c5b7afafa06219e9e8dcf1d962f23e89e382674e438c5dd8b1148e6e17ad48a54810a9b3c0fca2f4716aad8cc5
languageName: node
linkType: hard
"@codemirror/lang-javascript@npm:^6.0.0, @codemirror/lang-javascript@npm:^6.2.5":
version: 6.2.5
resolution: "@codemirror/lang-javascript@npm:6.2.5"
dependencies:
"@codemirror/autocomplete": "npm:^6.0.0"
"@codemirror/language": "npm:^6.6.0"
"@codemirror/lint": "npm:^6.0.0"
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.17.0"
"@lezer/common": "npm:^1.0.0"
"@lezer/javascript": "npm:^1.0.0"
checksum: 10/382cb5112dc9f9676e2a5905350f73e98d00f135405b21cab524cf3e3f2176454a99452ffe53b13c3a96f6670a63f07681d4f1c3154798a04d0e6c093b8d61c4
languageName: node
linkType: hard
"@codemirror/lang-json@npm:^6.0.2":
version: 6.0.2
resolution: "@codemirror/lang-json@npm:6.0.2"
@@ -727,21 +669,6 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/lang-markdown@npm:^6.5.1":
version: 6.5.1
resolution: "@codemirror/lang-markdown@npm:6.5.1"
dependencies:
"@codemirror/autocomplete": "npm:^6.7.1"
"@codemirror/lang-html": "npm:^6.0.0"
"@codemirror/language": "npm:^6.3.0"
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.0.0"
"@lezer/common": "npm:^1.2.1"
"@lezer/markdown": "npm:^1.0.0"
checksum: 10/ee34bad99acf4d2a682414e7b0f94d4fde3eb86f0e1a4093cc96c3ec0063d155f34e90011106d0772534827a60def0af631043d5b0e54de04bbea6ebdbb39023
languageName: node
linkType: hard
"@codemirror/lang-sql@npm:6.10.0, @codemirror/lang-sql@npm:^6.10.0":
version: 6.10.0
resolution: "@codemirror/lang-sql@npm:6.10.0"
@@ -756,36 +683,7 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/lang-xml@npm:^6.1.0":
version: 6.1.0
resolution: "@codemirror/lang-xml@npm:6.1.0"
dependencies:
"@codemirror/autocomplete": "npm:^6.0.0"
"@codemirror/language": "npm:^6.4.0"
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.0.0"
"@lezer/common": "npm:^1.0.0"
"@lezer/xml": "npm:^1.0.0"
checksum: 10/f5e54668c30efbb8a78a51e49ccec92a06931f2b98dce35c90be94ded30da02dac525124ce3c40f65c7b071f8db72d16b10a5a1795ccbf10e69939c0a9c1cac8
languageName: node
linkType: hard
"@codemirror/lang-yaml@npm:^6.1.3":
version: 6.1.3
resolution: "@codemirror/lang-yaml@npm:6.1.3"
dependencies:
"@codemirror/autocomplete": "npm:^6.0.0"
"@codemirror/language": "npm:^6.0.0"
"@codemirror/state": "npm:^6.0.0"
"@lezer/common": "npm:^1.2.0"
"@lezer/highlight": "npm:^1.2.0"
"@lezer/lr": "npm:^1.0.0"
"@lezer/yaml": "npm:^1.0.0"
checksum: 10/0af746beca691bbab992843b7803b30529da1e8b089978cde58de335bbad68de78f905d3cef4391011b64e9e77537bb5b39ff0bfc53c4e408903bc814a50168e
languageName: node
linkType: hard
"@codemirror/language@npm:6.12.4, @codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.12.3, @codemirror/language@npm:^6.3.0, @codemirror/language@npm:^6.4.0, @codemirror/language@npm:^6.6.0":
"@codemirror/language@npm:6.12.4, @codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.12.3":
version: 6.12.4
resolution: "@codemirror/language@npm:6.12.4"
dependencies:
@@ -3025,14 +2923,8 @@ __metadata:
resolution: "@grafana/ui@workspace:packages/grafana-ui"
dependencies:
"@codemirror/autocomplete": "npm:^6.20.1"
"@codemirror/lang-go": "npm:^6.0.1"
"@codemirror/lang-html": "npm:^6.4.11"
"@codemirror/lang-javascript": "npm:^6.2.5"
"@codemirror/lang-json": "npm:^6.0.2"
"@codemirror/lang-markdown": "npm:^6.5.1"
"@codemirror/lang-sql": "npm:^6.10.0"
"@codemirror/lang-xml": "npm:^6.1.0"
"@codemirror/lang-yaml": "npm:^6.1.3"
"@codemirror/language": "npm:^6.12.3"
"@codemirror/state": "npm:^6.6.0"
"@codemirror/view": "npm:^6.41.0"
@@ -4856,36 +4748,14 @@ __metadata:
languageName: node
linkType: hard
"@lezer/common@npm:1.5.2, @lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.2.1, @lezer/common@npm:^1.3.0, @lezer/common@npm:^1.5.0":
"@lezer/common@npm:1.5.2, @lezer/common@npm:^1.0.0, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.3.0, @lezer/common@npm:^1.5.0":
version: 1.5.2
resolution: "@lezer/common@npm:1.5.2"
checksum: 10/62c0a0ce431cb14bfa6cded04e24a05efa566e9260314bb3f3cf547d501efa1db5c83fc9a7871f8e0b6a4030212840f0e6567433168960c7c83bb3ffbeef8b99
languageName: node
linkType: hard
"@lezer/css@npm:^1.1.0, @lezer/css@npm:^1.1.7":
version: 1.3.4
resolution: "@lezer/css@npm:1.3.4"
dependencies:
"@lezer/common": "npm:^1.2.0"
"@lezer/highlight": "npm:^1.0.0"
"@lezer/lr": "npm:^1.3.0"
checksum: 10/a9ba78821e219353632e150a9de670010955ac40f73b1c0a212904bc80d27971f706db2bf9cdb4e1eaa553b17fc9d4283a5432a8a5926f7f7e626d0fe66ffb1e
languageName: node
linkType: hard
"@lezer/go@npm:^1.0.0":
version: 1.0.1
resolution: "@lezer/go@npm:1.0.1"
dependencies:
"@lezer/common": "npm:^1.2.0"
"@lezer/highlight": "npm:^1.0.0"
"@lezer/lr": "npm:^1.3.0"
checksum: 10/1b96969679a16973fc112027fb28301328454e739621e0695f0011b1e5f159bc792e829dd8d586b8fd1c34218be4d9dee2ae73fa8802dcba98da9219a38a92d9
languageName: node
linkType: hard
"@lezer/highlight@npm:1.2.3, @lezer/highlight@npm:^1.0.0, @lezer/highlight@npm:^1.1.3, @lezer/highlight@npm:^1.2.0, @lezer/highlight@npm:^1.2.3":
"@lezer/highlight@npm:1.2.3, @lezer/highlight@npm:^1.0.0, @lezer/highlight@npm:^1.2.3":
version: 1.2.3
resolution: "@lezer/highlight@npm:1.2.3"
dependencies:
@@ -4894,28 +4764,6 @@ __metadata:
languageName: node
linkType: hard
"@lezer/html@npm:^1.3.12":
version: 1.3.13
resolution: "@lezer/html@npm:1.3.13"
dependencies:
"@lezer/common": "npm:^1.2.0"
"@lezer/highlight": "npm:^1.0.0"
"@lezer/lr": "npm:^1.0.0"
checksum: 10/ee4dc2d5a245b11c912d1706d8f7be43f0c38f203c8b6ce4f4dbf58714a8b00f2e2c6734c1f971293b2628e62e3d1a0ef29b951e843e0b87d13d734e3876509d
languageName: node
linkType: hard
"@lezer/javascript@npm:^1.0.0":
version: 1.5.4
resolution: "@lezer/javascript@npm:1.5.4"
dependencies:
"@lezer/common": "npm:^1.2.0"
"@lezer/highlight": "npm:^1.1.3"
"@lezer/lr": "npm:^1.3.0"
checksum: 10/e3cf0b57b131e163aa6ce8a66469e5c39da4db6e83cbefa3bc864575f999229ad02d5d69af56d8aa05b21db22de284c13cfbc9b8b5bb7a628b5599330cf1ef39
languageName: node
linkType: hard
"@lezer/json@npm:^1.0.0":
version: 1.0.3
resolution: "@lezer/json@npm:1.0.3"
@@ -4927,7 +4775,7 @@ __metadata:
languageName: node
linkType: hard
"@lezer/lr@npm:1.4.8":
"@lezer/lr@npm:1.4.8, @lezer/lr@npm:^1.0.0":
version: 1.4.8
resolution: "@lezer/lr@npm:1.4.8"
dependencies:
@@ -4936,47 +4784,6 @@ __metadata:
languageName: node
linkType: hard
"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.3.0, @lezer/lr@npm:^1.4.0":
version: 1.4.10
resolution: "@lezer/lr@npm:1.4.10"
dependencies:
"@lezer/common": "npm:^1.0.0"
checksum: 10/f8615c755dbbc44aa77f018d81d941cb9afc6bf7c59d78d099dadf2b15c5971bd6cbe38930c0ec143d1b4b3ee7f05da758f72e4980849c9b3eeb19f4af6f34f8
languageName: node
linkType: hard
"@lezer/markdown@npm:^1.0.0":
version: 1.7.2
resolution: "@lezer/markdown@npm:1.7.2"
dependencies:
"@lezer/common": "npm:^1.5.0"
"@lezer/highlight": "npm:^1.0.0"
checksum: 10/2f2a94697f5e77d7de75d9bebdf0fffb331e2878b7673f5d0ba97f054e2d3801207c75afc011a1b8a877e7f38dea902d37163f91b240130e8d7532dfc1ebca97
languageName: node
linkType: hard
"@lezer/xml@npm:^1.0.0":
version: 1.0.6
resolution: "@lezer/xml@npm:1.0.6"
dependencies:
"@lezer/common": "npm:^1.2.0"
"@lezer/highlight": "npm:^1.0.0"
"@lezer/lr": "npm:^1.0.0"
checksum: 10/ffdc3fd587c992f86de84bd828e1d92216484a571423b6edb7b0b1f2eb495ff14e9a234ce5fcba4bce1cb00683af50c0e7f5dfb017a0d3da77607b42e77548a2
languageName: node
linkType: hard
"@lezer/yaml@npm:^1.0.0":
version: 1.0.4
resolution: "@lezer/yaml@npm:1.0.4"
dependencies:
"@lezer/common": "npm:^1.2.0"
"@lezer/highlight": "npm:^1.0.0"
"@lezer/lr": "npm:^1.4.0"
checksum: 10/9116e6f9b579be2737e281136c848d916def52de47937b5c2238fe5533e3bf636880b6aa5a5347bac955f3a179cb8d5345589995eee71bdee366f83a23af7062
languageName: node
linkType: hard
"@loaderkit/resolve@npm:^1.0.2":
version: 1.0.4
resolution: "@loaderkit/resolve@npm:1.0.4"