mirror of
https://github.com/grafana/grafana.git
synced 2026-08-07 03:45:07 -05:00
This reverts commit bfe238b458.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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(
|
||||
'<form><p>Form tags are sanitized.</p></form>\n<script>Script tags are sanitized.</script>'
|
||||
);
|
||||
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(
|
||||
'<form><p>Form tags are sanitized.</p></form>\n<script>Script tags are sanitized.</script>'
|
||||
);
|
||||
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&to=now">Example: from=now-6h&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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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;
|
||||
};
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user