mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-30 16:20:27 +01:00
Compare commits
13 Commits
chore/dash
...
feat/text-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
284526c9a2 | ||
|
|
6232781162 | ||
|
|
144b8cd8b8 | ||
|
|
926e60df61 | ||
|
|
7c18f0db2b | ||
|
|
827b278480 | ||
|
|
68fb9be9c8 | ||
|
|
0eeeed6de9 | ||
|
|
acaeaacbc6 | ||
|
|
052a7babb9 | ||
|
|
801cc097b5 | ||
|
|
e1bef67cf1 | ||
|
|
cf51dee878 |
@@ -565,12 +565,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
// Root V2 pages own the dashboard fetch lifecycle; useDashboardFetchRequired wraps it.
|
||||
// Root dashboard pages own the fetch lifecycle; useDashboardFetchRequired wraps it.
|
||||
// Everywhere else must use useDashboardFetchRequired().
|
||||
"files": [
|
||||
"src/pages/DashboardPageV2/DashboardPageV2.tsx",
|
||||
"src/pages/DashboardPageV2/PanelEditorPage/PanelEditorPage.tsx",
|
||||
"src/pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired.ts"
|
||||
"src/pages/DashboardPage/DashboardPage.tsx",
|
||||
"src/pages/DashboardPage/PanelEditorPage/PanelEditorPage.tsx",
|
||||
"src/pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired.ts"
|
||||
],
|
||||
"rules": {
|
||||
"signoz/no-dashboard-fetch-outside-root": "off"
|
||||
|
||||
@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
|
||||
transformIgnorePatterns: [
|
||||
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
|
||||
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
|
||||
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -94,18 +94,18 @@ export const OnboardingV2 = Loadable(
|
||||
export const DashboardsListPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "DashboardsListPage" */ 'pages/DashboardsListPageV2'
|
||||
/* webpackChunkName: "DashboardsListPage" */ 'pages/DashboardsListPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const DashboardPage = Loadable(
|
||||
() => import(/* webpackChunkName: "DashboardPage" */ 'pages/DashboardPageV2'),
|
||||
() => import(/* webpackChunkName: "DashboardPage" */ 'pages/DashboardPage'),
|
||||
);
|
||||
|
||||
export const DashboardPanelEditorPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "DashboardPanelEditorPage" */ 'pages/DashboardPageV2/PanelEditorPage/PanelEditorPage'
|
||||
/* webpackChunkName: "DashboardPanelEditorPage" */ 'pages/DashboardPage/PanelEditorPage/PanelEditorPage'
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
47
frontend/src/components/MarkdownEditor/EditorStatusBar.tsx
Normal file
47
frontend/src/components/MarkdownEditor/EditorStatusBar.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { CursorPosition } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
interface EditorStatusBarProps {
|
||||
cursor: CursorPosition;
|
||||
length: number;
|
||||
maxLength: number;
|
||||
hint?: ReactNode;
|
||||
}
|
||||
|
||||
function EditorStatusBar({
|
||||
cursor,
|
||||
length,
|
||||
maxLength,
|
||||
hint,
|
||||
}: EditorStatusBarProps): JSX.Element {
|
||||
const isOverLimit = length > maxLength;
|
||||
|
||||
return (
|
||||
<div className={styles.statusBar} data-testid="markdown-editor-status">
|
||||
<Typography.Text className={styles.statusPosition}>
|
||||
{`Ln ${cursor.line}, Col ${cursor.column}`}
|
||||
<span className={styles.statusSeparator}>·</span>
|
||||
<span
|
||||
className={cx(styles.statusCount, {
|
||||
[styles.statusCountOverLimit]: isOverLimit,
|
||||
})}
|
||||
data-testid="markdown-editor-char-count"
|
||||
>
|
||||
{isOverLimit
|
||||
? `${length} / ${maxLength} chars`
|
||||
: `${length} chars`}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
{hint && (
|
||||
<Typography.Text className={styles.statusHint}>{hint}</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditorStatusBar;
|
||||
101
frontend/src/components/MarkdownEditor/EditorToolbar.tsx
Normal file
101
frontend/src/components/MarkdownEditor/EditorToolbar.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Bold,
|
||||
CodeXml,
|
||||
Heading,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
ListOrdered,
|
||||
Table,
|
||||
Type,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import InsertVariableMenu from './InsertVariableMenu';
|
||||
import MarkdownHelp from './MarkdownHelp';
|
||||
import { formatShortcut } from './shortcut';
|
||||
import type { EditorCommand, EditorVariable } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
const COMMAND_ICONS: Record<string, ReactNode> = {
|
||||
heading: <Heading size={14} />,
|
||||
bold: <Bold size={14} />,
|
||||
italic: <Italic size={14} />,
|
||||
'bulleted-list': <List size={14} />,
|
||||
'numbered-list': <ListOrdered size={14} />,
|
||||
link: <Link size={14} />,
|
||||
code: <CodeXml size={14} />,
|
||||
table: <Table size={14} />,
|
||||
};
|
||||
|
||||
interface EditorToolbarProps {
|
||||
formatLabel: string;
|
||||
commands: EditorCommand[];
|
||||
onRunCommand: (command: EditorCommand) => void;
|
||||
variables: EditorVariable[];
|
||||
onInsertVariable: (name: string) => void;
|
||||
disabled: boolean;
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
function EditorToolbar({
|
||||
formatLabel,
|
||||
commands,
|
||||
onRunCommand,
|
||||
variables,
|
||||
onInsertVariable,
|
||||
disabled,
|
||||
extra,
|
||||
}: EditorToolbarProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.toolbar} data-testid="markdown-editor-toolbar">
|
||||
<span className={styles.formatChip}>
|
||||
<Type size={14} />
|
||||
<Typography.Text className={styles.formatLabel}>
|
||||
{formatLabel}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<span className={styles.toolbarDivider} />
|
||||
<div className={styles.commands}>
|
||||
{commands.map((command) => (
|
||||
<TooltipSimple
|
||||
key={command.id}
|
||||
title={
|
||||
command.shortcut
|
||||
? `${command.label} (${formatShortcut(command.shortcut)})`
|
||||
: command.label
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={command.label}
|
||||
data-testid={`markdown-command-${command.id}`}
|
||||
onClick={(): void => onRunCommand(command)}
|
||||
>
|
||||
{COMMAND_ICONS[command.id]}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.toolbarEnd}>
|
||||
{extra}
|
||||
<InsertVariableMenu
|
||||
variables={variables}
|
||||
onSelect={onInsertVariable}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<MarkdownHelp />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditorToolbar;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import { formatVariableToken } from './constants';
|
||||
import type { EditorVariable } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
const SEARCH_THRESHOLD = 8;
|
||||
|
||||
interface InsertVariableMenuProps {
|
||||
variables: EditorVariable[];
|
||||
/** Receives the variable name; the caller decides the token syntax. */
|
||||
onSelect: (name: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/** Groups by `source` when at least one variable declares one, preserving order. */
|
||||
function toMenuItems(
|
||||
variables: EditorVariable[],
|
||||
onSelect: (name: string) => void,
|
||||
): MenuItem[] {
|
||||
const toItem = (variable: EditorVariable): MenuItem => ({
|
||||
key: `${variable.source ?? ''}-${variable.name}`,
|
||||
label: formatVariableToken(variable.name),
|
||||
onClick: (): void => onSelect(variable.name),
|
||||
});
|
||||
|
||||
if (!variables.some((variable) => variable.source)) {
|
||||
return variables.map(toItem);
|
||||
}
|
||||
|
||||
const groups = new Map<string, EditorVariable[]>();
|
||||
variables.forEach((variable) => {
|
||||
const source = variable.source ?? 'Other';
|
||||
groups.set(source, [...(groups.get(source) ?? []), variable]);
|
||||
});
|
||||
|
||||
return Array.from(groups.entries()).map(([source, items]) => ({
|
||||
type: 'group',
|
||||
key: source,
|
||||
label: source,
|
||||
children: items.map(toItem),
|
||||
}));
|
||||
}
|
||||
|
||||
function InsertVariableMenu({
|
||||
variables,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: InsertVariableMenuProps): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return query
|
||||
? variables.filter((variable) =>
|
||||
variable.name.toLowerCase().includes(query),
|
||||
)
|
||||
: variables;
|
||||
}, [variables, search]);
|
||||
|
||||
const items = useMemo(() => toMenuItems(matches, onSelect), [
|
||||
matches,
|
||||
onSelect,
|
||||
]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple
|
||||
menu={{
|
||||
items,
|
||||
search:
|
||||
variables.length > SEARCH_THRESHOLD
|
||||
? { placeholder: 'Search variables', onSearchChange: setSearch }
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
disabled={disabled || variables.length === 0}
|
||||
suffix={<ChevronDown size={14} />}
|
||||
className={styles.insertVariable}
|
||||
data-testid="markdown-insert-variable"
|
||||
>
|
||||
Insert variable
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default InsertVariableMenu;
|
||||
@@ -0,0 +1,204 @@
|
||||
@use '../../styles/scrollbar' as *;
|
||||
|
||||
.container {
|
||||
// Read by the decoration theme in `markdownHighlight`, which can't see SCSS.
|
||||
--md-syntax-heading: var(--text-vanilla-100);
|
||||
--md-syntax-strong: var(--text-vanilla-100);
|
||||
--md-syntax-emphasis: var(--text-vanilla-300);
|
||||
--md-syntax-quote: var(--text-vanilla-400);
|
||||
--md-syntax-marker: var(--text-robin-300);
|
||||
--md-syntax-code: var(--text-forest-400);
|
||||
--md-syntax-link: var(--text-robin-400);
|
||||
--md-syntax-variable: var(--text-amber-400);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
:global(body.lightMode) .container {
|
||||
--md-syntax-heading: var(--text-ink-400);
|
||||
--md-syntax-strong: var(--text-ink-400);
|
||||
--md-syntax-emphasis: var(--text-ink-200);
|
||||
--md-syntax-quote: var(--text-neutral-light-100);
|
||||
--md-syntax-marker: var(--text-robin-500);
|
||||
--md-syntax-code: var(--text-forest-700);
|
||||
--md-syntax-link: var(--text-robin-500);
|
||||
--md-syntax-variable: var(--text-sienna-500);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.formatChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
color: var(--text-sienna-400);
|
||||
}
|
||||
|
||||
.formatLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.toolbarDivider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.commands {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbarEnd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.insertVariable {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editorArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codeMirror {
|
||||
height: 100%;
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 13px;
|
||||
|
||||
:global(.cm-editor) {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.cm-editor.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(.cm-gutters) {
|
||||
background: transparent;
|
||||
border-right: none;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
:global(.cm-scroller) {
|
||||
line-height: 20px;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
:global(.cm-content) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.statusPosition {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.statusSeparator {
|
||||
color: var(--l1-border);
|
||||
}
|
||||
|
||||
.statusCount {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.statusCountOverLimit {
|
||||
color: var(--text-cherry-400);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statusHint {
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.helpContent {
|
||||
width: 280px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.helpTitle {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.helpList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.helpRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
dt {
|
||||
margin: 0;
|
||||
|
||||
code {
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-forest-400);
|
||||
}
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
}
|
||||
|
||||
// The help popover portals out of `.container`, so it can't inherit its tokens.
|
||||
:global(body.lightMode) .helpRow dt code {
|
||||
color: var(--text-forest-700);
|
||||
}
|
||||
265
frontend/src/components/MarkdownEditor/MarkdownEditor.tsx
Normal file
265
frontend/src/components/MarkdownEditor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { copilot } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror, {
|
||||
type BasicSetupOptions,
|
||||
EditorView,
|
||||
keymap,
|
||||
Prec,
|
||||
type ViewUpdate,
|
||||
} from '@uiw/react-codemirror';
|
||||
import cx from 'classnames';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { formatVariableToken, MARKDOWN_MAX_LENGTH } from './constants';
|
||||
import EditorStatusBar from './EditorStatusBar';
|
||||
import EditorToolbar from './EditorToolbar';
|
||||
import { applyTransform, replaceDocument } from './editorDocument';
|
||||
import { insertText, MARKDOWN_COMMANDS } from './markdownCommands';
|
||||
import { markdownHighlight } from './markdownHighlight';
|
||||
import type {
|
||||
CursorPosition,
|
||||
EditorCommand,
|
||||
EditorTransform,
|
||||
EditorVariable,
|
||||
} from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
/** What the status bar reports. */
|
||||
type DocumentStatus = CursorPosition & { length: number };
|
||||
|
||||
// No language grammar is loaded, so bracket/indent/completion behaviour would only
|
||||
// get in the way of prose. `indentWithTab` stays off so Tab keeps moving focus.
|
||||
const BASIC_SETUP: BasicSetupOptions = {
|
||||
lineNumbers: true,
|
||||
highlightActiveLine: true,
|
||||
highlightActiveLineGutter: true,
|
||||
foldGutter: false,
|
||||
autocompletion: false,
|
||||
bracketMatching: false,
|
||||
closeBrackets: false,
|
||||
indentOnInput: false,
|
||||
syntaxHighlighting: false,
|
||||
highlightSelectionMatches: false,
|
||||
rectangularSelection: false,
|
||||
crosshairCursor: false,
|
||||
searchKeymap: false,
|
||||
foldKeymap: false,
|
||||
lintKeymap: false,
|
||||
completionKeymap: false,
|
||||
closeBracketsKeymap: false,
|
||||
};
|
||||
|
||||
const EMPTY_VARIABLES: EditorVariable[] = [];
|
||||
|
||||
export interface MarkdownEditorProps {
|
||||
/** Seeds the document; replaced only from outside. See the sync effect. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Offered by the "Insert variable" menu; the button disables when empty. */
|
||||
variables?: EditorVariable[];
|
||||
/** What the character counter reports against. */
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
/** Shown on the toolbar chip. */
|
||||
formatLabel?: string;
|
||||
/** Rendered before the "Insert variable" menu. */
|
||||
toolbarExtra?: ReactNode;
|
||||
/** Right-hand status-bar note, e.g. "Preview updates as you type". */
|
||||
statusHint?: ReactNode;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source editor for Markdown bodies. Source-only: it neither parses nor renders
|
||||
* the body, so the preview surface and its sanitisation stay the caller's concern.
|
||||
*/
|
||||
function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
variables = EMPTY_VARIABLES,
|
||||
maxLength = MARKDOWN_MAX_LENGTH,
|
||||
placeholder = 'Write Markdown…',
|
||||
readOnly = false,
|
||||
formatLabel = 'Markdown',
|
||||
toolbarExtra,
|
||||
statusHint,
|
||||
autoFocus = false,
|
||||
className,
|
||||
testId = 'markdown-editor',
|
||||
}: MarkdownEditorProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
// Set while a programmatic replacement is in flight, so the caller isn't told
|
||||
// about a change it asked for. `dispatch` runs listeners synchronously, so the
|
||||
// window is exactly one call.
|
||||
const isSyncingRef = useRef(false);
|
||||
const previousValueRef = useRef(value);
|
||||
const hasSeededRef = useRef(false);
|
||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||
const [status, setStatus] = useState<DocumentStatus>(() => ({
|
||||
line: 1,
|
||||
column: 1,
|
||||
length: value.length,
|
||||
}));
|
||||
|
||||
const syncDocument = useCallback((view: EditorView, next: string): void => {
|
||||
isSyncingRef.current = true;
|
||||
replaceDocument(view, next);
|
||||
isSyncingRef.current = false;
|
||||
}, []);
|
||||
|
||||
const onCreateEditor = useCallback((view: EditorView): void => {
|
||||
viewRef.current = view;
|
||||
setIsEditorReady(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Seeds the document, then applies external replacements — nothing else. Keeping
|
||||
* keystrokes out of this round-trip is what stops a stale `value` from replacing
|
||||
* the document and resetting the caret when typing outpaces React.
|
||||
*
|
||||
* The seed can't go in `onCreateEditor`: the wrapper defaults its own `value` to
|
||||
* `''` and reconciles against it once the view exists, wiping anything written
|
||||
* before that. `isEditorReady` puts this effect after that pass, since a parent's
|
||||
* effects flush after its children's.
|
||||
*
|
||||
* Focus marks ownership: a replacement arriving mid-typing is dropped rather than
|
||||
* applied over the author.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = previousValueRef.current;
|
||||
previousValueRef.current = value;
|
||||
const isSeeding = !hasSeededRef.current;
|
||||
hasSeededRef.current = true;
|
||||
|
||||
if (!isSeeding && (value === previous || view.hasFocus)) {
|
||||
return;
|
||||
}
|
||||
if (view.state.doc.toString() !== value) {
|
||||
syncDocument(view, value);
|
||||
}
|
||||
}, [value, isEditorReady, syncDocument]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(next: string): void => {
|
||||
if (!isSyncingRef.current) {
|
||||
onChange(next);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const runTransform = useCallback((transform: EditorTransform): void => {
|
||||
const view = viewRef.current;
|
||||
if (view) {
|
||||
applyTransform(view, transform);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onRunCommand = useCallback(
|
||||
(command: EditorCommand): void => runTransform(command.run),
|
||||
[runTransform],
|
||||
);
|
||||
|
||||
const onInsertVariable = useCallback(
|
||||
(name: string): void =>
|
||||
runTransform((snapshot) =>
|
||||
insertText(snapshot, formatVariableToken(name)),
|
||||
),
|
||||
[runTransform],
|
||||
);
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
markdownHighlight(),
|
||||
EditorView.lineWrapping,
|
||||
// Ahead of the default keymap so `Mod-i`/`Mod-k` reach the commands.
|
||||
Prec.high(
|
||||
keymap.of(
|
||||
MARKDOWN_COMMANDS.filter((command) => command.shortcut).map(
|
||||
(command) => ({
|
||||
key: command.shortcut as string,
|
||||
preventDefault: true,
|
||||
run: (view: EditorView): boolean =>
|
||||
applyTransform(view, command.run),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
// From the document, not `value`: the caller may debounce or drop a change, and
|
||||
// the counter has to match what the author sees.
|
||||
const onUpdate = useCallback((update: ViewUpdate): void => {
|
||||
if (!update.selectionSet && !update.docChanged) {
|
||||
return;
|
||||
}
|
||||
const { head } = update.state.selection.main;
|
||||
const line = update.state.doc.lineAt(head);
|
||||
setStatus({
|
||||
line: line.number,
|
||||
column: head - line.from + 1,
|
||||
length: update.state.doc.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container, className)} data-testid={testId}>
|
||||
<EditorToolbar
|
||||
formatLabel={formatLabel}
|
||||
commands={MARKDOWN_COMMANDS}
|
||||
onRunCommand={onRunCommand}
|
||||
variables={variables}
|
||||
onInsertVariable={onInsertVariable}
|
||||
disabled={readOnly}
|
||||
extra={toolbarExtra}
|
||||
/>
|
||||
<div className={styles.editorArea}>
|
||||
<CodeMirror
|
||||
className={styles.codeMirror}
|
||||
// No `value`: passing it re-enables the wrapper's own reconciliation,
|
||||
// and with it the caret reset.
|
||||
onCreateEditor={onCreateEditor}
|
||||
onChange={handleChange}
|
||||
onUpdate={onUpdate}
|
||||
theme={isDarkMode ? copilot : githubLight}
|
||||
basicSetup={BASIC_SETUP}
|
||||
placeholder={placeholder}
|
||||
editable={!readOnly}
|
||||
readOnly={readOnly}
|
||||
indentWithTab={false}
|
||||
autoFocus={autoFocus}
|
||||
extensions={extensions}
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
<EditorStatusBar
|
||||
cursor={status}
|
||||
length={status.length}
|
||||
maxLength={maxLength}
|
||||
hint={statusHint}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownEditor;
|
||||
44
frontend/src/components/MarkdownEditor/MarkdownHelp.tsx
Normal file
44
frontend/src/components/MarkdownEditor/MarkdownHelp.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { MARKDOWN_HELP_ITEMS } from './constants';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
function MarkdownHelp(): JSX.Element {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Markdown syntax help"
|
||||
data-testid="markdown-help-trigger"
|
||||
>
|
||||
<CircleHelp size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className={styles.helpContent}>
|
||||
<Typography.Text className={styles.helpTitle}>
|
||||
Markdown syntax
|
||||
</Typography.Text>
|
||||
<dl className={styles.helpList}>
|
||||
{MARKDOWN_HELP_ITEMS.map((item) => (
|
||||
<div key={item.syntax} className={styles.helpRow}>
|
||||
<dt>
|
||||
<code>{item.syntax}</code>
|
||||
</dt>
|
||||
<dd>{item.label}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownHelp;
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
import { mockCodeMirrorDomApis } from 'components/QueryBuilderV2/QueryV2/__tests__/codemirrorDomMocks';
|
||||
import { act, fireEvent, render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import MarkdownEditor from '../MarkdownEditor';
|
||||
import type { EditorVariable } from '../types';
|
||||
|
||||
beforeAll(() => {
|
||||
mockCodeMirrorDomApis();
|
||||
});
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => true,
|
||||
}));
|
||||
|
||||
const VARIABLES: EditorVariable[] = [
|
||||
{ name: 'environment', source: 'Dashboard variable' },
|
||||
{ name: 'service', source: 'Dashboard variable' },
|
||||
];
|
||||
|
||||
/** A caller whose state trails the editor by one keystroke. */
|
||||
function LaggingHarness(): JSX.Element {
|
||||
const [value, setValue] = useState('');
|
||||
const previousRef = useRef('');
|
||||
const onChange = useCallback((next: string): void => {
|
||||
setValue(previousRef.current);
|
||||
previousRef.current = next;
|
||||
}, []);
|
||||
|
||||
return <MarkdownEditor value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
/** Pushes a replacement in from outside the editor. */
|
||||
function ExternalHarness(): JSX.Element {
|
||||
const [value, setValue] = useState('before');
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={(): void => setValue('after')}>
|
||||
push
|
||||
</button>
|
||||
<MarkdownEditor value={value} onChange={setValue} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Harness({
|
||||
initialValue = '',
|
||||
maxLength,
|
||||
variables = VARIABLES,
|
||||
}: {
|
||||
initialValue?: string;
|
||||
maxLength?: number;
|
||||
variables?: EditorVariable[];
|
||||
}): JSX.Element {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
return (
|
||||
<MarkdownEditor
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
variables={variables}
|
||||
maxLength={maxLength}
|
||||
statusHint="Preview updates as you type"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getView = (): EditorView => {
|
||||
const dom = document.querySelector('.cm-editor');
|
||||
const view = dom ? EditorView.findFromDOM(dom as HTMLElement) : null;
|
||||
if (!view) {
|
||||
throw new Error('editor view not mounted');
|
||||
}
|
||||
return view;
|
||||
};
|
||||
|
||||
const select = (from: number, to: number): void => {
|
||||
act(() => {
|
||||
getView().dispatch({ selection: { anchor: from, head: to } });
|
||||
});
|
||||
};
|
||||
|
||||
const documentText = (): string => getView().state.doc.toString();
|
||||
|
||||
describe('MarkdownEditor', () => {
|
||||
it('reports the caret position and character count', async () => {
|
||||
render(<Harness initialValue={'one\ntwo'} />);
|
||||
|
||||
select(5, 5);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('markdown-editor-status')).toHaveTextContent(
|
||||
'Ln 2, Col 2',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
|
||||
'7 chars',
|
||||
);
|
||||
});
|
||||
|
||||
it('flags a body over the character cap', async () => {
|
||||
render(<Harness initialValue="123456" maxLength={5} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
|
||||
'6 / 5 chars',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('applies a toolbar command to the selection', async () => {
|
||||
render(<Harness initialValue="a word b" />);
|
||||
|
||||
select(2, 6);
|
||||
await userEvent.click(screen.getByTestId('markdown-command-bold'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(documentText()).toBe('a **word** b');
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts a variable token at the caret', async () => {
|
||||
render(<Harness initialValue="env: " />);
|
||||
|
||||
select(5, 5);
|
||||
await userEvent.click(screen.getByTestId('markdown-insert-variable'));
|
||||
// fireEvent: userEvent's pointer-down path walks DOM selection APIs the
|
||||
// CodeMirror mocks stub out.
|
||||
fireEvent.click(await screen.findByText('{{environment}}'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(documentText()).toBe('env: {{environment}}');
|
||||
});
|
||||
});
|
||||
|
||||
it('colours Markdown syntax and variable tokens in the source', async () => {
|
||||
render(<Harness initialValue={'## Runbook\nowner {{team}}'} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.cm-md-heading')).toBeInTheDocument();
|
||||
});
|
||||
expect(document.querySelector('.cm-md-variable')).toHaveTextContent(
|
||||
'{{team}}',
|
||||
);
|
||||
});
|
||||
|
||||
describe('uncontrolled document', () => {
|
||||
const type = (at: number, text: string): void => {
|
||||
act(() => {
|
||||
getView().dispatch({
|
||||
changes: { from: at, insert: text },
|
||||
selection: { anchor: at + text.length },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const focusEditor = (): void => {
|
||||
act(() => {
|
||||
getView().focus();
|
||||
});
|
||||
};
|
||||
|
||||
it('keeps the document and caret while the caller lags behind the typing', () => {
|
||||
render(<LaggingHarness />);
|
||||
focusEditor();
|
||||
|
||||
type(0, 'a');
|
||||
type(1, 'b');
|
||||
type(2, 'c');
|
||||
|
||||
expect(documentText()).toBe('abc');
|
||||
expect(getView().state.selection.main.head).toBe(3);
|
||||
});
|
||||
|
||||
it('reports every keystroke to the caller', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<MarkdownEditor value="ab" onChange={onChange} />);
|
||||
|
||||
type(2, 'c');
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith('abc');
|
||||
});
|
||||
|
||||
it('does not report the seed back as a change', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<MarkdownEditor value="seeded" onChange={onChange} />);
|
||||
|
||||
expect(documentText()).toBe('seeded');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies an external replacement while the editor is unfocused', async () => {
|
||||
render(<ExternalHarness />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'push' }));
|
||||
|
||||
expect(documentText()).toBe('after');
|
||||
});
|
||||
|
||||
it('ignores a replacement that arrives while the author is still typing', () => {
|
||||
render(<ExternalHarness />);
|
||||
focusEditor();
|
||||
|
||||
// fireEvent: a real click would blur the editor first. This covers an update
|
||||
// arriving on its own, while the author is still in the document.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'push' }));
|
||||
|
||||
expect(documentText()).toBe('before');
|
||||
});
|
||||
|
||||
it('counts characters from the document, not from the lagging value', async () => {
|
||||
render(<MarkdownEditor value="ab" onChange={jest.fn()} />);
|
||||
|
||||
type(2, 'cde');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('markdown-editor-char-count'),
|
||||
).toHaveTextContent('5 chars');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('offers both list kinds in the toolbar', () => {
|
||||
render(<Harness />);
|
||||
|
||||
expect(screen.getByTestId('markdown-command-bulleted-list')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('markdown-command-numbered-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables authoring affordances when read-only', () => {
|
||||
render(
|
||||
<MarkdownEditor value="body" onChange={jest.fn()} variables={VARIABLES} readOnly />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('markdown-command-bold')).toBeDisabled();
|
||||
expect(screen.getByTestId('markdown-insert-variable')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('offers no variables to insert when none are available', () => {
|
||||
render(<Harness variables={[]} />);
|
||||
|
||||
expect(screen.getByTestId('markdown-insert-variable')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { insertText, MARKDOWN_COMMANDS } from '../markdownCommands';
|
||||
import type { EditorSnapshot, EditorTransform } from '../types';
|
||||
|
||||
const commandById = (id: string): EditorTransform => {
|
||||
const command = MARKDOWN_COMMANDS.find((entry) => entry.id === id);
|
||||
if (!command) {
|
||||
throw new Error(`unknown command: ${id}`);
|
||||
}
|
||||
return command.run;
|
||||
};
|
||||
|
||||
const heading = commandById('heading');
|
||||
const bold = commandById('bold');
|
||||
const italic = commandById('italic');
|
||||
const bulletedList = commandById('bulleted-list');
|
||||
const numberedList = commandById('numbered-list');
|
||||
const link = commandById('link');
|
||||
const code = commandById('code');
|
||||
const table = commandById('table');
|
||||
|
||||
/** `|` marks a caret, `[...]` a range, so expectations read like the editor looks. */
|
||||
const snapshot = (marked: string): EditorSnapshot => {
|
||||
if (marked.includes('|')) {
|
||||
const caret = marked.indexOf('|');
|
||||
return {
|
||||
text: marked.replace('|', ''),
|
||||
selectionStart: caret,
|
||||
selectionEnd: caret,
|
||||
};
|
||||
}
|
||||
const start = marked.indexOf('[');
|
||||
const end = marked.indexOf(']') - 1;
|
||||
return {
|
||||
text: marked.replace('[', '').replace(']', ''),
|
||||
selectionStart: start,
|
||||
selectionEnd: end,
|
||||
};
|
||||
};
|
||||
|
||||
const selectionOf = (result: EditorSnapshot): string =>
|
||||
result.text.slice(result.selectionStart, result.selectionEnd);
|
||||
|
||||
describe('heading', () => {
|
||||
it('prefixes the caret line and keeps the caret on the same character', () => {
|
||||
const result = heading(snapshot('Chec|kout'));
|
||||
|
||||
expect(result.text).toBe('## Checkout');
|
||||
expect(result.selectionStart).toBe(7);
|
||||
});
|
||||
|
||||
it('strips the prefix when every selected line already has one', () => {
|
||||
const result = heading({
|
||||
text: '## one\n### two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
|
||||
it('adds the prefix when only some selected lines have one', () => {
|
||||
const result = heading({
|
||||
text: '## one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('## ## one\n## two');
|
||||
});
|
||||
|
||||
it('does not pull in the line after a selection ending on a line break', () => {
|
||||
const result = heading({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 4,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('## one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulleted list', () => {
|
||||
it('bullets every line of a multi-line selection', () => {
|
||||
const result = bulletedList({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 7,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('- one\n- two');
|
||||
expect(selectionOf(result)).toBe('- one\n- two');
|
||||
});
|
||||
|
||||
it('unbullets a list written with a different marker', () => {
|
||||
const result = bulletedList({
|
||||
text: '* one\n+ two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 11,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('numbered list', () => {
|
||||
it('numbers each line of the selection in order', () => {
|
||||
const result = numberedList({
|
||||
text: 'one\ntwo\nthree',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n2. two\n3. three');
|
||||
});
|
||||
|
||||
it('unnumbers a list whose numbering is not sequential', () => {
|
||||
const result = numberedList({
|
||||
text: '1. one\n5. two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('switching between list kinds', () => {
|
||||
it('converts bullets to numbers rather than marking them twice', () => {
|
||||
const result = numberedList({
|
||||
text: '- one\n- two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 11,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n2. two');
|
||||
});
|
||||
|
||||
it('converts numbers to bullets', () => {
|
||||
const result = bulletedList({
|
||||
text: '1. one\n2. two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('- one\n- two');
|
||||
});
|
||||
|
||||
it('keeps indentation so nested items stay nested', () => {
|
||||
const result = numberedList({
|
||||
text: 'one\n - nested',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 16,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n 2. nested');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bold and italic', () => {
|
||||
it('wraps the selection and keeps the original text selected', () => {
|
||||
const result = bold(snapshot('a [word] b'));
|
||||
|
||||
expect(result.text).toBe('a **word** b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('unwraps when the markers sit inside the selection', () => {
|
||||
const result = bold({
|
||||
text: 'a **word** b',
|
||||
selectionStart: 2,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('a word b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('unwraps when the markers sit just outside the selection', () => {
|
||||
const result = bold({
|
||||
text: 'a **word** b',
|
||||
selectionStart: 4,
|
||||
selectionEnd: 8,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('a word b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('leaves the caret between the markers when nothing is selected', () => {
|
||||
const result = italic(snapshot('a |b'));
|
||||
|
||||
expect(result.text).toBe('a __b');
|
||||
expect(result.selectionStart).toBe(3);
|
||||
expect(result.selectionEnd).toBe(3);
|
||||
});
|
||||
|
||||
it('does not mistake a leading document boundary for a marker', () => {
|
||||
const result = bold(snapshot('[word] tail'));
|
||||
|
||||
expect(result.text).toBe('**word** tail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('link', () => {
|
||||
it('selects the url when the label came from the selection', () => {
|
||||
const result = link(snapshot('see [docs] now'));
|
||||
|
||||
expect(result.text).toBe('see [docs](https://) now');
|
||||
expect(selectionOf(result)).toBe('https://');
|
||||
});
|
||||
|
||||
it('selects the label placeholder when nothing was selected', () => {
|
||||
const result = link(snapshot('see |'));
|
||||
|
||||
expect(result.text).toBe('see [text](https://)');
|
||||
expect(selectionOf(result)).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('code', () => {
|
||||
it('uses backticks for a single-line selection', () => {
|
||||
const result = code(snapshot('run [npm] here'));
|
||||
|
||||
expect(result.text).toBe('run `npm` here');
|
||||
});
|
||||
|
||||
it('fences a multi-line selection and selects its content', () => {
|
||||
const result = code({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 7,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('```\none\ntwo\n```');
|
||||
expect(selectionOf(result)).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('table', () => {
|
||||
it('starts the skeleton on its own line and selects the first header cell', () => {
|
||||
const result = table(snapshot('intro|'));
|
||||
|
||||
expect(result.text).toBe(
|
||||
'intro\n| Column | Column |\n| --- | --- |\n| | |',
|
||||
);
|
||||
expect(selectionOf(result)).toBe('Column');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertText', () => {
|
||||
it('replaces the selection and leaves the caret after the insertion', () => {
|
||||
const result = insertText(snapshot('env is [old]'), '{{env}}');
|
||||
|
||||
expect(result.text).toBe('env is {{env}}');
|
||||
expect(result.selectionStart).toBe(14);
|
||||
expect(result.selectionEnd).toBe(14);
|
||||
});
|
||||
});
|
||||
24
frontend/src/components/MarkdownEditor/constants.ts
Normal file
24
frontend/src/components/MarkdownEditor/constants.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// The body is persisted inline in the dashboard JSON, so its length is capped.
|
||||
export const MARKDOWN_MAX_LENGTH = 16000;
|
||||
|
||||
/** The canonical syntax; the renderer resolves the other three too. */
|
||||
export const formatVariableToken = (name: string): string => `{{${name}}}`;
|
||||
|
||||
export const MARKDOWN_HELP_ITEMS: { syntax: string; label: string }[] = [
|
||||
// First: consecutive lines joining into one paragraph is the CommonMark rule
|
||||
// authors trip over before any of the formatting syntax.
|
||||
{ syntax: 'blank line', label: 'New paragraph' },
|
||||
{ syntax: '2 spaces + ⏎', label: 'Line break' },
|
||||
{ syntax: '# Heading', label: 'Heading (1–6 #)' },
|
||||
{ syntax: '**bold**', label: 'Bold' },
|
||||
{ syntax: '_italic_', label: 'Italic' },
|
||||
{ syntax: '- item', label: 'Bulleted list' },
|
||||
{ syntax: '1. item', label: 'Numbered list' },
|
||||
{ syntax: '- [ ] task', label: 'Task list' },
|
||||
{ syntax: '[label](url)', label: 'Link' },
|
||||
{ syntax: '', label: 'Image' },
|
||||
{ syntax: '`code`', label: 'Inline code' },
|
||||
{ syntax: '```lang', label: 'Code block' },
|
||||
{ syntax: '> quote', label: 'Blockquote' },
|
||||
{ syntax: '| a | b |', label: 'Table' },
|
||||
];
|
||||
69
frontend/src/components/MarkdownEditor/editorDocument.ts
Normal file
69
frontend/src/components/MarkdownEditor/editorDocument.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
|
||||
import type { EditorSnapshot, EditorTransform } from './types';
|
||||
|
||||
// Narrows a whole-document replacement to the range that changed, so a toolbar
|
||||
// action doesn't invalidate the document's decorations or scroll position.
|
||||
function toChangeSpec(
|
||||
previous: string,
|
||||
next: string,
|
||||
): { from: number; to: number; insert: string } | null {
|
||||
if (previous === next) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shorter = Math.min(previous.length, next.length);
|
||||
let start = 0;
|
||||
while (start < shorter && previous[start] === next[start]) {
|
||||
start += 1;
|
||||
}
|
||||
|
||||
let previousEnd = previous.length;
|
||||
let nextEnd = next.length;
|
||||
while (
|
||||
previousEnd > start &&
|
||||
nextEnd > start &&
|
||||
previous[previousEnd - 1] === next[nextEnd - 1]
|
||||
) {
|
||||
previousEnd -= 1;
|
||||
nextEnd -= 1;
|
||||
}
|
||||
|
||||
return { from: start, to: previousEnd, insert: next.slice(start, nextEnd) };
|
||||
}
|
||||
|
||||
export function readSnapshot(view: EditorView): EditorSnapshot {
|
||||
const range = view.state.selection.main;
|
||||
return {
|
||||
text: view.state.doc.toString(),
|
||||
selectionStart: range.from,
|
||||
selectionEnd: range.to,
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns whether the transform ran, as CodeMirror's keymap contract expects. */
|
||||
export function applyTransform(
|
||||
view: EditorView,
|
||||
transform: EditorTransform,
|
||||
): boolean {
|
||||
if (view.state.readOnly) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const next = transform(readSnapshot(view));
|
||||
const changes = toChangeSpec(view.state.doc.toString(), next.text);
|
||||
view.dispatch({
|
||||
...(changes ? { changes } : {}),
|
||||
selection: { anchor: next.selectionStart, head: next.selectionEnd },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Replaces the whole document, for seeding and external replacements. */
|
||||
export function replaceDocument(view: EditorView, next: string): void {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: next },
|
||||
});
|
||||
}
|
||||
270
frontend/src/components/MarkdownEditor/markdownCommands.ts
Normal file
270
frontend/src/components/MarkdownEditor/markdownCommands.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { EditorCommand, EditorSnapshot, EditorTransform } from './types';
|
||||
|
||||
const BOLD_MARKER = '**';
|
||||
const ITALIC_MARKER = '_';
|
||||
const INLINE_CODE_MARKER = '`';
|
||||
const CODE_FENCE = '```';
|
||||
const HEADING_PREFIX = '## ';
|
||||
const BULLET_MARKER = '- ';
|
||||
|
||||
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
|
||||
const BULLET_LIST_PATTERN = /^[ \t]*[-*+] /;
|
||||
const ORDERED_LIST_PATTERN = /^[ \t]*\d+\. /;
|
||||
// Either kind of marker, matched after the indent has been split off.
|
||||
const LIST_MARKER_PATTERN = /^(?:[-*+]|\d+\.) /;
|
||||
const INDENT_PATTERN = /^[ \t]*/;
|
||||
|
||||
const LINK_LABEL_PLACEHOLDER = 'text';
|
||||
const LINK_URL_PLACEHOLDER = 'https://';
|
||||
const TABLE_CELL_PLACEHOLDER = 'Column';
|
||||
const TABLE_SNIPPET = [
|
||||
`| ${TABLE_CELL_PLACEHOLDER} | ${TABLE_CELL_PLACEHOLDER} |`,
|
||||
'| --- | --- |',
|
||||
'| | |',
|
||||
].join('\n');
|
||||
|
||||
interface LineRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
// A selection ending exactly on a line break stops there rather than pulling in
|
||||
// the next line, so "select the line, hit list" doesn't bullet the line below too.
|
||||
function expandToLines(text: string, from: number, to: number): LineRange {
|
||||
const end = to > from && text[to - 1] === '\n' ? to - 1 : to;
|
||||
const breakBefore = from === 0 ? -1 : text.lastIndexOf('\n', from - 1);
|
||||
const breakAfter = text.indexOf('\n', end);
|
||||
return {
|
||||
start: breakBefore + 1,
|
||||
end: breakAfter === -1 ? text.length : breakAfter,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pads `block` so it starts and ends on its own line. */
|
||||
function replaceWithBlock(
|
||||
snapshot: EditorSnapshot,
|
||||
block: string,
|
||||
): { text: string; blockStart: number } {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const before = text.slice(0, selectionStart);
|
||||
const after = text.slice(selectionEnd);
|
||||
const lead = before === '' || before.endsWith('\n') ? '' : '\n';
|
||||
const trail = after === '' || after.startsWith('\n') ? '' : '\n';
|
||||
return {
|
||||
text: before + lead + block + trail + after,
|
||||
blockStart: before.length + lead.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Rewrites every line the selection touches. */
|
||||
function replaceLines(
|
||||
snapshot: EditorSnapshot,
|
||||
mapLines: (lines: string[]) => string[],
|
||||
): EditorSnapshot {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const { start, end } = expandToLines(text, selectionStart, selectionEnd);
|
||||
const lines = text.slice(start, end).split('\n');
|
||||
const nextLines = mapLines(lines);
|
||||
const block = nextLines.join('\n');
|
||||
const nextText = text.slice(0, start) + block + text.slice(end);
|
||||
|
||||
if (selectionStart !== selectionEnd) {
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: start,
|
||||
selectionEnd: start + block.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Caret-only: the range covers one line, so shift by that line's delta.
|
||||
const shifted = selectionStart + nextLines[0].length - lines[0].length;
|
||||
const caret = Math.min(Math.max(shifted, start), start + nextLines[0].length);
|
||||
return { text: nextText, selectionStart: caret, selectionEnd: caret };
|
||||
}
|
||||
|
||||
/** Strips `prefix` when every selected line already matches `pattern`, else adds it. */
|
||||
function toggleLinePrefix(prefix: string, pattern: RegExp): EditorTransform {
|
||||
return (snapshot): EditorSnapshot =>
|
||||
replaceLines(snapshot, (lines) => {
|
||||
const isApplied = lines.every((line) => pattern.test(line));
|
||||
return lines.map((line) =>
|
||||
isApplied ? line.replace(pattern, '') : `${prefix}${line}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles this kind of list marker. A line carrying the *other* kind is converted
|
||||
* rather than marked twice, and indentation is preserved so nesting survives.
|
||||
* `markerAt` takes the line's position, which is what lets an ordered list number.
|
||||
*/
|
||||
function toggleList(
|
||||
pattern: RegExp,
|
||||
markerAt: (index: number) => string,
|
||||
): EditorTransform {
|
||||
return (snapshot): EditorSnapshot =>
|
||||
replaceLines(snapshot, (lines) => {
|
||||
const isApplied = lines.every((line) => pattern.test(line));
|
||||
return lines.map((line, index) => {
|
||||
const indent = INDENT_PATTERN.exec(line)?.[0] ?? '';
|
||||
const body = line
|
||||
.slice(indent.length)
|
||||
.replace(LIST_MARKER_PATTERN, '');
|
||||
return isApplied
|
||||
? `${indent}${body}`
|
||||
: `${indent}${markerAt(index)}${body}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps when the markers are already there, whether they sit inside the selection
|
||||
* (`**bold**` selected whole) or just outside it (only `bold` selected).
|
||||
*/
|
||||
function toggleWrap(marker: string): EditorTransform {
|
||||
return ({ text, selectionStart, selectionEnd }): EditorSnapshot => {
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
const width = marker.length;
|
||||
|
||||
if (
|
||||
selected.length >= width * 2 &&
|
||||
selected.startsWith(marker) &&
|
||||
selected.endsWith(marker)
|
||||
) {
|
||||
const inner = selected.slice(width, -width);
|
||||
return {
|
||||
text: text.slice(0, selectionStart) + inner + text.slice(selectionEnd),
|
||||
selectionStart,
|
||||
selectionEnd: selectionStart + inner.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
selectionStart >= width &&
|
||||
text.slice(selectionStart - width, selectionStart) === marker &&
|
||||
text.slice(selectionEnd, selectionEnd + width) === marker
|
||||
) {
|
||||
return {
|
||||
text:
|
||||
text.slice(0, selectionStart - width) +
|
||||
selected +
|
||||
text.slice(selectionEnd + width),
|
||||
selectionStart: selectionStart - width,
|
||||
selectionEnd: selectionStart - width + selected.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text:
|
||||
text.slice(0, selectionStart) +
|
||||
marker +
|
||||
selected +
|
||||
marker +
|
||||
text.slice(selectionEnd),
|
||||
selectionStart: selectionStart + width,
|
||||
selectionEnd: selectionStart + width + selected.length,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Lands the selection on whichever half is still a placeholder. */
|
||||
const insertLink: EditorTransform = ({
|
||||
text,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
}): EditorSnapshot => {
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
const label = selected || LINK_LABEL_PLACEHOLDER;
|
||||
const snippet = `[${label}](${LINK_URL_PLACEHOLDER})`;
|
||||
const nextText =
|
||||
text.slice(0, selectionStart) + snippet + text.slice(selectionEnd);
|
||||
// `[` + label + `](` is label.length + 3 characters.
|
||||
const target = selected
|
||||
? { from: selectionStart + label.length + 3, length: LINK_URL_PLACEHOLDER.length }
|
||||
: { from: selectionStart + 1, length: label.length };
|
||||
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: target.from,
|
||||
selectionEnd: target.from + target.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Backticks for a single-line selection, a fence for a multi-line one. */
|
||||
const insertCode: EditorTransform = (snapshot): EditorSnapshot => {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
if (!selected.includes('\n')) {
|
||||
return toggleWrap(INLINE_CODE_MARKER)(snapshot);
|
||||
}
|
||||
|
||||
const { text: nextText, blockStart } = replaceWithBlock(
|
||||
snapshot,
|
||||
`${CODE_FENCE}\n${selected}\n${CODE_FENCE}`,
|
||||
);
|
||||
const contentStart = blockStart + CODE_FENCE.length + 1;
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: contentStart,
|
||||
selectionEnd: contentStart + selected.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Selects the first header cell, for immediate typing. */
|
||||
const insertTable: EditorTransform = (snapshot): EditorSnapshot => {
|
||||
const { text, blockStart } = replaceWithBlock(snapshot, TABLE_SNIPPET);
|
||||
const firstCell = blockStart + TABLE_SNIPPET.indexOf(TABLE_CELL_PLACEHOLDER);
|
||||
return {
|
||||
text,
|
||||
selectionStart: firstCell,
|
||||
selectionEnd: firstCell + TABLE_CELL_PLACEHOLDER.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Replaces the selection and leaves the caret after the insertion. */
|
||||
export function insertText(
|
||||
snapshot: EditorSnapshot,
|
||||
value: string,
|
||||
): EditorSnapshot {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const caret = selectionStart + value.length;
|
||||
return {
|
||||
text: text.slice(0, selectionStart) + value + text.slice(selectionEnd),
|
||||
selectionStart: caret,
|
||||
selectionEnd: caret,
|
||||
};
|
||||
}
|
||||
|
||||
/** Display order. A new action is an entry here plus an icon in `EditorToolbar`. */
|
||||
export const MARKDOWN_COMMANDS: EditorCommand[] = [
|
||||
{
|
||||
id: 'heading',
|
||||
label: 'Heading',
|
||||
run: toggleLinePrefix(HEADING_PREFIX, HEADING_PATTERN),
|
||||
},
|
||||
{
|
||||
id: 'bold',
|
||||
label: 'Bold',
|
||||
shortcut: 'Mod-b',
|
||||
run: toggleWrap(BOLD_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'italic',
|
||||
label: 'Italic',
|
||||
shortcut: 'Mod-i',
|
||||
run: toggleWrap(ITALIC_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'bulleted-list',
|
||||
label: 'Bulleted list',
|
||||
run: toggleList(BULLET_LIST_PATTERN, () => BULLET_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'numbered-list',
|
||||
label: 'Numbered list',
|
||||
run: toggleList(ORDERED_LIST_PATTERN, (index) => `${index + 1}. `),
|
||||
},
|
||||
{ id: 'link', label: 'Link', shortcut: 'Mod-k', run: insertLink },
|
||||
{ id: 'code', label: 'Code', shortcut: 'Mod-e', run: insertCode },
|
||||
{ id: 'table', label: 'Table', run: insertTable },
|
||||
];
|
||||
151
frontend/src/components/MarkdownEditor/markdownHighlight.ts
Normal file
151
frontend/src/components/MarkdownEditor/markdownHighlight.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { Extension, Line, Range } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
type ViewUpdate,
|
||||
} from '@codemirror/view';
|
||||
|
||||
const FENCE_PATTERN = /^ {0,3}(```|~~~)/;
|
||||
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
|
||||
const QUOTE_PATTERN = /^ {0,3}> ?/;
|
||||
const LIST_MARKER_PATTERN = /^ {0,3}([-*+]|\d+\.) /;
|
||||
|
||||
/**
|
||||
* Convention: capture group 1, when present, is a left guard the token excludes —
|
||||
* the token runs from the end of that group to the end of the match. Lookbehind is
|
||||
* avoided for Safari compatibility, so guards are captured rather than asserted.
|
||||
*/
|
||||
const INLINE_PATTERNS: { pattern: RegExp; className: string }[] = [
|
||||
{ pattern: /`[^`\n]+`/g, className: 'cm-md-code' },
|
||||
{ pattern: /\*\*[^*\n]+\*\*/g, className: 'cm-md-strong' },
|
||||
{ pattern: /(^|[^\w*_`])_[^_\n]+_(?![\w_])/g, className: 'cm-md-emphasis' },
|
||||
{ pattern: /!?\[[^\]\n]*\]\([^)\n]*\)/g, className: 'cm-md-link' },
|
||||
{
|
||||
// The four variable syntaxes a dashboard body may carry.
|
||||
pattern: /\{\{\s*\.?[\w.-]+\s*\}\}|\[\[\s*[\w.-]+\s*\]\]|\$[A-Za-z_]\w*/g,
|
||||
className: 'cm-md-variable',
|
||||
},
|
||||
];
|
||||
|
||||
const MARKS = {
|
||||
heading: Decoration.mark({ class: 'cm-md-heading' }),
|
||||
quote: Decoration.mark({ class: 'cm-md-quote' }),
|
||||
listMarker: Decoration.mark({ class: 'cm-md-list-marker' }),
|
||||
code: Decoration.mark({ class: 'cm-md-code' }),
|
||||
} as const;
|
||||
|
||||
const INLINE_MARKS = INLINE_PATTERNS.map(({ pattern, className }) => ({
|
||||
pattern,
|
||||
mark: Decoration.mark({ class: className }),
|
||||
}));
|
||||
|
||||
function pushInlineMarks(
|
||||
lineText: string,
|
||||
lineFrom: number,
|
||||
ranges: Range<Decoration>[],
|
||||
): void {
|
||||
INLINE_MARKS.forEach(({ pattern, mark }) => {
|
||||
pattern.lastIndex = 0;
|
||||
let match = pattern.exec(lineText);
|
||||
while (match !== null) {
|
||||
const guardLength = match[1]?.length ?? 0;
|
||||
const from = lineFrom + match.index + guardLength;
|
||||
const to = lineFrom + match.index + match[0].length;
|
||||
if (to > from) {
|
||||
ranges.push(mark.range(from, to));
|
||||
}
|
||||
match = pattern.exec(lineText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function pushBlockMark(
|
||||
line: Line,
|
||||
ranges: Range<Decoration>[],
|
||||
): void {
|
||||
if (HEADING_PATTERN.test(line.text)) {
|
||||
ranges.push(MARKS.heading.range(line.from, line.to));
|
||||
return;
|
||||
}
|
||||
if (QUOTE_PATTERN.test(line.text)) {
|
||||
ranges.push(MARKS.quote.range(line.from, line.to));
|
||||
return;
|
||||
}
|
||||
const listMarker = LIST_MARKER_PATTERN.exec(line.text);
|
||||
if (listMarker) {
|
||||
ranges.push(
|
||||
MARKS.listMarker.range(line.from, line.from + listMarker[0].length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scans the whole document rather than the viewport: fenced blocks opening above
|
||||
// the visible range would otherwise be mis-detected. Bounded by the length cap.
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const { doc } = view.state;
|
||||
const ranges: Range<Decoration>[] = [];
|
||||
let isInsideFence = false;
|
||||
|
||||
for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber += 1) {
|
||||
const line = doc.line(lineNumber);
|
||||
const isFenceDelimiter = FENCE_PATTERN.test(line.text);
|
||||
|
||||
if (isFenceDelimiter || isInsideFence) {
|
||||
if (line.to > line.from) {
|
||||
ranges.push(MARKS.code.range(line.from, line.to));
|
||||
}
|
||||
isInsideFence = isFenceDelimiter ? !isInsideFence : isInsideFence;
|
||||
} else {
|
||||
pushBlockMark(line, ranges);
|
||||
pushInlineMarks(line.text, line.from, ranges);
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(ranges, true);
|
||||
}
|
||||
|
||||
// Colours come from custom properties so the SCSS module owns light/dark.
|
||||
const syntaxTheme = EditorView.theme({
|
||||
'.cm-md-heading': {
|
||||
color: 'var(--md-syntax-heading)',
|
||||
fontWeight: '600',
|
||||
},
|
||||
'.cm-md-quote': { color: 'var(--md-syntax-quote)', fontStyle: 'italic' },
|
||||
'.cm-md-list-marker': { color: 'var(--md-syntax-marker)' },
|
||||
'.cm-md-code': { color: 'var(--md-syntax-code)' },
|
||||
'.cm-md-strong': { color: 'var(--md-syntax-strong)', fontWeight: '600' },
|
||||
'.cm-md-emphasis': {
|
||||
color: 'var(--md-syntax-emphasis)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'.cm-md-link': { color: 'var(--md-syntax-link)' },
|
||||
'.cm-md-variable': { color: 'var(--md-syntax-variable)' },
|
||||
});
|
||||
|
||||
const highlightPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (plugin): DecorationSet => plugin.decorations },
|
||||
);
|
||||
|
||||
/**
|
||||
* Decorations rather than a grammar, so the editor stays on the CodeMirror packages
|
||||
* already bundled — no `@codemirror/lang-markdown` / `@lezer` for what is only a
|
||||
* colouring pass over a body the renderer parses for real.
|
||||
*/
|
||||
export function markdownHighlight(): Extension {
|
||||
return [highlightPlugin, syntaxTheme];
|
||||
}
|
||||
19
frontend/src/components/MarkdownEditor/shortcut.ts
Normal file
19
frontend/src/components/MarkdownEditor/shortcut.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
const isMacPlatform = (): boolean =>
|
||||
typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform);
|
||||
|
||||
/** `Mod-b` → `⌘B` / `Ctrl+B`. */
|
||||
export function formatShortcut(binding: string): string {
|
||||
const isMac = isMacPlatform();
|
||||
return binding
|
||||
.split('-')
|
||||
.map((part) => {
|
||||
if (part === 'Mod') {
|
||||
return isMac ? '⌘' : 'Ctrl';
|
||||
}
|
||||
if (part === 'Shift') {
|
||||
return isMac ? '⇧' : 'Shift';
|
||||
}
|
||||
return part.toUpperCase();
|
||||
})
|
||||
.join(isMac ? '' : '+');
|
||||
}
|
||||
29
frontend/src/components/MarkdownEditor/types.ts
Normal file
29
frontend/src/components/MarkdownEditor/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/** The value every editor command reads and returns. */
|
||||
export interface EditorSnapshot {
|
||||
text: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
export type EditorTransform = (snapshot: EditorSnapshot) => EditorSnapshot;
|
||||
|
||||
export interface EditorVariable {
|
||||
name: string;
|
||||
/** Grouping label, e.g. "Dashboard variable". Ungrouped when omitted. */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface EditorCommand {
|
||||
id: string;
|
||||
/** Accessible name and tooltip for the toolbar button. */
|
||||
label: string;
|
||||
/** CodeMirror key binding, e.g. `Mod-b`. */
|
||||
shortcut?: string;
|
||||
run: EditorTransform;
|
||||
}
|
||||
|
||||
/** 1-based, as the status bar reports it. */
|
||||
export interface CursorPosition {
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { evaluateThresholdWithConvertedValue } from 'container/WidgetCard/TablePanel/utils';
|
||||
import { evaluateThresholdWithConvertedValue } from 'container/WidgetCard/Panels/TablePanel/utils';
|
||||
import { ThresholdProps } from 'types/api/widgets/threshold';
|
||||
|
||||
function doesValueSatisfyThreshold(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Uplot from 'components/Uplot';
|
||||
import GridTableComponent from 'container/WidgetCard/TablePanel';
|
||||
import GridValueComponent from 'container/WidgetCard/ValuePanel';
|
||||
import GridTableComponent from 'container/WidgetCard/Panels/TablePanel';
|
||||
import GridValueComponent from 'container/WidgetCard/Panels/ValuePanel';
|
||||
import LogsPanelComponent from 'container/LogsPanelTable/LogsPanelComponent';
|
||||
import TracesTableComponent from 'container/TracesTableComponent/TracesTableComponent';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MessageContext } from 'api/ai-assistant/chat';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AlertListTabs } from 'pages/AlertList/types';
|
||||
import { NEW_PANEL_ID } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { NEW_PANEL_ID } from 'pages/DashboardPage/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { buildExportPanelLink } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { buildExportPanelLink } from 'pages/DashboardPage/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
|
||||
import ExplorerOptionWrapper from '../ExplorerOptionWrapper';
|
||||
import { getExplorerToolBarVisibility } from '../utils';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import {
|
||||
@@ -76,7 +75,7 @@ export function buildEntityMetricsChartConfig({
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
isTimeAxis: true,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
@@ -85,7 +84,6 @@ export function buildEntityMetricsChartConfig({
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
if (!apiResponse?.data?.result) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import DashboardContainer from 'pages/DashboardPageV2/DashboardContainer';
|
||||
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
|
||||
|
||||
import { useSeededDashboardV2 } from './hooks/useSeededDashboardV2';
|
||||
import styles from './Overview.module.scss';
|
||||
|
||||
@@ -13,7 +13,7 @@ import LLMObservability from '../LLMObservability';
|
||||
// The Overview tab renders the full V2 DashboardContainer (toolbar + date picker
|
||||
// call useNavigationType, which needs a data router this integration test doesn't
|
||||
// set up). These cases assert tab routing, not dashboard rendering, so stub it.
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer', () => ({
|
||||
jest.mock('pages/DashboardPage/DashboardContainer', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="llm-overview-dashboard" />,
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import {
|
||||
@@ -72,7 +71,7 @@ export function buildMeterChartConfig({
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
isTimeAxis: true,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
@@ -81,7 +80,6 @@ export function buildMeterChartConfig({
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
});
|
||||
|
||||
if (!apiResponse?.data?.result) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
fromPerses,
|
||||
toPerses,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { ClickedData } from 'periscope/components/ContextMenu';
|
||||
|
||||
import { getGroupContextMenuConfig } from '../contextConfig';
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { isApmMetric } from 'container/WidgetCard/PanelWrapper/utils';
|
||||
import { isApmMetric } from 'container/WidgetCard/Panels/utils';
|
||||
import {
|
||||
applyMappingsToExpression,
|
||||
DRILLDOWN_TO_LOGS_MAPPINGS,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import PanelWrapper from 'container/WidgetCard/PanelWrapper/PanelWrapper';
|
||||
import PanelWrapper from 'container/WidgetCard/Panels/PanelWrapper';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
@@ -10,7 +10,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('container/WidgetCard/PanelWrapper/constants', () => ({
|
||||
jest.mock('container/WidgetCard/Panels/constants', () => ({
|
||||
PanelTypeVsPanelWrapper: {
|
||||
[PANEL_TYPES.TIME_SERIES]: ({
|
||||
onDragSelect,
|
||||
|
||||
@@ -26,7 +26,7 @@ import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import useDrilldown from 'container/WidgetCard/Card/FullView/useDrilldown';
|
||||
import { populateMultipleResults } from 'lib/query/populateMultipleResults';
|
||||
import { timeItems, timePreferance } from 'constants/timePreference';
|
||||
import PanelWrapper from 'container/WidgetCard/PanelWrapper/PanelWrapper';
|
||||
import PanelWrapper from 'container/WidgetCard/Panels/PanelWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import PanelWrapper from 'container/WidgetCard/PanelWrapper/PanelWrapper';
|
||||
import PanelWrapper from 'container/WidgetCard/Panels/PanelWrapper';
|
||||
import useGetResolvedText from 'hooks/dashboard/useGetResolvedText';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import BarPanel from 'container/WidgetCard/PanelWrapper/panels/BarPanel/BarPanel';
|
||||
import HistogramPanel from 'container/WidgetCard/PanelWrapper/panels/HistogramPanel/HistogramPanel';
|
||||
|
||||
import TimeSeriesPanel from 'container/WidgetCard/PanelWrapper/panels/TimeSeriesPanel/TimeSeriesPanel';
|
||||
import ListPanelWrapper from 'container/WidgetCard/PanelWrapper/ListPanelWrapper';
|
||||
import PiePanelWrapper from 'container/WidgetCard/PanelWrapper/PiePanelWrapper';
|
||||
import TablePanelWrapper from 'container/WidgetCard/PanelWrapper/TablePanelWrapper';
|
||||
import ValuePanelWrapper from 'container/WidgetCard/PanelWrapper/ValuePanelWrapper';
|
||||
|
||||
export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.TIME_SERIES]: TimeSeriesPanel,
|
||||
[PANEL_TYPES.TABLE]: TablePanelWrapper,
|
||||
[PANEL_TYPES.LIST]: ListPanelWrapper,
|
||||
[PANEL_TYPES.VALUE]: ValuePanelWrapper,
|
||||
[PANEL_TYPES.TRACE]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
[PANEL_TYPES.PIE]: PiePanelWrapper,
|
||||
[PANEL_TYPES.BAR]: BarPanel,
|
||||
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PanelWrapperProps } from 'container/WidgetCard/PanelWrapper/panelWrapper.types';
|
||||
import { PanelWrapperProps } from 'container/WidgetCard/Panels/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import {
|
||||
@@ -17,11 +17,11 @@ import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import BarChart from 'lib/visualization/charts/BarChart/BarChart';
|
||||
import ChartManager from 'lib/visualization/components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from 'container/WidgetCard/PanelWrapper/hooks/usePanelContextMenu';
|
||||
import { usePanelContextMenu } from 'container/WidgetCard/Panels/hooks/usePanelContextMenu';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import { prepareBarPanelConfig } from 'container/WidgetCard/PanelWrapper/panels/BarPanel/utils';
|
||||
import { prepareBarPanelConfig } from 'container/WidgetCard/Panels/BarPanel/utils';
|
||||
|
||||
import 'container/WidgetCard/PanelWrapper/panels/Panel.styles.scss';
|
||||
import 'container/WidgetCard/Panels/Panel.styles.scss';
|
||||
import TooltipFooter from 'lib/visualization/panels/components/TooltipFooter';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import { prepareBarPanelConfig } from 'container/WidgetCard/PanelWrapper/panels/BarPanel/utils';
|
||||
import { prepareBarPanelConfig } from 'container/WidgetCard/Panels/BarPanel/utils';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
|
||||
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { PanelWrapperProps } from 'container/WidgetCard/PanelWrapper/panelWrapper.types';
|
||||
import { PanelWrapperProps } from 'container/WidgetCard/Panels/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import {
|
||||
@@ -13,9 +13,9 @@ import ChartManager from 'lib/visualization/components/ChartManager/ChartManager
|
||||
import {
|
||||
prepareHistogramPanelConfig,
|
||||
prepareHistogramPanelData,
|
||||
} from 'container/WidgetCard/PanelWrapper/panels/HistogramPanel/utils';
|
||||
} from 'container/WidgetCard/Panels/HistogramPanel/utils';
|
||||
|
||||
import 'container/WidgetCard/PanelWrapper/panels/Panel.styles.scss';
|
||||
import 'container/WidgetCard/Panels/Panel.styles.scss';
|
||||
import TooltipFooter from 'lib/visualization/panels/components/TooltipFooter';
|
||||
|
||||
function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
MetricRangePayloadProps,
|
||||
} from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import HistogramPanel from 'container/WidgetCard/PanelWrapper/panels/HistogramPanel/HistogramPanel';
|
||||
import HistogramPanel from 'container/WidgetCard/Panels/HistogramPanel/HistogramPanel';
|
||||
|
||||
jest.mock('hooks/useDimensions', () => ({
|
||||
useResizeObserver: jest.fn().mockReturnValue({ width: 800, height: 400 }),
|
||||
@@ -2,7 +2,7 @@ import LogsPanelComponent from 'container/LogsPanelTable/LogsPanelComponent';
|
||||
import TracesTableComponent from 'container/TracesTableComponent/TracesTableComponent';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { PanelWrapperProps } from 'container/WidgetCard/PanelWrapper/panelWrapper.types';
|
||||
import { PanelWrapperProps } from 'container/WidgetCard/Panels/panelWrapper.types';
|
||||
|
||||
function ListPanelWrapper({
|
||||
widget,
|
||||
@@ -2,8 +2,8 @@ import { FC, useMemo } from 'react';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { PanelTypeVsPanelWrapper } from 'container/WidgetCard/PanelWrapper/constants';
|
||||
import { PanelWrapperProps } from 'container/WidgetCard/PanelWrapper/panelWrapper.types';
|
||||
import { PanelTypeVsPanelWrapper } from 'container/WidgetCard/Panels/constants';
|
||||
import { PanelWrapperProps } from 'container/WidgetCard/Panels/panelWrapper.types';
|
||||
|
||||
function PanelWrapper({
|
||||
widget,
|
||||
@@ -13,14 +13,11 @@ import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import {
|
||||
PanelWrapperProps,
|
||||
TooltipData,
|
||||
} from 'container/WidgetCard/PanelWrapper/panelWrapper.types';
|
||||
import { preparePieChartData } from 'container/WidgetCard/PanelWrapper/preparePieChartData';
|
||||
import {
|
||||
lightenColor,
|
||||
tooltipStyles,
|
||||
} from 'container/WidgetCard/PanelWrapper/utils';
|
||||
} from 'container/WidgetCard/Panels/panelWrapper.types';
|
||||
import { preparePieChartData } from 'container/WidgetCard/Panels/preparePieChartData';
|
||||
import { lightenColor, tooltipStyles } from 'container/WidgetCard/Panels/utils';
|
||||
|
||||
import 'container/WidgetCard/PanelWrapper/PiePanelWrapper.styles.scss';
|
||||
import 'container/WidgetCard/Panels/PiePanelWrapper.styles.scss';
|
||||
|
||||
// reference: https://www.youtube.com/watch?v=bL3P9CqQkKw
|
||||
function PiePanelWrapper({
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
createColumnsAndDataSource,
|
||||
getQueryLegend,
|
||||
sortFunction,
|
||||
} from 'container/WidgetCard/TablePanel/utils';
|
||||
} from 'container/WidgetCard/Panels/TablePanel/utils';
|
||||
import {
|
||||
expectedOutputQBv5MultiAggregations,
|
||||
expectedOutputWithLegends,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
tableDataQBv5MultiAggregations,
|
||||
widgetQueryQBv5MultiAggregations,
|
||||
widgetQueryWithLegend,
|
||||
} from 'container/WidgetCard/TablePanel/__tests__/response';
|
||||
} from 'container/WidgetCard/Panels/TablePanel/__tests__/response';
|
||||
|
||||
describe('Table Panel utils', () => {
|
||||
it('createColumnsAndDataSource function', () => {
|
||||
@@ -12,15 +12,15 @@ import LineClampedText from 'periscope/components/LineClampedText/LineClampedTex
|
||||
import styled from 'styled-components';
|
||||
import { eventEmitter } from 'utils/getEventEmitter';
|
||||
|
||||
import { WrapperStyled } from 'container/WidgetCard/TablePanel/styles';
|
||||
import { GridTableComponentProps } from 'container/WidgetCard/TablePanel/types';
|
||||
import { WrapperStyled } from 'container/WidgetCard/Panels/TablePanel/styles';
|
||||
import { GridTableComponentProps } from 'container/WidgetCard/Panels/TablePanel/types';
|
||||
import {
|
||||
createColumnsAndDataSource,
|
||||
findMatchingThreshold,
|
||||
TableData,
|
||||
} from 'container/WidgetCard/TablePanel/utils';
|
||||
} from 'container/WidgetCard/Panels/TablePanel/utils';
|
||||
|
||||
import 'container/WidgetCard/TablePanel/GridTableComponent.styles.scss';
|
||||
import 'container/WidgetCard/Panels/TablePanel/GridTableComponent.styles.scss';
|
||||
|
||||
const ButtonWrapper = styled.div`
|
||||
position: absolute;
|
||||
@@ -1,8 +1,8 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GridTableComponent from 'container/WidgetCard/TablePanel';
|
||||
import { GRID_TABLE_CONFIG } from 'container/WidgetCard/TablePanel/config';
|
||||
import GridTableComponent from 'container/WidgetCard/Panels/TablePanel';
|
||||
import { GRID_TABLE_CONFIG } from 'container/WidgetCard/Panels/TablePanel/config';
|
||||
|
||||
import { PanelWrapperProps } from 'container/WidgetCard/PanelWrapper/panelWrapper.types';
|
||||
import { PanelWrapperProps } from 'container/WidgetCard/Panels/panelWrapper.types';
|
||||
|
||||
function TablePanelWrapper({
|
||||
widget,
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import TimeSeries from 'lib/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'lib/visualization/components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from 'container/WidgetCard/PanelWrapper/hooks/usePanelContextMenu';
|
||||
import { PanelWrapperProps } from 'container/WidgetCard/PanelWrapper/panelWrapper.types';
|
||||
import { usePanelContextMenu } from 'container/WidgetCard/Panels/hooks/usePanelContextMenu';
|
||||
import { PanelWrapperProps } from 'container/WidgetCard/Panels/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import {
|
||||
@@ -18,10 +18,10 @@ import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import { prepareUPlotConfig } from 'container/WidgetCard/PanelWrapper/panels/TimeSeriesPanel/utils';
|
||||
import { prepareUPlotConfig } from 'container/WidgetCard/Panels/TimeSeriesPanel/utils';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
|
||||
import 'container/WidgetCard/PanelWrapper/panels/Panel.styles.scss';
|
||||
import 'container/WidgetCard/Panels/Panel.styles.scss';
|
||||
import TooltipFooter from 'lib/visualization/panels/components/TooltipFooter';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import { prepareUPlotConfig } from 'container/WidgetCard/PanelWrapper/panels/TimeSeriesPanel/utils';
|
||||
import { prepareUPlotConfig } from 'container/WidgetCard/Panels/TimeSeriesPanel/utils';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
|
||||
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
|
||||
@@ -11,8 +11,8 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import {
|
||||
TitleContainer,
|
||||
ValueContainer,
|
||||
} from 'container/WidgetCard/ValuePanel/styles';
|
||||
import { GridValueComponentProps } from 'container/WidgetCard/ValuePanel/types';
|
||||
} from 'container/WidgetCard/Panels/ValuePanel/styles';
|
||||
import { GridValueComponentProps } from 'container/WidgetCard/Panels/ValuePanel/types';
|
||||
|
||||
function GridValueComponent({
|
||||
data,
|
||||
@@ -1,7 +1,7 @@
|
||||
import GridValueComponent from 'container/WidgetCard/ValuePanel';
|
||||
import GridValueComponent from 'container/WidgetCard/Panels/ValuePanel';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
|
||||
import { PanelWrapperProps } from 'container/WidgetCard/PanelWrapper/panelWrapper.types';
|
||||
import { PanelWrapperProps } from 'container/WidgetCard/Panels/panelWrapper.types';
|
||||
|
||||
function ValuePanelWrapper({
|
||||
widget,
|
||||
@@ -2,11 +2,11 @@ import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
|
||||
import TablePanelWrapper from 'container/WidgetCard/PanelWrapper/TablePanelWrapper';
|
||||
import TablePanelWrapper from 'container/WidgetCard/Panels/TablePanelWrapper';
|
||||
import {
|
||||
tablePanelQueryResponse,
|
||||
tablePanelWidgetQuery,
|
||||
} from 'container/WidgetCard/PanelWrapper/__tests__/tablePanelWrapperHelper';
|
||||
} from 'container/WidgetCard/Panels/__tests__/tablePanelWrapperHelper';
|
||||
|
||||
describe('Table panel wrappper tests', () => {
|
||||
it('table should render fine with the query response and column units', () => {
|
||||
@@ -2,12 +2,12 @@ import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
|
||||
import ValuePanelWrapper from 'container/WidgetCard/PanelWrapper/ValuePanelWrapper';
|
||||
import ValuePanelWrapper from 'container/WidgetCard/Panels/ValuePanelWrapper';
|
||||
import {
|
||||
thresholds,
|
||||
valuePanelQueryResponse,
|
||||
valuePanelWidget,
|
||||
} from 'container/WidgetCard/PanelWrapper/__tests__/valuePanelWrapperHelper';
|
||||
} from 'container/WidgetCard/Panels/__tests__/valuePanelWrapperHelper';
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
applyEnhancedLegendStyling,
|
||||
calculateEnhancedLegendConfig,
|
||||
EnhancedLegendConfig,
|
||||
} from 'container/WidgetCard/PanelWrapper/enhancedLegend';
|
||||
} from 'container/WidgetCard/Panels/enhancedLegend';
|
||||
|
||||
describe('Enhanced Legend Functionality', () => {
|
||||
const mockDimensions: Dimensions = {
|
||||
@@ -7,7 +7,7 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import {
|
||||
getMockQuery,
|
||||
getMockQueryData,
|
||||
} from 'container/WidgetCard/PanelWrapper/__tests__/testUtils';
|
||||
} from 'container/WidgetCard/Panels/__tests__/testUtils';
|
||||
|
||||
const mockQueryData = getMockQueryData();
|
||||
const mockQuery = getMockQuery();
|
||||
@@ -18,7 +18,7 @@ jest.mock('uplot', () => {
|
||||
});
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('container/WidgetCard/PanelWrapper/enhancedLegend', () => ({
|
||||
jest.mock('container/WidgetCard/Panels/enhancedLegend', () => ({
|
||||
calculateEnhancedLegendConfig: jest.fn(() => ({
|
||||
minHeight: 46,
|
||||
maxHeight: 80,
|
||||
@@ -2,7 +2,7 @@ import { themeColors } from 'constants/theme';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
import { preparePieChartData } from 'container/WidgetCard/PanelWrapper/preparePieChartData';
|
||||
import { preparePieChartData } from 'container/WidgetCard/Panels/preparePieChartData';
|
||||
|
||||
const options = { colorMap: themeColors.chartcolors };
|
||||
|
||||
21
frontend/src/container/WidgetCard/Panels/constants.ts
Normal file
21
frontend/src/container/WidgetCard/Panels/constants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import BarPanel from 'container/WidgetCard/Panels/BarPanel/BarPanel';
|
||||
import HistogramPanel from 'container/WidgetCard/Panels/HistogramPanel/HistogramPanel';
|
||||
|
||||
import TimeSeriesPanel from 'container/WidgetCard/Panels/TimeSeriesPanel/TimeSeriesPanel';
|
||||
import ListPanelWrapper from 'container/WidgetCard/Panels/ListPanelWrapper';
|
||||
import PiePanelWrapper from 'container/WidgetCard/Panels/PiePanelWrapper';
|
||||
import TablePanelWrapper from 'container/WidgetCard/Panels/TablePanelWrapper';
|
||||
import ValuePanelWrapper from 'container/WidgetCard/Panels/ValuePanelWrapper';
|
||||
|
||||
export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.TIME_SERIES]: TimeSeriesPanel,
|
||||
[PANEL_TYPES.TABLE]: TablePanelWrapper,
|
||||
[PANEL_TYPES.LIST]: ListPanelWrapper,
|
||||
[PANEL_TYPES.VALUE]: ValuePanelWrapper,
|
||||
[PANEL_TYPES.TRACE]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
[PANEL_TYPES.PIE]: PiePanelWrapper,
|
||||
[PANEL_TYPES.BAR]: BarPanel,
|
||||
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { UseQueryResult } from 'react-query';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import { usePanelContextMenu } from 'container/WidgetCard/PanelWrapper/hooks/usePanelContextMenu';
|
||||
import { usePanelContextMenu } from 'container/WidgetCard/Panels/hooks/usePanelContextMenu';
|
||||
|
||||
// The hook composes `useCoordinates` (popover state) and `useGraphContextMenu`
|
||||
// (menu items). We mock both so the test focuses on the `enableDrillDown` gate
|
||||
@@ -37,7 +37,7 @@ jest.mock('container/QueryTable/Drilldown/drilldownUtils', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('container/WidgetCard/PanelWrapper/utils', () => ({
|
||||
jest.mock('container/WidgetCard/Panels/utils', () => ({
|
||||
isApmMetric: jest.fn(() => false),
|
||||
getTimeRangeFromStepInterval: jest.fn(() => ({ start: 0, end: 0 })),
|
||||
}));
|
||||
@@ -3,7 +3,7 @@ import { UseQueryResult } from 'react-query';
|
||||
import {
|
||||
getTimeRangeFromStepInterval,
|
||||
isApmMetric,
|
||||
} from 'container/WidgetCard/PanelWrapper/utils';
|
||||
} from 'container/WidgetCard/Panels/utils';
|
||||
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { buildExportPanelLink } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { buildExportPanelLink } from 'pages/DashboardPage/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
interface ExportToDashboardLinkParams {
|
||||
|
||||
@@ -26,7 +26,7 @@ import { getGraphType } from 'utils/getGraphType';
|
||||
/**
|
||||
* @deprecated V1-only. V2 dashboards seed alerts from a panel via
|
||||
* `useCreateAlertFromPanel` / `buildCreateAlertUrl`
|
||||
* (pages/DashboardPageV2/.../Panel). Do not use in new code.
|
||||
* (pages/DashboardPage/.../Panel). Do not use in new code.
|
||||
*/
|
||||
const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
const queryRangeMutation = useMutation(getSubstituteVars);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { IDashboardVariable } from 'types/api/dashboard/variables';
|
||||
*
|
||||
* Keyed on `IDashboardVariable`. The V2 editor has a parallel implementation
|
||||
* over its own flat form model in
|
||||
* `pages/DashboardPageV2/DashboardContainer/VariablesBar/utils/variableDependencies.ts`.
|
||||
* `pages/DashboardPage/DashboardContainer/VariablesBar/utils/variableDependencies.ts`.
|
||||
*/
|
||||
|
||||
export type VariableGraph = Record<string, string[]>;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ThresholdProps } from 'types/api/widgets/threshold';
|
||||
import {
|
||||
applyEnhancedLegendStyling,
|
||||
calculateEnhancedLegendConfig,
|
||||
} from 'container/WidgetCard/PanelWrapper/enhancedLegend';
|
||||
} from 'container/WidgetCard/Panels/enhancedLegend';
|
||||
import { Dimensions } from 'hooks/useDimensions';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import { convertValue } from 'lib/getConvertedValue';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import uPlot, { Axis } from 'uplot';
|
||||
|
||||
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
|
||||
@@ -7,11 +6,6 @@ import getGridColor from '../../uPlotLib/utils/getGridColor';
|
||||
import { buildYAxisSizeCalculator } from '../utils/axis';
|
||||
import { AxisProps, ConfigBuilder } from './types';
|
||||
|
||||
const PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT = [
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
PANEL_TYPES.BAR,
|
||||
];
|
||||
|
||||
/**
|
||||
* Builder for uPlot axis configuration
|
||||
* Handles creation and merging of axis settings
|
||||
@@ -67,12 +61,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
* Build values formatter for X-axis (time)
|
||||
*/
|
||||
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
|
||||
const { panelType } = this.props;
|
||||
const { isTimeAxis } = this.props;
|
||||
|
||||
if (
|
||||
panelType &&
|
||||
PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT.includes(panelType)
|
||||
) {
|
||||
if (isTimeAxis) {
|
||||
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
@@ -137,11 +136,11 @@ describe('UPlotAxisBuilder', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses time-based X-axis values formatter for time-series like panels', () => {
|
||||
it('uses time-based X-axis values formatter when the caller declares a time axis', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
isTimeAxis: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -150,11 +149,11 @@ describe('UPlotAxisBuilder', () => {
|
||||
expect(config.values).toBe(uPlotXAxisValuesFormat);
|
||||
});
|
||||
|
||||
it('does not attach X-axis datetime formatter when panel type is not supported', () => {
|
||||
it('does not attach X-axis datetime formatter for a non-time axis', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.LIST, // not in PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT
|
||||
isTimeAxis: false,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -290,22 +289,9 @@ describe('UPlotAxisBuilder', () => {
|
||||
expect(config.space).toBe(50);
|
||||
});
|
||||
|
||||
it('includes PANEL_TYPES.BAR and PANEL_TYPES.TIME_SERIES in X-axis datetime formatter', () => {
|
||||
const barBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
}),
|
||||
);
|
||||
expect(barBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
|
||||
|
||||
const timeSeriesBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
);
|
||||
expect(timeSeriesBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
|
||||
it('omits the X-axis datetime formatter when no time axis is declared', () => {
|
||||
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'x' }));
|
||||
expect(builder.getConfig().values).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the existing size when cycleNum > 1', () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import uPlot, { Series } from 'uplot';
|
||||
|
||||
import { ThresholdsDrawHookOptions } from '../hooks/types';
|
||||
@@ -53,31 +52,50 @@ export interface ConfigBuilderProps {
|
||||
* Props for configuring an axis
|
||||
*/
|
||||
export interface AxisProps {
|
||||
/** Scale this axis is drawn against — `'x'` / `'y'`, matching an `addScale` key. Also
|
||||
* selects the default tick formatter and sizing (x: time, y: value + unit). */
|
||||
scaleKey: string;
|
||||
/** Axis title drawn alongside the ticks; omitted when there's nothing to name. */
|
||||
label?: string;
|
||||
/** Render the axis at all; false keeps the scale but draws no ticks or labels. */
|
||||
show?: boolean;
|
||||
side?: 0 | 1 | 2 | 3; // top, right, bottom, left
|
||||
/** Which edge of the plot the axis sits on: 0 | 1 | 2 | 3 — top, right, bottom, left. */
|
||||
side?: 0 | 1 | 2 | 3;
|
||||
/** Tick/label color. Defaults to black or white from `isDarkMode`. */
|
||||
stroke?: string;
|
||||
/** Partial override of the grid lines; unset keys fall back to the theme defaults. */
|
||||
grid?: {
|
||||
stroke?: string;
|
||||
width?: number;
|
||||
show?: boolean;
|
||||
};
|
||||
/** Partial override of the tick marks; provided as-is to uPlot when set. */
|
||||
ticks?: {
|
||||
stroke?: string;
|
||||
width?: number;
|
||||
show?: boolean;
|
||||
size?: number;
|
||||
};
|
||||
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
|
||||
values?: uPlot.Axis.Values;
|
||||
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
|
||||
gap?: number;
|
||||
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */
|
||||
size?: uPlot.Axis.Size;
|
||||
formatValue?: (v: number) => string;
|
||||
space?: number; // Space for log scale axes
|
||||
/** Picks the dark or light default for stroke and grid color. */
|
||||
isDarkMode?: boolean;
|
||||
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
|
||||
isLogScale?: boolean;
|
||||
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`). */
|
||||
yAxisUnit?: string;
|
||||
panelType?: PANEL_TYPES;
|
||||
/**
|
||||
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
|
||||
* rather than inferred from a panel type — a chart whose x axis is buckets or categories
|
||||
* (histogram) leaves it off.
|
||||
*/
|
||||
isTimeAxis?: boolean;
|
||||
/** Decimal places for y axis tick values; unset lets the unit formatter decide. */
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,6 @@ export default function ChartManager({
|
||||
onToggleSeriesOnOff: handleToggleSeriesOnOff,
|
||||
onToggleSeriesVisibility,
|
||||
yAxisUnit,
|
||||
isGraphDisabled: false,
|
||||
decimalPrecision,
|
||||
}),
|
||||
[
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface GetChartManagerColumnsParams {
|
||||
onToggleSeriesVisibility: (index: number) => void;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
isGraphDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function getChartManagerColumns({
|
||||
@@ -26,7 +25,6 @@ export function getChartManagerColumns({
|
||||
onToggleSeriesVisibility,
|
||||
yAxisUnit,
|
||||
decimalPrecision = PrecisionOptionsEnum.TWO,
|
||||
isGraphDisabled,
|
||||
}: GetChartManagerColumnsParams): ColumnType<ExtendedChartDataset>[] {
|
||||
return [
|
||||
{
|
||||
@@ -39,7 +37,6 @@ export function getChartManagerColumns({
|
||||
data={tableDataSet}
|
||||
graphVisibilityState={graphVisibilityState}
|
||||
index={record.index}
|
||||
disabled={isGraphDisabled}
|
||||
checkBoxOnChangeHandler={(_e, idx): void => onToggleSeriesOnOff(idx)}
|
||||
/>
|
||||
),
|
||||
@@ -53,7 +50,6 @@ export function getChartManagerColumns({
|
||||
<SeriesLabel
|
||||
label={label ?? ''}
|
||||
labelIndex={record.index}
|
||||
disabled={isGraphDisabled}
|
||||
onClick={onToggleSeriesVisibility}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -124,7 +124,9 @@ export function buildBaseConfig({
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
isLogScale,
|
||||
panelType,
|
||||
// Graph and bar plot time on X; every other panel type here does not.
|
||||
isTimeAxis:
|
||||
panelType === PANEL_TYPES.TIME_SERIES || panelType === PANEL_TYPES.BAR,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
@@ -134,7 +136,6 @@ export function buildBaseConfig({
|
||||
isDarkMode,
|
||||
isLogScale,
|
||||
yAxisUnit,
|
||||
panelType,
|
||||
});
|
||||
|
||||
return builder;
|
||||
|
||||
@@ -28,7 +28,7 @@ import { cloneDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -11,7 +11,7 @@ import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useDashboardPreferencesStore } from 'hooks/dashboard/useDashboardPreference';
|
||||
import history from 'lib/history';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { linkifyText } from 'utils/linkifyText';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -8,7 +8,7 @@ import cx from 'classnames';
|
||||
import { Drawer } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user